Glide及图片加载

395 阅读13分钟

一、图片加载框架对比

首先,当下流行的图片加载框架有那么几个,可以拿 Glide 跟Fresco对比,例如这些:

Glide:

  • 多种图片格式的缓存,适用于更多的内容表现形式(如Gif、WebP、缩略图、Video)
  • 生命周期集成(根据Activity或者Fragment的生命周期管理图片加载请求)
  • 高效处理Bitmap(bitmap的复用和主动回收,减少系统回收压力)
  • 高效的缓存策略,灵活(Picasso只会缓存原始尺寸的图片,Glide缓存的是多种规格),加载速度快且内存开销小(默认Bitmap格式的不同,使得内存开销是Picasso的一半)

Fresco:

  • 最大的优势在于5.0以下(最低2.3)的bitmap加载。在5.0以下系统,Fresco将图片放到一个特别的内存区域(Ashmem区)
  • 大大减少OOM(在更底层的Native层对OOM进行处理,图片将不再占用App的内存)
  • 适用于需要高性能加载大量图片的场景

对于一般App来说,Glide完全够用,而对于图片需求比较大的App,为了防止加载大量图片导致OOM,Fresco 会更合适一些。并不是说用Glide会导致OOM,Glide默认用的内存缓存是LruCache,内存不会一直往上涨。

LruCache初始化是需要指定一个内存大小的,在大量图片的需求下,可能会指定大一点的内存,保证效率,LruCache内存越大,发生OOM的几率就越高,特别是在老设备上,内存本来就小。

二、假如让你自己写个图片加载框架,你会考虑哪些问题?

首先,梳理一下必要的图片加载框架的需求:

  • 异步加载:线程池
  • 切换线程:Handler
  • 缓存:LruCache、DiskLruCache
  • 防止OOM:软引用、LruCache、图片压缩、Bitmap像素存储位置
  • 防止内存泄露:注意ImageView的正确引用,生命周期管理
  • 列表滑动加载的问题:加载错乱、队满任务过多问题

2.1、异步加载

缓存一般有三级,内存、硬盘、网络。

加载流程:先从内存加载,内存中有,就直接加载;内存中没有,就从硬盘加载,若硬盘中有,加载的同时缓存到内存;若硬盘也没有,从网络加载,同时缓存到内存和硬盘。

由于网络会阻塞,所以读内存和硬盘可以放在一个线程池,网络需要另外一个线程池。所以用两个线程池比较合适。

Glide 必然也需要多个线程池,看下源码是不是这样:

public final class GlideBuilder {
  ...
  private GlideExecutor sourceExecutor; //加载源文件的线程池,包括网络加载
  private GlideExecutor diskCacheExecutor; //加载硬盘缓存的线程池
  ...
  private GlideExecutor animationExecutor; //动画线程池

Glide使用了三个线程池,不考虑动画的话就是两个。

2.2、切换线程

图片异步加载成功,需要在主线程去更新ImageView,

无论是RxJava、EventBus,还是Glide,只要是想从子线程切换到Android主线程,都离不开Handler。

看下Glide 相关源码:

    class EngineJob<R> implements DecodeJob.Callback<R>,Poolable {
      private static final EngineResourceFactory DEFAULT_FACTORY = new EngineResourceFactory();
      //创建Handler
      private static final Handler MAIN_THREAD_HANDLER =
          new Handler(Looper.getMainLooper(), new MainThreadCallback());

问:RxJava是完全用Java语言写的,那怎么实现从子线程切换到Android主线程的?

答:引入了rxandroid,利用handler进行主线程切换。

2.3、缓存

2.3.1、内存缓存-LruCache

目的:防止应用重复将图片读入到内存,造成内存资源浪费。

一般都是用LruCache,LruCache 采用最近最少使用算法,设定一个缓存大小,当缓存达到这个大小之后,会将最老的数据移除,避免图片占用内存过大导致OOM。

2.3.2、 磁盘缓存-DiskLruCache

目的:防止应用重复的从网络或者其他地方下载和读取数据。

DiskLruCache 跟 LruCache 实现思路是差不多的,一样是设置一个总大小,每次往硬盘写文件,总大小超过阈值,就会将旧的文件删除。

2.4、防止OOM

加载图片非常重要的一点是需要防止OOM,上面的LruCache初始化是需要指定一个内存大小的,在大量图片的需求下,可能会指定大一点的内存,保证效率,LruCache内存越大,发生OOM的几率就越高,特别是在老设备上,内存本来就小。那应该探索其它防止OOM的方法。

2.4.1、软引用
//强引用:
//直接new出来的对象
String str = new String(“aaa”);
//特点:对象任何时候都不会对系统回收,JVM宁愿抛出OOM异常,也不会回收强引用所指向的对象。//软引用:SoftReference
String str = new String(“aaa”);
SoftReference s = new SoftReference(str);
if(s.get() != null){ // 一定要判空
String softStr = s.get();
}
//特点:内存空间足,不回收;内存空间不足,才会回收//弱引用:WeakReference
String str = new String(“aaa”);
WeakReferences = new WeakReference(str);
if(s.get() != null){ // 一定要判空
String weakStr = s.get();
}
//特点:无论内存空间是否足够,只要发现,就会回收//虚引用:PhantomReference
//特点:可以在任何时候被回收,无法通过get()方法来获取对象实例。仅仅只能在对象被回收时收到一个通知。

到底什么时候用软引用,什么时候用弱引用?

  • 如果只是想避免OOM,则可以使用软引用;如果对应用的性能更在意,则可以使用弱引用。
  • 根据对象是否经常被使用。如果对象经常被使用,则尽量用软引用,否则使用弱引用。
2.4.2、onLowMemory

当内存不足的时候,Activity、Fragment会调用onLowMemory方法,可以在这个方法里去清除缓存,Glide使用的就是这一种方式来防止OOM。

//Glide
public void onLowMemory() {
    clearMemory();
}
​
public void clearMemory() {
    // Engine asserts this anyway when removing resources, fail faster and consistently
    Util.assertMainThread();
    // memory cache needs to be cleared before bitmap pool to clear re-pooled Bitmaps too. See #687.
    memoryCache.clearMemory();
    bitmapPool.clearMemory();
    arrayPool.clearMemory();
  }
2.4.3、从Bitmap像素存储位置考虑

各个解码格式所占byte大小(1byte = 8bit):

/**
  * Bytes per pixel definitions
  */
 public static final int ALPHA_8_BYTES_PER_PIXEL = 1;
 public static final int ARGB_4444_BYTES_PER_PIXEL = 2;
 public static final int ARGB_8888_BYTES_PER_PIXEL = 4;
 public static final int RGB_565_BYTES_PER_PIXEL = 2;
 public static final int RGBA_F16_BYTES_PER_PIXEL = 8;
  • 如果Bitmap使用 RGB_565 格式,则1像素占用 2 byte,16位;
  • 如果Bitmap使用ARGB_8888 格式则占4 byte,32位;
  • Glide4.0之前,Glide默认使用RGB565格式,比较省内存;内存开销是Picasso的一半。
  • Glide4.0之后,默认格式已经变成了ARGB_8888格式了,这一优势也就不存在了。

2.5、防止内存泄露

Glide的做法是通过启动一个无UI空Fragment监听生命周期回调,看 RequestManager 这个类

public void onDestroy() {
    targetTracker.onDestroy();
    for (Target<?> target : targetTracker.getAll()) {
      //清理任务
      clear(target);
    }
    targetTracker.clear();
    requestTracker.clearRequests();
    lifecycle.removeListener(this);
    lifecycle.removeListener(connectivityMonitor);
    mainHandler.removeCallbacks(addSelfToLifecycle);
    glide.unregisterRequestManager(this);
  }
复制代码

在Activity/fragment 销毁的时候,取消图片加载任务。

2.6、列表滑动加载的问题

2.6.1、加载错乱

由于RecyclerView或者LIstView的复用机制,网络加载图片开始的时候ImageView是第一个item的,加载成功之后ImageView由于复用可能跑到第10个item去了,在第10个item显示第一个item的图片肯定是错的。

常规的做法是:

  • 给ImageView设置tag,tag一般是图片地址,更新ImageView之前判断tag是否跟url一致。
  • 在item从列表消失的时候,取消对应的图片加载任务。。
2.6.2、队满任务过多

列表滑动,会有很多图片请求,如果是第一次进入,没有缓存,那么队列会有很多任务在等待。所以在请求网络图片之前,需要判断队列中是否已经存在该任务,存在则不加到队列去。

三、Glide缓存机制

3.1、缓存key

不管是内存缓存还是磁盘缓存,存储的时候肯定需要一个唯一 key 值。Glide生成缓存key的地方其实就在Engine的load方法中,通过重写equals和hashCode方法,来确保只有key对象的唯一性。

通过 URL + ignature + width + height 等等10个参数一起传入到EngineKeyFactory的buildKey()方法当中,从而构建出了一个EngineKey对象,这个EngineKey也就是Glide中的缓存Key了。

因此,

如果你图片的width或者height发生改变,也会生成一个完全不同的缓存Key。

  • 4.4以前必须长宽相等Bitmap才可以复用;
  • 而4.4及以后是Size >= 所需就可以复用。
//Engine  
public synchronized <R> LoadStatus load(
      GlideContext glideContext,
      Object model,
      Key signature,
      int width,
      int height,
      Class<?> resourceClass,
      Class<R> transcodeClass,
      Priority priority,
      DiskCacheStrategy diskCacheStrategy,
      Map<Class<?>, Transformation<?>> transformations,
      boolean isTransformationRequired,
      boolean isScaleOnlyOrNoTransform,
      Options options,
      boolean isMemoryCacheable,
      boolean useUnlimitedSourceExecutorPool,
      boolean useAnimationPool,
      boolean onlyRetrieveFromCache,
      ResourceCallback cb,
      Executor callbackExecutor) {
    long startTime = VERBOSE_IS_LOGGABLE ? LogTime.getLogTime() : 0;
​
    //创建EngineKey对象
    EngineKey key = keyFactory.buildKey(model, signature, width, height, transformations,
        resourceClass, transcodeClass, options);
    ......
    return new LoadStatus(cb, engineJob);
  }
​
//EngineKey
class EngineKey implements Key {
  private final Object model;
  private final int width;
  private final int height;
  private final Class<?> resourceClass;
  private final Class<?> transcodeClass;
  private final Key signature;
  private final Map<Class<?>, Transformation<?>> transformations;
  private final Options options;
  private int hashCode;
​
  EngineKey(
      Object model,
      Key signature,
      int width,
      int height,
      Map<Class<?>, Transformation<?>> transformations,
      Class<?> resourceClass,
      Class<?> transcodeClass,
      Options options) {
    this.model = Preconditions.checkNotNull(model);
    this.signature = Preconditions.checkNotNull(signature, "Signature must not be null");
    this.width = width;
    this.height = height;
    this.transformations = Preconditions.checkNotNull(transformations);
    this.resourceClass =
        Preconditions.checkNotNull(resourceClass, "Resource class must not be null");
    this.transcodeClass =
        Preconditions.checkNotNull(transcodeClass, "Transcode class must not be null");
    this.options = Preconditions.checkNotNull(options);
  }
​
  @Override
  public boolean equals(Object o) {
    if (o instanceof EngineKey) {
      EngineKey other = (EngineKey) o;
      return model.equals(other.model)
          && 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;
  }
​
  @Override
  public int hashCode() {
    if (hashCode == 0) {
      hashCode = model.hashCode();
      hashCode = 31 * hashCode + signature.hashCode();
      hashCode = 31 * hashCode + width;
      hashCode = 31 * hashCode + height;
      hashCode = 31 * hashCode + transformations.hashCode();
      hashCode = 31 * hashCode + resourceClass.hashCode();
      hashCode = 31 * hashCode + transcodeClass.hashCode();
      hashCode = 31 * hashCode + options.hashCode();
    }
    return hashCode;
  }
  ....
}
3.1.1、缓存key所引发的问题

缓存key中一个重要参数信息就是图片的URL,这会造成两个问题:

  • 同一张图片,动态URL的时候会发生重复加载的问题;

    原因:

    • 如七牛云上的图片会在原url后面添加一个token参数,而token又不是一成不变的,导致图片url变了,缓存key也变了,结果就造成了,明明是同一张图片,因为token不断在变,导致Glide缓存失效。

    解决方案:

    • 新建一个继承于GlideUrl的子类——MyGlideUrl类,在MyGlideUrl里重写getCacheKey方法,最后在Glide请求里面采用MyGlideUrl,如下:

      Glide.with(this)
          .load(new MyGlideUrl(url))
          .into(imageView);
      
  • 同一个URL,返回不同的图片,前端显示的还是之前旧的图片。

    原因:

    • 因为虽然服务器返回的图片变了,但是图片 url 还是以前那个,其决定缓存 Key 的参数也不会变,Glide 就认为有该缓存,就会直接从缓存中获取,而不是重新下载,所以显示的还是以前的图片。

    解决方案:

    • 图片 url 不要固定。也就是说如果某个图片改变了,那么该图片的 url 也要跟着改变。(推荐)
    • 使用 signature() 更改缓存 Key。我们刚刚知道了决定缓存 Key 的参数包括 signature,刚好 Glide 提供了 signature() 方法来更改该参数。(不推荐,增加前后端工作)具体如下:
    Glide.with(this).load(url).signature(new ObjectKey(timeModified)).into(imageView);
    

    其中 timeModified 可以是任意数据,这里用图片的更改时间。例如图片改变了,那么服务器应该改变该字段的值,然后随图片 url 一起返回给前端,这样前端加载的时候就知道图片改变了,需要重新下载。

    • 禁用缓存。前端加载图片的时候设置禁用内存与磁盘缓存,这样每次加载都会重新下载最新的。(不推荐)

3.2、缓存类型

Glide 缓存机制可以说是设计的非常完美,考虑的非常周全,下面就以一张表格来说明下 Glide 缓存。

缓存类型缓存代表说明
活动缓存ActiveResources如果当前对应的图片资源是从内存缓存中获取的,那么会将这个图片存储到活动资源中。
内存缓存LruResourceCache图片解析完成并最近被加载过,则放入内存中
磁盘缓存-资源类型DiskLruCacheWrapper被解码后的图片写入磁盘文件中
磁盘缓存-原始数据DiskLruCacheWrapper网络请求成功后将原始数据在磁盘中缓存
3.2.1、活动缓存
  • 原理:弱引用 + HashMap,用来缓存正在使用中的图片。

  • 读取:优先读取活动缓存。

  • 写入:

    • 从内存缓存读取并移出内存缓存,再写入活动缓存。
    • 从磁盘或原始数据加载完成。

设计原理:保护不想被回收掉的图片不被 LruCache 算法回收掉。

比如我们 Lru 内存缓存 size 设置装 99 张图片,在滑动 RecycleView 的时候,如果刚刚滑动到 100 张,那么就会回收掉我们已经加载出来的第一张,这个时候如果返回滑动到第一张,会重新判断是否有内存缓存,如果没有就会重新开一个 Request 请求,很明显这里如果清理掉了第一张图片并不是我们要的效果。所以在从内存缓存中拿到资源数据的时候就主动添加到活动资源中,并且清理掉内存缓存中的资源。这么做很显然好处是 保护不想被回收掉的图片不被 LruCache 算法回收掉,充分利用了资源。

3.2.2、内存缓存
  • 原理:LruCache最近最少原则,用来缓存最近被加载过,并且当前没有使用的图片。
  • 读取:活动缓存读取不到,则从内存缓存读取。
  • 写入:从活动缓存移出后。
3.2.3、磁盘缓存-资源类型
  • 原理:DiskLruCache,被解码后的图片写入磁盘文件中。
  • 读取:内存缓存读取不到,则从资源类型读取。
  • 写入:原图资源解码后。
3.2.4、磁盘缓存-原始数据
  • 原理:图片原始数据在磁盘中的缓存(从网络、文件中直接获得的原始数据)。
  • 读取:当以上缓存都没读取到,则将原始数据解码转换读取出来。
  • 写入:从网络、文件中获取到就将其写入磁盘。
3.2.5、流程总结

以开启内存缓存和磁盘缓存为例

  • 先从活动缓存获取,获取到则直接显示;
  • 获取不到,则从内存缓存获取,获取到则将图片移出内存缓存,写入活动缓存并显示;
  • 获取不到,则从磁盘缓存-资源类型获取,获取到则将图片写入活动缓存并显示;
  • 获取不到,则从磁盘缓存-原始数据获取并转换,获取到则将图片写入活动缓存和磁盘缓存-资源类型并显示;
  • 获取不到,则从网络、文件获取,获取到则将图片写入磁盘缓存-原始数据,转换后,将图片写入活动缓存和磁盘缓存-资源类型并显示。
  • 不再使用的图片则将移出活动缓存,写入内存缓存。

四、Glide加载GIF原理

  • 首先需要区分加载的图片类型,即网络请求拿到输入流后,获取输入流的前三个字节,若为 GIF 文件头,则返回图片类型为 GIF。
  • 确认为 GIF 动图后,会构建一个 GIF 的解码器(StandardGifDecoder),它可以从 GIF 动图中读取每一帧的数据并转换成 Bitmap,然后使用 Canvas 将 Bitmap 绘制到 ImageView 上,下一帧则利用 Handler 发送一个延迟消息实现连续播放,所有 Bitmap 绘制完成后又会重新循环,所以就实现了加载 GIF 动图的效果。