从一次图片加载的极致优化深入探究Glide缓存原理

597 阅读24分钟

任何极致体验的背后,都有很多细节需要打磨,而这些打磨的痛苦背后,都是一次深度思考、技术提升的机会。

今天我们从项目中遇到的几个实际问题入手,探究如何一步一步让图片加载和界面过度变得丝滑无感,结合过往使用到的优化手法,尝试从源码得到这些优化手法的理论支撑,最后重点系统性的学习一下Glide强大的缓存设计,Glide的源码设计有很多优秀的地方值得学习,比如巧妙的绑定生命周期,极致的若干处对象池复用,图片压缩策略,自定义ModelLoader,多级的缓存策略等。

一、图片加载优化的实践记录

最近需求其中有多个场景由于图片加载慢导致体验较差

场景一

第一次进入卡片页,由于进入就需要拿到图片做一个展开的衔接动画,但图片还显示加载导致动画效果无法还原动效稿。

场景二

卡片页->详情页,由于跳转需要使用共享元素动画实现无缝衔接因此对详情页的加载速度极高,否则就会出现跳帧闪烁。

场景三

图片所在布局加载慢或者图片依赖的数据加载慢,导致图片开始加载时机延后,例如布局加载的耗时trace如下图所示

基于以上场景,我们在图片加载速度这块的能力就面临以下多个问题需要解决

1、怎么看是否命中缓存?

设置加载监听,并打印 DataSource

public interface RequestListener<R> {
  ...
  /**
   ...
   * @param dataSource The {@link DataSource} the resource was loaded from.
   ...
   */
  boolean onResourceReady(
	  R resource, Object model, Target<R> target, DataSource dataSource, boolean isFirstResource);
}

数据源有5种类型:

LOCAL:本地数据,例如本地图片文件,也可能是通过ContentProvider共享的远程数据,比如在ContentProvider中进行网络请求。

REMOTE:远程数据,例如通过网络请求数据。

DATA_DISK_CACHE:缓存在磁盘的原始数据。

RESOURCE_DISK_CACHE:缓存在磁盘的解码、变换后的数据。

MEMORY_CACHE:存储在内存中的数据。

2、如何预加载即将打开页面中的图片?

大家都知道有一个preload方法可以实现预加载,但实际使用起来还是有很多细节要注意的,网络上的资料给出的普遍错误用法如下:

执行预加载

Glide.with(this).load(url).preload()

复用已预加载的图片

Glide.with(this).load(url).into(iv)				

有些同学说加上宽高就行,其实也是不行的。

真正实践后,发现在调用预加载方法后访问相同的URL还是会出现加载过程,打印DataSource发现基本都没有实现 MEMORY_CACHE 级别的复用, 要么导致从网络重新拉取,要么需要重新解码。

因为还有更苛刻的条件被忽略,详细的缓存命中原理咱们在后面再说,这里只看一下EngineKey的生成规则说明缓存复用的条件包含哪些内容。

class EngineKey implements Key {
  ...
  @Override
  public boolean equals(Object o) {
	if (o instanceof EngineKey) {
	  EngineKey other = (EngineKey) o;
	  return model.equals(other.model)//理解成url即可
		  && signature.equals(other.signature)//签名
		  && height == other.height//高和宽
		  && width == other.width
		  && transformations.equals(other.transformations)
		  && resourceClass.equals(other.resourceClass)
		  && transcodeClass.equals(other.transcodeClass)
		  && options.equals(other.options);
	}
	return false;
  }			

总结一下关键的几个容易被忽略的必要条件

:必须指定宽高

:必须apply RequestOptions,且 RequestOptions 必须指定 transformations

结合RequestBuilder源码后发现:

由于调用的preload方法,没有绑定View,所以无法知道scaleType,导致没有设置对应的transformations

而后面真正使用的时候调用的into,有view,Glide在RequestBuilder中会自动根据View的scaleType帮我们加上裁切方式的transformations。

这就导致了上述EngineKey的equals方法不相同,导致复用失败。

 //com.bumptech.glide.RequestBuilder
  public ViewTarget<ImageView, TranscodeType> into(@NonNull ImageView view) {
	...
	BaseRequestOptions<?> requestOptions = this;
	if (!requestOptions.isTransformationSet()
		&& requestOptions.isTransformationAllowed()
		&& view.getScaleType() != null) {
	  switch (view.getScaleType()) {
		case CENTER_CROP:
		  requestOptions = requestOptions.clone().optionalCenterCrop();
		  break;
		case CENTER_INSIDE:
		  requestOptions = requestOptions.clone().optionalCenterInside();
		  break;
		...
	  }
	}

	return into(
		glideContext.buildImageViewTarget(view, transcodeClass),
		/*targetListener=*/ null,
		requestOptions,
		Executors.mainThreadExecutor());
  }

正确预加载用法如下:

这里可以根据视觉稿或者屏幕宽高来提前确定即将打开页面的图片大小

执行预加载

Glide.with(this@MainActivityGlide)
.load(URL_IMG1)
.apply(
	RequestOptions()
//      .transform(CenterCrop(), RoundedCorners(20))//有处理需求的时候设置这个,无处理需求的时候设置dontTransform
	.dontTransform()
)
.priority(Priority.IMMEDIATE)
.preload(width_target_iv,height_target_iv) 	

复用预加载的图片

Glide.with(this@MainActivityGlide)
.load(URL_IMG1)
.apply(
	RequestOptions()
//      .transform(CenterCrop(), RoundedCorners(20))//有处理需求的时候设置这个,无处理需求的时候设置dontTransform
	.dontTransform()
)
.priority(Priority.IMMEDIATE)
.into(iv)

3、为什么图片加载有时候会自动停止?

在执行预加载后,在某些时候还是会出现缓存未命中的情况发生,检查后发现是由于传入的Context导致,Glide的加载任务会自动绑定生命周期,当 Activity、Fragment 等组件进入不可见,或者已经销毁的时候,Glide 会停止加载资源

因此这里建议在做图片预加载的时候传入Application会更稳妥一些。

这里我们简单看一下是如何在获取 RequestManager 时绑定的生命周期,这里设计也是比较巧妙的,值的学习。

<!--com.bumptech.glide.manager.RequestManagerRetriever
public class RequestManagerRetriever implements Handler.Callback {
	...
	@NonNull
	public RequestManager get(@NonNull Context context) {
	if (context == null) {
	  throw new IllegalArgumentException("You cannot start a load on a null Context");
	} else if (Util.isOnMainThread() && !(context instanceof Application)) {
		if (context instanceof FragmentActivity) {
			return get((FragmentActivity) context);
		} else if (context instanceof Activity) {
			return get((Activity) context);
		} 
		...
	}

	return getApplicationManager(context);
	}		
	...
	public RequestManager get(@NonNull Activity activity) {
		if (Util.isOnBackgroundThread()) {
		  return get(activity.getApplicationContext());
		} else {
		  assertNotDestroyed(activity);
		  android.app.FragmentManager fm = activity.getFragmentManager();
		  return fragmentGet(activity, fm, /*parentHint=*/ null, isActivityVisible(activity));
		}
	}
	private RequestManager fragmentGet(...) {
		RequestManagerFragment current = getRequestManagerFragment(fm, parentHint, isParentVisible);
		...
		return requestManager;
	}		
	...
	private RequestManagerFragment getRequestManagerFragment(...) {
			...
			current = new RequestManagerFragment();
			...
			fm.beginTransaction().add(current, FRAGMENT_TAG).commitAllowingStateLoss();//添加透明的Fragment
		}		
	}
-->
<!--
public class RequestManager{	
	RequestManager(...) {
	...
	if (Util.isOnBackgroundThread()) {
	  mainHandler.post(addSelfToLifecycle);
	} else {
	  lifecycle.addListener(this);//绑定生命周期的RequestManager
	}
	lifecycle.addListener(connectivityMonitor);//绑定生命周期的网络监听器
	...
}	
-->

以上代码做的事情概括如下:

1. 非 UI 线程,返回 applicationManager 对象,跟Application的销毁的时候直接销毁。

2. UI 线程,如果 context 是 Activity 、FragmentActivity则会创建一个能感知对应 Activity 的 RequestManager。

3. UI 线程,如果 Context 是 Fragment、android.support.v4.app.Fragment 则会创建一个能感知对应 Fragment 生命周期 的 RequestManager

初始化 RequstManager 的时候通过给FragmentManager加入一个新的透明Fragement用来监听生命周期,

把ReqeustManager加入到Fragment的Listener中,对应三个周期方法onStart,onStop,onDestroy更新任务的请求状态。

4、本地图片加载耗时如何解决?

从首次进入加载慢的Trace分析得到本地图片加载也挺耗时,图片不仅出来得慢而且会引起交互的卡顿。

优化方法则是可以借助Glide实现缓存复用,但不要直接传入Drawable给到Glide加载,这样内部的复用取决于传入的Drawable是否相同,多半会每次都传入新的,导致复用失效。

可以考虑传入Uri的方式作为url替代传入Drawable方式给到Glide加载图片,本地图片转URI方式访问的方法如下:

public static String buildResourceUri(Context context, int resourceId){
   String uri= new Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
			.encodedAuthority(context.getPackageName())
			.encodedPath(resourceId+"")
			.toString();
   return uri;
}	

5、多个图片同时加载时,如何根据优先级加载?

这一次由于我们图片较多,同时由于使用ViewPager2开启了离屏预渲染,导致同一时间加载多个图片的时候,存在任务的争抢,导致焦点图片出来得较慢,这里可以设置加载优先级保证某张图片优先加载。

Glide.with(this)
   .load(url)
   .priority(Priority.IMMEDIATE)
   .preload()	

优先级包含

public enum Priority {
  IMMEDIATE,
  HIGH,
  NORMAL,//默认
  LOW,
}

原理简述就是其中真正执行任务的DecodeJob实现了Comparable接口,提交到优先级的容器的线程池中,会按照当前DecodeJob的Priority进行排序选择执行,核心代码如下:

class DecodeJob<R> implements DataFetcherGenerator.FetcherReadyCallback,
	Runnable,
	Comparable<DecodeJob<?>>,
	Poolable{} 

public final class GlideExecutor implements ExecutorService {
	public GlideExecutor build() {
	  ...
	  ThreadPoolExecutor executor =
		  new ThreadPoolExecutor(
			  corePoolSize,
			  maximumPoolSize,
			  /*keepAliveTime=*/ threadTimeoutMillis,
			  TimeUnit.MILLISECONDS,
			  new PriorityBlockingQueue<Runnable>(),//优先级队列
			  new DefaultThreadFactory(name, uncaughtThrowableStrategy, preventNetworkOperations));
		...
		return new GlideExecutor(executor);
	}		
}

之所以有这个优先级队列,是因为Glide执行任务的线程池中线程数量是有限的,有兴趣的可以去看看,这里就不展开说了。

6、布局和数据预加载?

数据预加载

数据预加载这里就不过多介绍,找到合适的时机,按照各自业务的特性选择从数据库或者网络提前准备好数据,达到进入页面立即可用的状态即可

布局预加载

优化前:先看一下优化前表现在trace上的耗时效果,可以看到图中有多个布局inflate加载的耗时段

布局加载耗时的原因主要是需要通过IO将XML布局文件加载到内存中并进行解析以及通过反射创建View,如果层级较深,加载耗时问题更为严重,解决这个问题有两种思路:

  • 一种是不用IO和反射,比如简单View直接new对象以及通过addView的方式进行View的构建,或者通过x2c框架辅助转换。

  • 一种是将耗时的加载操作放到子线程中。

我们结合项目中目前的使用习惯,综合上述的子线程异步加载和new方式来进行优化。

当然使用过程中也遇到了很多的疑难杂症,在贴出核心源码之前,我先罗列出来,希望你们少走弯路。

A、布局预加载布局遇到的几个问题

1、inflate参数个数问题

inflate是有3个参数的,由于预加载不能传入Parent,因此无法使用Parent的生成LayoutParam设置给目标View,因此如果要使用LayoutParam则会崩溃。

2、merge兼容

无法独立加载ViewStub结合merge修饰的子布局,这种问题的解决办法是直接加载根布局,先不管子布局如何,报错的原因我们从LayoutInflater的源码可以看到:

3、主题问题

主题属性无法获取问题,因为传入的Context一般如果在Activity加载的话是传入的Activity,而声明Activity的时候一般会带主题,而如果预加载的话我们传入的是全局Context,因此如果xml中有对应主题相关的属性,则会崩溃。这种问题的解决办法我是通过传入一个默认主题进行预加载,真正进入再进行修正。

4、Handler创建失败

由于我们是在子线程创建的View,因此如果在View中有使用Handler,子线程创建Handler则会没有Looper循环可用的报错,因此我们这里改成懒加载进行规避。

5、ViewPager2对子View是否填充满的检查

ViewPager场景中需要对从预加载取出来的子布局进行手动重置布局参数,保证他始终填满父布局,否则就会报错

6、子线程DataBindingUtil.inflate导致的Looper检查崩溃

由于DataBindingUtil.inflate方式内部有Looper检查导致崩溃,解决办法为实例化ViewBinding时在对应线程手动创建属于子线程的Looper

B、核心源码和使用方法

由于核心加载类代码不多,我这里直接贴出来,

/**

* 用于异步预加载布局,优化启动卡顿,加载慢等场景

* 相比原生AsyncLayoutInflater进行优化

* 1.引入线程池,减少单线程等待

* 2.手动设置setFactory2

* 3.支持View缓存,用于预加载场景

* 4.支持阻塞等待超时获取预加载View

* 5.支持非inflate方式的view预加载

* 6.支持ViewBinding预加载

* 7.兼容Adapter多个View同时预加载 */

class AsyncLayoutInflateExt private constructor(context: Context) {
    companion object {
        private const val TAG = "AsyncLayoutInflateExt"
        private const val TIME_MAX_WAIT = 300L
        private const val TAG_VIEW_BINDING = 1000

        @Volatile
        private var instance: AsyncLayoutInflateExt? = null
        fun getInstance(context: Context): AsyncLayoutInflateExt {
            return instance ?: synchronized(AsyncLayoutInflateExt::class.java) {
                instance ?: AsyncLayoutInflateExt(context).also {
                    instance = it
                }
            }
        }
    }

    //支持一个类型同时存储多个View,兼容Adapter场景
    private val mPreLayoutPool = ConcurrentHashMap<Int, ArrayBlockingQueue<View?>>()
    private val mRequestPool = SynchronizedPool<InflateRequest>(10)
    private var mInflater: LayoutInflater = BasicInflater(context)
    private var mDispatcher: Dispatcher = Dispatcher()
    private var mHandler: Handler = Handler(Looper.getMainLooper()) { msg ->
        val request = msg.obj as InflateRequest
        request.callback?.onInflateFinished(
            request.view, request.resid, request.parent
        )
        releaseRequest(request)
        true
    }

    /**
     * 预加载
     * 结果不缓存,使用回调给到调用方
     */
    @UiThread
    fun inflate(
        @LayoutRes resid: Int, parent: ViewGroup?, callback: OnInflateFinishedListener?
    ) {
        Log.d(TAG, "inflate.resid:$resid")
        val request = obtainRequest()
        request.inflater = this
        request.resid = resid
        request.parent = parent
        request.callback = callback
        mDispatcher.enqueue(request)
    }

    /**
     * 预加载
     * 通过layoutId创建View,需要将加载结果缓存起来
     */
    fun preInflate(@LayoutRes resid: Int) {
        Log.d(TAG, "preInflate.resid:$resid")
        val request = obtainRequest()
        request.inflater = this
        request.resid = resid
        request.callback = PreInflateListener()
        mPreLayoutPool[resid] = ArrayBlockingQueue<View?>(1)
        mDispatcher.enqueue(request)
    }

    /**
     * 预加载
     * 通过传入的表达式来自定义创建View,因为有些View是new出来的,并不都是通过inflate
     */
    fun preInflateByInvoke(identity: Int, creater: () -> View) {
        Log.d(TAG, "preInflateByCreater.identity:$identity")
        val request = obtainRequest()
        request.inflater = this
        request.resid = identity
        request.callback = PreInflateListener()
        request.viewCreater = creater
        mPreLayoutPool[identity] = ArrayBlockingQueue<View?>(1)
        mDispatcher.enqueue(request)
    }

    fun preInflateBinding(
        identity: Int,
        size: Int = 1,
        creater: (inflater: LayoutInflater?) -> Any?
    ) {
        Log.d(TAG, "preInflateBinding.identity:$identity")
        mPreLayoutPool[identity] = ArrayBlockingQueue<View?>(size)
        for (i in 1..size) {
            val request = obtainRequest()
            request.inflater = this
            request.resid = identity
            request.callback = PreInflateListener()
            request.bindingCreater = creater
            mDispatcher.enqueue(request)
        }
    }

    fun hasNoAvailableLayout(resId: Int): Boolean {
        return mPreLayoutPool[resId]?.isEmpty() ?: true
    }

    fun getLayout(@LayoutRes resId: Int): View? {
        val inflatedView = mPreLayoutPool[resId]?.poll(TIME_MAX_WAIT, TimeUnit.MILLISECONDS)
        Log.d(TAG, "getLayout.inflatedView:$inflatedView")
        mPreLayoutPool.remove(resId) //防止下次来取同样redId的导致一直需要等待
        return inflatedView
    }

    fun <E> getViewBinding(@LayoutRes resId: Int): E? {
        val inflatedView = mPreLayoutPool[resId]?.poll(TIME_MAX_WAIT, TimeUnit.MILLISECONDS)
        val viewBinding = inflatedView?.getTag(R.id.tag_preload_binding) as? E
        Log.d(TAG, "getLayout.viewBinding:$viewBinding")
        if (mPreLayoutPool[resId]?.size == 0) {//防止第二次取的时候由于队列被移除导致获取失败
            mPreLayoutPool.remove(resId) //防止下次来取同样redId的导致一直需要等待
        }
        return viewBinding
    }

    interface OnInflateFinishedListener {
        fun onInflateFinished(
            view: View?, @LayoutRes resid: Int, parent: ViewGroup?
        ) {
        }

        fun onPreInflateFinished(
            view: View?, @LayoutRes resid: Int, parent: ViewGroup?, request: InflateRequest
        ) {
        }
    }

    inner class PreInflateListener : OnInflateFinishedListener {
        override fun onPreInflateFinished(
            view: View?, resid: Int, parent: ViewGroup?, request: InflateRequest
        ) {
            val offerRes = mPreLayoutPool[resid]?.offer(view)
            Log.d(
                TAG,
                "Thread:" + (Thread.currentThread().name) + " onInflateFinished.resid:$resid offerRes:$offerRes"
            )
            releaseRequest(request)
        }
    }

    class InflateRequest internal constructor() {
        var inflater: AsyncLayoutInflateExt? = null
        var parent: ViewGroup? = null
        var resid = 0
        var view: View? = null
        var viewCreater: (() -> View)? = null
        var bindingCreater: ((inflater: LayoutInflater?) -> Any?)? = null
        var callback: OnInflateFinishedListener? = null
    }

    class Dispatcher {
        fun enqueue(request: InflateRequest) {
            THREAD_POOL_EXECUTOR.execute(InflateRunnable(request))
        }

        companion object {
            //获得当前CPU的核心数
            private val CPU_COUNT = Runtime.getRuntime().availableProcessors()

            //设置线程池的核心线程数2-4之间,但是取决于CPU核数
            private val CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4))

            //设置线程池的最大线程数为 CPU核数 * 2 + 1
            private val MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1

            //设置线程池空闲线程存活时间30s
            private const val KEEP_ALIVE_SECONDS = 30
            private val sThreadFactory: ThreadFactory = object : ThreadFactory {
                private val mCount = AtomicInteger(1)
                override fun newThread(r: Runnable): Thread {
                    return Thread(r, TAG + "#" + mCount.getAndIncrement())
                }
            }

            //LinkedBlockingQueue 默认构造器,队列容量是Integer.MAX_VALUE
            private val sPoolWorkQueue: BlockingQueue<Runnable> = LinkedBlockingQueue()

            /**
             * An [Executor] that can be used to execute tasks in parallel.
             */
            var THREAD_POOL_EXECUTOR: ThreadPoolExecutor

            init {
                Log.i(
                    TAG,
                    "static initializer: " + " CPU_COUNT = " + CPU_COUNT + " CORE_POOL_SIZE = " + CORE_POOL_SIZE + " MAXIMUM_POOL_SIZE = " + MAXIMUM_POOL_SIZE
                )
                val threadPoolExecutor = ThreadPoolExecutor(
                    CORE_POOL_SIZE,
                    MAXIMUM_POOL_SIZE,
                    KEEP_ALIVE_SECONDS.toLong(),
                    TimeUnit.SECONDS,
                    sPoolWorkQueue,
                    sThreadFactory
                )
                threadPoolExecutor.allowCoreThreadTimeOut(true)
                THREAD_POOL_EXECUTOR = threadPoolExecutor
            }
        }
    }

    private class BasicInflater internal constructor(context: Context?) : LayoutInflater(context) {
        init {
            if (context is AppCompatActivity) {
                // 手动setFactory2,兼容AppCompatTextView等控件
                val appCompatDelegate = context.delegate
                if (appCompatDelegate is Factory2) {
                    LayoutInflaterCompat.setFactory2(this, (appCompatDelegate as Factory2))
                }
            }
        }

        override fun cloneInContext(newContext: Context): LayoutInflater {
            return BasicInflater(newContext)
        }

        @Throws(ClassNotFoundException::class)
        override fun onCreateView(name: String, attrs: AttributeSet): View {
            for (prefix in sClassPrefixList) {
                try {
                    val view = createView(name, prefix, attrs)
                    if (view != null) {
                        return view
                    }
                } catch (e: ClassNotFoundException) {
                    // In this case we want to let the base class take a crack
                    // at it.
                }
            }
            return super.onCreateView(name, attrs)
        }

        companion object {
            private val sClassPrefixList = arrayOf(
                "android.widget.", "android.webkit.", "android.app."
            )
        }
    }

    private class InflateRunnable(private val request: InflateRequest) : Runnable {
        var isRunning = false
            private set

        override fun run() {
            isRunning = true
            try {
                request.view = request.viewCreater?.run {
                    this.invoke()
                } ?: request.bindingCreater?.run {
                    if (Looper.myLooper() == null) {
                        Looper.prepare()
                    }
                    val viewBinding = this.invoke(request.inflater?.mInflater)
                    View(request.inflater?.mInflater?.context).apply {
                        setTag(R.id.tag_preload_binding, viewBinding)
                    }
                } ?: request.inflater?.mInflater?.run {
                    inflate(
                        request.resid, request.parent, false
                    )
                }
            } catch (ex: RuntimeException) {
                // Probably a Looper failure, retry on the UI thread
                Log.w(
                    TAG,
                    "Failed to inflate resource in the background! Retrying on the UI" + " thread",
                    ex
                )
            }
            if (request.callback is PreInflateListener) {
                request.view?.run {
                    (request.callback as? PreInflateListener)?.onPreInflateFinished(
                        request.view, request.resid, request.parent, request
                    )
                }
            } else {
                Message.obtain(request.inflater!!.mHandler, 0, request).sendToTarget()
            }
        }
    }

    private fun obtainRequest(): InflateRequest {
        var obj = mRequestPool.acquire()
        if (obj == null) {
            obj = InflateRequest()
        }
        return obj
    }

    private fun releaseRequest(obj: InflateRequest) {
        obj.callback = null
        obj.inflater = null
        obj.parent = null
        obj.resid = 0
        obj.view = null
        mRequestPool.release(obj)
    }

    fun cancel() {
        mHandler.removeCallbacksAndMessages(null)
    }
}

7、关于Glide的其他优化手段介绍

大图展示水波纹不清晰?

:大图场景DecodeFormat编码格式从565设置为8888即可

异步加载应用图标

:使用PackageManager.getApplicationIcon方式加载应用图标也是一个耗时的过程,需要开子线程进行加载,高频访问时还需要手动去实现缓存复用,其实也可以借助Glide来帮我们去实现这些能力,也就是通过自定义GlideModule、ApkModelLoaderFactory、ApkIconModelLoader及ApkIconFetcher等,实现从Apk中高效加载图标到ImageView

图片签名

:当图片实时性要求较高的场景,服务端下发的图片url不变的情况下,可以结合Head请求+借助Http协议三级缓存中提供的Etag或者Last-Modified来判断资源是否改变,并通过Glide的signature重新发起请求即可

    Glide.with(context)
            .setDefaultRequestOptions(RequestOptions.circleCropTransform()
             //图片签名信息,相同url下如果需要刷新图片,signature不同则会重新加载
            .signature(etag)
            .placeholder(imageView.drawable))
            .load(url)
            .into(imageView)
Glide内存优化

在反复进出多图的页面的会发现Graphics内存暴涨,这时候可以采用如下方式选择进行内存优化

:Glide.trimMemory()/Glide.get(context).clearMemory()

在Applicaton的内存告警onLowMemory、onTrimMemory回调中手动调用上述方法或者在进入过那些图片很多的页面后,可以考虑主动释放,优化常驻内存。

:Glide.get(context).setMemoryCategory(MemoryCategory.LOW)

MemoryCategory有3个值可供选择,越大理论上来说加载速度更快:

MemoryCategory.HIGH(初始缓存大小的1.5倍)

MemoryCategory.NORMAL(初始缓存大小的1倍)

MemoryCategory.LOW(初始缓存大小的0.5倍)

:清除指定View使用的Bitmap:

Glide.with(imageView.getContext()).clear(imageView);

二、效果对比

通过上述各个手段的优化之后,咱们看一下各场景的效果对比

场景一

图片加载体验变得更加丝滑,没有了加载闪烁的过程,动画过度更自然流畅,效果对比如下:

场景二

效果对比略

场景三

打开分发卡页面的布局加载耗时从96ms减少到了17ms,进入页面的扩散动画从肉眼可见的卡顿到顺畅执行。

效果对比如图所示:

三、从源码学习缓存设计

注:源码版本4.11.0

1、整体流程概述

Glide的缓存设计是非常优秀的,在缓存这一功能上,Glide将它分成了两个模块,一个是内存缓存,一个是硬盘缓存

内存缓存的主要作用是防止应用重复将图片数据读取到内存当中,硬盘缓存的主要作用是防止应用重复从网络或其他地方重复下载和读取数据。

当我们的APP中想要加载某张图片时,整体流程如下:

1.先去内存缓存中的活跃资源activeResources中寻找,如果activeResources中有,则从activeResources中取出图片使用

2.如果内存缓存中的活跃资源中没有,则去内存缓存中的最近资源LruCache中寻找图片,如果LruCache中有,则先保存到activeResources中再取出来使用

3.如果内存缓存中的最近资源LruCache中没有,则去磁盘缓存中的解码资源缓存中寻找,如果有则取出来使用,同时将图片添加到LruCache中

4.如果磁盘缓存的解码资源缓存中没有,则去磁盘缓存的原始资源缓存中查找,有的话则执行解码后使用并添加到LruCache中

5.如果磁盘缓存中都没有,则连接网络从网上下载图片。

整体流程图示

核心代码

<!--com.bumptech.glide.load.engine.Engine
 public <R> LoadStatus load(...) {
	long startTime = VERBOSE_IS_LOGGABLE ? LogTime.getLogTime() : 0;

	EngineKey key =
		keyFactory.buildKey(...);//构建唯一键值索引

	EngineResource<?> memoryResource;
	synchronized (this) {
	  //1.从内存缓存中获取
	  memoryResource = loadFromMemory(key, isMemoryCacheable, startTime);

	  if (memoryResource == null) {
		//2.从磁盘缓存或者网络获取
		return waitForExistingOrStartNewJob(...);
	  }
	}
	...
	return null;
  }	

-->

2、内存缓存

默认情况下,Glide自动就是开启内存缓存的,当我们使用Glide加载了一张图片之后,这张图片就会被缓存到内存当中,只要在它还没从内存中被清除之前,下次使用Glide 再加载这张图片都会直接从内存当中读取,而不用重新从网络或硬盘上读取了,这样无疑就可以大幅度提升图片的加载效率。

内存缓存又分为活动资源和内存资源缓存,核心代码如下:

  //com.bumptech.glide.load.engine.Engine
  private EngineResource<?> loadFromMemory(...) {
	if (!isMemoryCacheable) {
	  return null;
	}
	//1. 优先加载内存中的活动缓存
	EngineResource<?> active = loadFromActiveResources(key);
	...
	//2. 然后加载Lru缓存
	EngineResource<?> cached = loadFromCache(key);
	...
  }	

A、活动资源缓存

活动资源缓存管理核心在ActiveResources类中,包含的能力有管理活动资源、弱引用+引用计数、自动清理策略等 不管是哪个缓存,我们都主要就从放入和取出这两个核心逻辑作为切入点来分析。

  //com.bumptech.glide.load.engine.Engine
  ...
  private final ActiveResources activeResources;	
  ...
  @Nullable
  private EngineResource<?> loadFromActiveResources(Key key) {
	//取出活动资源
	EngineResource<?> active = activeResources.get(key);
	if (active != null) {
	  active.acquire();//让EngineResource的引用计数+1,防止同一个资源被多个地方使用的时候被误释放
	}

	return active;
  }
  private EngineResource<?> loadFromCache(Key key) {
	EngineResource<?> cached = getEngineResourceFromCache(key);
	if (cached != null) {
	  cached.acquire();
	  //放入活动资源
	  activeResources.activate(key, cached);
	}
	return cached;
  }		  

结合Engine源码我们可以知道放入活动资源是从lru缓存获取后放入的,而且从getEngineResourceFromCache方法的实现可以看到一个资源要么在Lru资源中缓存要么在活跃资源中缓存,不可能同时在两个里面缓存,

再进一步结合ActiveResources的源码,我们再重点分析一下众多开源框架中都在使用的自动清理策略在Glide是如何实现的?

final class ActiveResources {
  @VisibleForTesting final Map<Key, ResourceWeakReference> activeEngineResources = new HashMap<>();
  private final ReferenceQueue<EngineResource<?>> resourceReferenceQueue = new ReferenceQueue<>();
  ...
  ActiveResources(boolean isActiveResourceRetentionAllowed, Executor monitorClearedResourcesExecutor) {
	...
	//初始化的时候就使用一个后台优先级的线程池开始执行清理策略任务
	monitorClearedResourcesExecutor.execute(
		new Runnable() {
		  @Override
		  public void run() {
			cleanReferenceQueue();
		  }
		});
  }
  ...
  synchronized void activate(Key key, EngineResource<?> resource) {//保存活跃资源
	ResourceWeakReference toPut =
		new ResourceWeakReference(
			key, resource, resourceReferenceQueue, isActiveResourceRetentionAllowed);

	ResourceWeakReference removed = activeEngineResources.put(key, toPut);
	if (removed != null) {
	  removed.reset();
	}
  }

  synchronized void deactivate(Key key) {
	ResourceWeakReference removed = activeEngineResources.remove(key);
	if (removed != null) {
	  removed.reset();
	}
  }

  @Nullable
  synchronized EngineResource<?> get(Key key) {
	ResourceWeakReference activeRef = activeEngineResources.get(key);
	if (activeRef == null) {
	  return null;
	}

	EngineResource<?> active = activeRef.get();
	if (active == null) {//为空说明已经被释放,需要清除集合中的弱引用本身
	  cleanupActiveReference(activeRef);
	}
	return active;
  }

  void cleanupActiveReference(@NonNull ResourceWeakReference ref) {
	synchronized (this) {
	  activeEngineResources.remove(ref.key);

	  if (!ref.isCacheable || ref.resource == null) {
		return;
	  }
	}

	EngineResource<?> newResource =
		new EngineResource<>(
			ref.resource, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ false, ref.key, listener);
	listener.onResourceReleased(ref.key, newResource);
  }

  void cleanReferenceQueue() {
	while (!isShutdown) {
	  try {
		//存入引用队列的说明弱引用对应的对象已经被回收
		ResourceWeakReference ref = (ResourceWeakReference) resourceReferenceQueue.remove();
		cleanupActiveReference(ref);
		...
	  } catch (InterruptedException e) {
		Thread.currentThread().interrupt();
	  }
	}
  }
  ...
  @VisibleForTesting
  static final class ResourceWeakReference extends WeakReference<EngineResource<?>> {
	...
	ResourceWeakReference(
		@NonNull Key key,
		@NonNull EngineResource<?> referent,
		@NonNull ReferenceQueue<? super EngineResource<?>> queue,
		boolean isActiveResourceRetentionAllowed) {
	  super(referent, queue);//跟踪的是referent
	  ...
	}
  }
}		

通过上述源码,我们得出

:活跃资源采用的是一个HashMap进行存储

:自动清理策略是通过引用队列来实现待清理任务的收集的,其中cleanupActiveReference有两个触发,一个是get的时候获取到的资源为null,一个是清除线程的检测

对活跃资源采用的weakReference+referenceQueue方式跟踪,跟leakCanary对于Acitivity是否内存泄漏的跟踪做法一样

类比的知识点还有WeakHashMap弱键方式实现原理,以及OkHttp中连接RealConnection对于每个持有当前Connection的RealCall们的存储管理。

这么做的一个目的是

1.可以跟踪被引用对象的一个回收情况,比如leakCanary

2.可以防止容器导致的内存泄漏问题,使用弱引用的value,保证EngineResource==null后不会因为集合类造成内存泄漏

3.依据refernce.get()是否为null、或者通过引用队列poll出引用对象本身定期或者主动触发修剪我们的容器

有兴趣可查看我另一篇文章【OkHttp源码分析之连接池复用

B、Lru资源缓存

LruCache相对于活动资源缓存就要简单一些,核心还是继承了标准的LruCache实现的

  //com.bumptech.glide.load.engine.Engine
  private final MemoryCache cache;
  private EngineResource<?> getEngineResourceFromCache(Key key) {
	Resource<?> cached = cache.remove(key);
	...
  }
  @Override
  public void onResourceReleased(Key cacheKey, EngineResource<?> resource) {
	activeResources.deactivate(cacheKey);
	if (resource.isMemoryCacheable()) {
	  cache.put(cacheKey, resource);
	} else {
	  resourceRecycler.recycle(resource, /*forceNextFrame=*/ false);
	}
  }	

他的资源放入时机为资源释放onResourceReleased回调的时候,什么时候会调用资源释放呢?

也就是在前面说到的活跃资源的引用计数数量为0的时候会释放,这个也代表该资源没有正在被使用。

class EngineResource<Z> implements Resource<Z> {		
  void release() {
	boolean release = false;
	synchronized (this) {
	  if (acquired <= 0) {
		throw new IllegalStateException("Cannot release a recycled or not yet acquired resource");
	  }
	  if (--acquired == 0) {
		release = true;
	  }
	}
	if (release) {
	  listener.onResourceReleased(key, this);
	}
  }
}		






MemoryCache 的实现类为 LruResourceCache,他的设计还有一个巧妙的地方

当内存不存的时候会分级进行内存清理

public class LruResourceCache extends LruCache<Key, Resource<?>> implements MemoryCache {
  ...
  public void trimMemory(int level) {
	if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
	  // Entering list of cached background apps
	  // Evict our entire bitmap cache
	  clearMemory();
	} else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN
		|| level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
	  // The app's UI is no longer visible, or app is in the foreground but system is running
	  // critically low on memory
	  // Evict oldest half of our bitmap cache
	  trimToSize(getMaxSize() / 2);
	}
  }
}		

看下调用的地方在哪

public class Glide {	
  ...
  public void trimMemory(int level) {
	// Engine asserts this anyway when removing resources, fail faster and consistently
	Util.assertMainThread();
	// Request managers need to be trimmed before the caches and pools, in order for the latter to
	// have the most benefit.
	for (RequestManager manager : managers) {
	  manager.onTrimMemory(level);
	}
	// memory cache needs to be trimmed before bitmap pool to trim re-pooled Bitmaps too. See #687.
	memoryCache.trimMemory(level);
	bitmapPool.trimMemory(level);
	arrayPool.trimMemory(level);
  }
  ...
}  

从上面我们还可以看到很多个池子都需要清理,这里就又到了另一个非常值得学习的知识点,Glide的极致的对象池复用机制。

C、极致的对象池复用

这里我们只做一个简单的介绍,后续如果有类似高性能的需求,咱们可以回来借鉴一下这里。 BaseKeyPool

遍布于源码各处的Key值复用,其中有数组复用池的Key,大小策略SizeStrategy的Key等等

BitmapPool

Bitmap复用,只要目标Bitmap的size小于缓存的Bitmap就可复用,不用立马重新分配,这对内存抖动的帮助非常大。 LruArrayPool

数组复用,主要用于解码时候的数组Bytebuffer的分配

线程池复用

线程复用,采用缓存和source两种线程池用于不同阶段的任务执行

3、硬盘缓存

Glide 默认并不会将原始图片展示出来,而是根据目标View的宽高对图片进行压缩和转换一系列操作之后得到的图片进行展示

Glide.with(this)
	 .load(url)
	 .diskCacheStrategy(DiskCacheStrategy.NONE)//关闭磁盘缓存
	 .into(imageView);

我们通过调用diskCacheStrategy()方法则可以改变这一默认行为。

跟内存缓存类似,硬盘缓存的实现也是使用的LruCache算法,采用的 DiskLruCache进行存储,存储策略如下:

DiskCacheStrategy.NONE: 表示不缓存任何内容。

DiskCacheStrategy.DATA 表示只缓存原始图片。

DiskCacheStrategy.RESOURCE 表示只缓存转换过后的图片(默认选项)。

DiskCacheStrategy.ALL : 表示既缓存原始图片,也缓存转换过后的图片。

DiskCacheStrategy.AUTOMATIC : 根据数据源和编码格式自动选择上述的策略

通过前面的内存缓存我们知道,如果在活动缓存、Lru缓存中没有找到数据, 那么就重新开启一个DecodeJob任务执行新的请求,下面我们就直接来看 DecodeJob.run 函数去找资源数据的加载:

class DecodeJob<R> implements ...{
   ...
  public void run() {
	 ...
	try {
	  //如果取消就通知加载失败
	  if (isCancelled) {
		notifyFailed();
		return;
	  }
	  //1. 执行runWrapped
	  runWrapped();
	} catch (CallbackException e) {
	...
	}
  }
   ... 
 }

  private void runWrapped() {
	switch (runReason) {
	  case INITIALIZE:
		//2. 找到执行的状态
		stage = getNextStage(Stage.INITIALIZE);
		//3. 找到具体执行器
		currentGenerator = getNextGenerator();
		//4. 开始执行
		runGenerators();
		break;
	 ...
	}
  }
  private DataFetcherGenerator getNextGenerator() {
	switch (stage) {
	  case RESOURCE_CACHE: //解码后的资源执行器
		return new ResourceCacheGenerator(decodeHelper, this);
	  case DATA_CACHE://原始数据执行器
		return new DataCacheGenerator(decodeHelper, this);
	  case SOURCE://新的请求,http 执行器
		return new SourceGenerator(decodeHelper, this);
	  case FINISHED:
		return null;
	  default:
		throw new IllegalStateException("Unrecognized stage: " + stage);
	}
  }
}
private void runGenerators() {
	...
	while (...(isStarted = currentGenerator.startNext())) {
	  ...
	}
	...
  }	

可以看到看到是根据当前的执行状态找到对应的执行器后执行不同执行器的 startNext 方法,这里有多种执行器 ResourceCacheGenerator

经过处理的资源数据缓存文件生成器

DataCacheGenerator

未经处理的资源数据缓存文件生成器

SourceGenerator

无缓存的情况下使用源数据的生成器,表示从源头获取,网络获取的方式一般最终会到HttpUrlFetcher中用InputStream进行获取

由于逻辑都类似,咱们只看一个即可,这里挑选 ResourceCacheGenerator 来看

ResourceCacheGenerator

class ResourceCacheGenerator implements DataFetcherGenerator,
	DataFetcher.DataCallback<Object> {
  ...  
  public boolean startNext() {
		...
		while (modelLoaders == null || !hasNextModelLoader()) {
		  resourceClassIndex++;
		  ...
		  //1. 拿到资源缓存 key
		  currentKey =
			  new ResourceCacheKey(// NOPMD AvoidInstantiatingObjectsInLoops
				  helper.getArrayPool(),
				  sourceId,
				  helper.getSignature(),
				  helper.getWidth(),
				  helper.getHeight(),
				  transformation,
				  resourceClass,
				  helper.getOptions());
		  //2. 通过 key 从DiskLruCache 获取到资源缓存
		  cacheFile = helper.getDiskCache().get(currentKey);
		  if (cacheFile != null) {
			sourceKey = sourceId;
			modelLoaders = helper.getModelLoaders(cacheFile);
			modelLoaderIndex = 0;
		  }
		}
		...
		while (!started && hasNextModelLoader()) {
		  //3. 获取一个数据加载器
		  ModelLoader<File, ?> modelLoader = modelLoaders.get(modelLoaderIndex++);
		  //3.1 为资源缓存文件,构建一个加载器,这是构建出来的是 ByteBufferFileLoader 的内部类 ByteBufferFetcher
		  loadData = modelLoader.buildLoadData(cacheFile,
			  helper.getWidth(), helper.getHeight(), helper.getOptions());
		  if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) {
			started = true;
			//3.2 利用 ByteBufferFetcher 加载,最后把结果会通过回调给 DecodeJob 的 onDataFetcherReady 函数
			loadData.fetcher.loadData(helper.getPriority(), this);
		  }
		}
		  
		...
	  
	}  
}

通过上面注释可以看出

1.首先根据 资源 ID 等一些信息拿到资源缓存 Key

2.通过 key 从 DiskLruCache 拿到缓存文件

3.构建一个 ByteBufferFetcher 加载缓存文件

4.加载完成之后回调到 DecodeJob 中。

四、总结

前部分我们从项目中的实际体验问题出发,通过对问题的各个击破,完成了图片的加载速度优化, 后半部分我们完成了Glide缓存部分的源码分析,可以看到 Glide 在性能优化方面可谓是达到了极致,不光设计了多级复杂的缓存策略,就连开销较小的Key也利用多处复用池进行优化。

除了上文提到的多级缓存、自动清理、生命周期绑定之外,还有很多优秀的小细节由于篇幅原因没有记录下来。

强烈建议每一个Android开发者都能亲自去捋一捋,感受这个图片加载老框架经久不衰的魅力。

五、参考资料

【Glide 图片预加载的正确姿势】

juejin.cn/post/738178…

【Android 图片加载框架 Glide 4.9.0 (二) 从源码的角度分析 Glide 缓存策略】 juejin.cn/post/684490…

【Glide4.x加载应用图标】

blog.csdn.net/JALLV/artic…