这篇文章来源是测试发现的一个bug, 为了解决这个问题,深入分析了部分Glide源码和Android View的绘制原理,在这里做个记录。
问题描述
这个bug是这样的:在商品详情页面,图片展示详情的时候,会出现如下问题:
左边是刚进入详情页面的时候, 右边是详情页面往下滑动,再回到原来的位置展示的情况。会发现:左边是正常的,图片所有内容都正常展示在view中,而右边,图好像“变大“了,部分内容都超出了控件范围。
首先来看看我在图片详情列表的实现,详情是由一个图片列表构成的,我在这里用了RecyclerView,ViewHolder则是一个ImageView布局,然后在onBindView的时候进行如下操作:
public void setContent(Content content){
if(content.getType() == Content.TYPE_IMAGE){
textView.setVisibility(View.GONE);
imageView.setVisibility(View.VISIBLE);
ImageInfo imageInfo = content.getPhoto();
int height = imageInfo.getHeight() * photoWidth / imageInfo.getWidth();
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(photoWidth, height);
layoutParams.setMargins(margin, 0, margin, 0);
imageView.setLayoutParams(layoutParams);
imageView.setImageUrl(imageInfo.getUrl());
}
}
这里用了Content这个结构来包含图片或者文字两种情况, 如果是图片,则获取图片的尺寸,然后设置ImageView的宽高(由于图片尺寸不一,宽度统一为屏幕宽度,高度会根据当前图片比例设置),最后imageView设置网络图片路径,这里imageView是封装了Glide功能的网络图片控件。
分析问题
首先,列出与这个问题相关的点:1. 列表RecyclerView;2. Glide网络图片库; 3. 设置控件尺寸setLayoutParams;
- 列表RecyclerView
根据上述问题的描述,刚进入商品详情页面的时候,图片展示是正常的,而滑动以后,再回到原来的位置,出现bug。那么可以想到,recyclerView有一个View复用机制,当列表滑动的时候,下面的View不会重新构建,而是复用上面已经滑出界面的View。
那么,容易发现一个问题, 由于这里控件的尺寸是动态设置的,复用的imageView的尺寸与要展示的图片尺寸会不一样。当然,我也知道会不一样,所以在代码中动态获取尺寸,并且设置ImageView尺寸。 - Glide
我在比较早之前有一篇对比过fresco与glide网络加载图片对比(Fresco/Glide) ,其中提到一个内容就是:Glide会根据传入ImageView控件的尺寸对Bitmap进行缩放,获取相应尺寸的Bitmap。
说到这里,再看看上面bug的情况,应该大致可以推断出:当滑动的时候,我在代码中设置的setLayoutParams还没生效,Glide获取的ImageView控件尺寸仍然是复用的imageView尺寸,然后根据这个错误的尺寸,获取了Bitmap对象bitmap1,当setlayoutParams生效的时候,再展示bitmap1,就出现了问题描述中的情况。
验证推断
为了验证上述分析问题中的推断, 我们需要深入源码看看,其中列表RecyclerView这部分的机制这里不做具体分析了, 主要看看以下两部分源码: 1, Glide获取图片尺寸的机制; 2, setLayoutParams这个函数在源码中做了哪些操作。
Glide源码分析
Glide.with(context).load(imageUrl).into(imageView);
这是一段Glide使用的代码,非常简单,我们分别看看以上3个步骤分别做了什么:
- Glide.with(context)
生成了一个RequestManager对象,它作为Glide的“总管”,管理着Lifecycle(生命周期的绑定), RequestTracker(请求列表管理)等Glide核心成员。public static RequestManager with(Context context) { RequestManagerRetriever retriever = RequestManagerRetriever.get(); return retriever.get(context); }
-
load(imageUrl)
public <T> DrawableTypeRequest<T> load(T model) { return (DrawableTypeRequest<T>) loadGeneric(getSafeClass(model)).load(model); } private <T> DrawableTypeRequest<T> loadGeneric(Class<T> modelClass) { ModelLoader<T, InputStream> streamModelLoader = Glide.buildStreamModelLoader(modelClass, context); ModelLoader<T, ParcelFileDescriptor> fileDescriptorModelLoader = Glide.buildFileDescriptorModelLoader(modelClass, context); return optionsApplier.apply( new DrawableTypeRequest<T>(modelClass, streamModelLoader, fileDescriptorModelLoader, context, glide, requestTracker, lifecycle, optionsApplier)); }
这里生成了一个DrawableTypeRequest对象,它是一个request对象的Builder模式,在这一步会通过imageUrl构造RequstBuilder。
-
into(imageView)
这是Glide网络图片请求的最后一步,也是最核心的一步,into()方法是由上述DrawableTypeRequest基类GenericRequestBuilder实现的public Target<TranscodeType> into(ImageView view) { Util.assertMainThread(); if (view == null) { throw new IllegalArgumentException("You must pass in a non null View"); } if (!isTransformationSet && view.getScaleType() != null) { switch (view.getScaleType()) { case CENTER_CROP: applyCenterCrop(); break; case FIT_CENTER: case FIT_START: case FIT_END: applyFitCenter(); break; //$CASES-OMITTED$ default: // Do nothing. } } return into(glide.buildImageViewTarget(view, transcodeClass)); }
当调用into(imageView), 会生成一个Target对象,Target对象就是当request请求完成以后,会将结果输出给Target对象。
再看看into(Y target)函数中的源码:public <Y extends Target<TranscodeType>> Y into(Y target) { Util.assertMainThread(); if (target == null) { throw new IllegalArgumentException("You must pass in a non null Target"); } if (!isModelSet) { throw new IllegalArgumentException("You must first set a model (try #load())"); } Request previous = target.getRequest(); if (previous != null) { previous.clear(); requestTracker.removeRequest(previous); previous.recycle(); } Request request = buildRequest(target); target.setRequest(request); lifecycle.addListener(target); requestTracker.runRequest(request); return target; }
其中最最关键的代码应该是
requestTracker.runRequest(request)
了,因为到了into这个阶段,所有的参数都已经准备就绪,是时候进行网络请求,开始获取图片了:public void runRequest(Request request) { requests.add(request); if (!isPaused) { request.begin(); } else { pendingRequests.add(request); } } @Override public void begin() { //省略部分代码 status = Status.WAITING_FOR_SIZE; if (Util.isValidDimensions(overrideWidth, overrideHeight)) { onSizeReady(overrideWidth, overrideHeight); } else { target.getSize(this); } //省略部分代码 }
还记得,我们为什么要分析Glide源码的么?是为了搞清楚Glide如何获取imageView的宽高,并且输出对应尺寸的bitmap。
那么,通过上面的代码大致可以看出: 当用户指定了宽高(即上述得overrideWidth, overrideHeight),那么直接进入onSizeReady(), 即获取图片对象。如果没有指定,通过target.getSize(this), 获取imageView的宽高:public void getSize(SizeReadyCallback cb) { int currentWidth = getViewWidthOrParam(); int currentHeight = getViewHeightOrParam(); if (isSizeValid(currentWidth) && isSizeValid(currentHeight)) { cb.onSizeReady(currentWidth, currentHeight); } else { // We want to notify callbacks in the order they were added and we only expect one or two callbacks to // be added a time, so a List is a reasonable choice. if (!cbs.contains(cb)) { cbs.add(cb); } if (layoutListener == null) { final ViewTreeObserver observer = view.getViewTreeObserver(); layoutListener = new SizeDeterminerLayoutListener(this); observer.addOnPreDrawListener(layoutListener); } } } private int getViewWidthOrParam() { final LayoutParams layoutParams = view.getLayoutParams(); if (isSizeValid(view.getWidth())) { return view.getWidth(); } else if (layoutParams != null) { return getSizeForParam(layoutParams.width, false /*isHeight*/); } else { return PENDING_SIZE; } }
看到这里,我们就可以和之前问题分析的结果联系起来,当imageview的width,height有效的时候,直接获取,否则则获取layoutparams的参数,再无效的的话则通过ViewTreeObserver回调得到imageVIew的尺寸, 而这个回调函数正是onSizeReady(int width, int height):
public void onSizeReady(int width, int height) { if (Log.isLoggable(TAG, Log.VERBOSE)) { logV("Got onSizeReady in " + LogTime.getElapsedMillis(startTime)); } if (status != Status.WAITING_FOR_SIZE) { return; } status = Status.RUNNING; width = Math.round(sizeMultiplier * width); height = Math.round(sizeMultiplier * height); ModelLoader<A, T> modelLoader = loadProvider.getModelLoader(); final DataFetcher<T> dataFetcher = modelLoader.getResourceFetcher(model, width, height); if (dataFetcher == null) { onException(new Exception("Failed to load model: \'" + model + "\'")); return; } ResourceTranscoder<Z, R> transcoder = loadProvider.getTranscoder(); if (Log.isLoggable(TAG, Log.VERBOSE)) { logV("finished setup for calling load in " + LogTime.getElapsedMillis(startTime)); } loadedFromMemoryCache = true; loadStatus = engine.load(signature, width, height, dataFetcher, loadProvider, transformation, transcoder, priority, isMemoryCacheable, diskCacheStrategy, this); loadedFromMemoryCache = resource != null; }
到这里开始,Glide就真正启动了获取网络图片的engine, 其中dataFetcher作为网络请求的对象从网络获取数据。
再看从缓存或者网络获取图片以后, 会将原始图片进行一次transform, 而且transform会根据imageView设置的scaleType做相应的变化,这里主要有两种centerCrop和fitCenter, 下面看看centerCrop的变化代码:public static Bitmap centerCrop(Bitmap recycled, Bitmap toCrop, int width, int height) { if (toCrop == null) { return null; } else if (toCrop.getWidth() == width && toCrop.getHeight() == height) { return toCrop; } // From ImageView/Bitmap.createScaledBitmap. final float scale; float dx = 0, dy = 0; Matrix m = new Matrix(); if (toCrop.getWidth() * height > width * toCrop.getHeight()) { scale = (float) height / (float) toCrop.getHeight(); dx = (width - toCrop.getWidth() * scale) * 0.5f; } else { scale = (float) width / (float) toCrop.getWidth(); dy = (height - toCrop.getHeight() * scale) * 0.5f; } m.setScale(scale, scale); m.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f)); final Bitmap result; if (recycled != null) { result = recycled; } else { result = Bitmap.createBitmap(width, height, getSafeConfig(toCrop)); } // We don't add or remove alpha, so keep the alpha setting of the Bitmap we were given. TransformationUtils.setAlpha(toCrop, result); Canvas canvas = new Canvas(result); Paint paint = new Paint(PAINT_FLAGS); canvas.drawBitmap(toCrop, m, paint); return result; }
到这里,我们就大致过完了Glide的源码,看到了Glide是如何获取imageView的尺寸,并且根据该尺寸和scaleType输出对应的bitmap。
通过上述分析,我们也可以了解到Glide对于Bitmap的处理是非常细致的,最后输出的bitmap是按照控件对象的尺寸,展示多少大,就输出对应尺寸的BItmap。
setLayoutParams源码
public void setLayoutParams(ViewGroup.LayoutParams params) {
if (params == null) {
throw new NullPointerException("Layout parameters cannot be null");
}
mLayoutParams = params;
resolveLayoutParams();
if (mParent instanceof ViewGroup) {
((ViewGroup) mParent).onSetLayoutParams(this, params);
}
requestLayout();
}
@CallSuper
public void requestLayout() {
if (mMeasureCache != null) mMeasureCache.clear();
if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
// Only trigger request-during-layout logic if this is the view requesting it,
// not the views in its parent hierarchy
ViewRootImpl viewRoot = getViewRootImpl();
if (viewRoot != null && viewRoot.isInLayout()) {
if (!viewRoot.requestLayoutDuringLayout(this)) {
return;
}
}
mAttachInfo.mViewRequestingLayout = this;
}
mPrivateFlags |= PFLAG_FORCE_LAYOUT;
mPrivateFlags |= PFLAG_INVALIDATED;
if (mParent != null && !mParent.isLayoutRequested()) {
mParent.requestLayout();
}
if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
mAttachInfo.mViewRequestingLayout = null;
}
}
当调用requestLayout的时候,会一直往上调用mParent.requestLayout
, 最终调用ViewRootImpl的requestLayout函数,下面看其中的requestLayout实现:
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();
}
}
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}
final TraversalRunnable mTraversalRunnable = new TraversalRunnable();
其实看到这里,我们已经可以得出我们想要的结论了: 将任务doTraversal传递给Choreographer对象处理,而Choreographer是在Android4.1之后增加的用于调度界面绘图的机制。TraversalRunnable作为任务的形式,会被放入CallbackQueue中,当执行到该任务的时候,后面的逻辑是和绘制的逻辑一样的: measure, layout, draw。
问题解决
既然已经分析清楚问题的原因, 要解决这个问题,还是得从Glide着手处理。其实在上面也分析过了, 因为recyclerView重用了view,导致Glide获取的imageView的width,height是之前重用的width,height,而非我们设置的layoutParams的宽高。而Glide除了获取imageView的宽高之前,会首先判断是否设置了width,height参数,也就是下面这段代码:
if (Util.isValidDimensions(overrideWidth, overrideHeight)) {
onSizeReady(overrideWidth, overrideHeight);
} else {
target.getSize(this);
}
这里overrideWidth,overrideHeight提供了接口设置,代码如下:
DrawableTypeRequest glideRequest = Glide
.with(getContext())
.load(imageUrl);
if (width > 0 && height > 0) {
glideRequest.override(width, height);
}
这样,在最开始的代码中,只要修改一行代码即可:
public void setContent(Content content){
if(content.getType() == Content.TYPE_IMAGE){
textView.setVisibility(View.GONE);
imageView.setVisibility(View.VISIBLE);
ImageInfo imageInfo = content.getPhoto();
int height = imageInfo.getHeight() * photoWidth / imageInfo.getWidth();
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(photoWidth, height);
layoutParams.setMargins(margin, 0, margin, 0);
imageView.setLayoutParams(layoutParams);
imageView.setImageUrl(imageInfo.getUrl(), layoutParams.width, layoutParams.height);
}
}