这篇文章来源是测试发现的一个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)
public static RequestManager with(Context context) {
RequestManagerRetriever retriever = RequestManagerRetriever.get();
return retriever.get(context);
}
生成了一个RequestManager对象,它作为Glide的“总管”,管理着Lifecycle(生命周期的绑定), RequestTracker(请求列表管理)等Glide核心成员。
- load(imageUrl)
public DrawableTypeRequest load(T model) {
return (DrawableTypeRequest) loadGeneric(getSafeClass(model)).load(model);
}
private DrawableTypeRequest loadGeneric(Class modelClass) {
ModelLoader streamModelLoader = Glide.buildStreamModelLoader(modelClass, context);
ModelLoader fileDescriptorModelLoader =
Glide.buildFileDescriptorModelLoader(modelClass, context);
return optionsApplier.apply(
new DrawableTypeRequest(modelClass, streamModelLoader, fileDescriptorModelLoader, context,
glide, requestTracker, lifecycle, optionsApplier));
}
这里生成了一个DrawableTypeRequest对象,它是一个request对象的Builder模式,在这一步会通过imageUrl构造RequstBuilder。
- into(imageView)
这是Glide网络图片请求的最后一步,也是最核心的一步,into()方法是由上述DrawableTypeRequest基类GenericRequestBuilder实现的
public Target 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 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 modelLoader = loadProvider.getModelLoader();
final DataFetcher dataFetcher = modelLoader.getResourceFetcher(model, width, height);
if (dataFetcher == null) {
onException(new Exception("Failed to load model: \'" + model + "\'"));
return;
}
ResourceTranscoder 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);
}
}