一个图片列表的问题(Glide)

这篇文章来源是测试发现的一个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;

  1. 列表RecyclerView
    根据上述问题的描述,刚进入商品详情页面的时候,图片展示是正常的,而滑动以后,再回到原来的位置,出现bug。那么可以想到,recyclerView有一个View复用机制,当列表滑动的时候,下面的View不会重新构建,而是复用上面已经滑出界面的View。
    那么,容易发现一个问题, 由于这里控件的尺寸是动态设置的,复用的imageView的尺寸与要展示的图片尺寸会不一样。当然,我也知道会不一样,所以在代码中动态获取尺寸,并且设置ImageView尺寸。
  2. 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个步骤分别做了什么:

  1. Glide.with(context)
    public static RequestManager with(Context context) {
     RequestManagerRetriever retriever = RequestManagerRetriever.get();
     return retriever.get(context);
    }
    生成了一个RequestManager对象,它作为Glide的“总管”,管理着Lifecycle(生命周期的绑定), RequestTracker(请求列表管理)等Glide核心成员。
  2. 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。

  3. 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);
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值