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

这篇文章来源是测试发现的一个bug, 为了解决这个问题,深入分析了部分Glide源码和Android View的绘制原理,在这里做个记录。

问题描述

这个bug是这样的:在商品详情页面,图片展示详情的时候,会出现如下问题:


一个图片列表的问题(Glide)_第1张图片

左边是刚进入详情页面的时候, 右边是详情页面往下滑动,再回到原来的位置展示的情况。会发现:左边是正常的,图片所有内容都正常展示在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核心成员。

  1. 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。

  1. 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);
    }
}

你可能感兴趣的:(一个图片列表的问题(Glide))