Load url with etag by glide

需求场景

通过固定的 url 加载图片, 在 url 不变的情况下, 服务器可能会更新 url 对应的资源, 客户端需要能够及时检测到图片的更新并加载最新资源.

服务器能够提供的交互

  1. 在正常情况下, 加载成功后, 返回 200 成功, Response Body 返回对应的图片资源, 同时 Response Header 中带上 etag 表示该 url 对应资源的特征值. 如:

    1
    etag:"bd6d0ff25778318b238ac530cb147247"
  2. 后续的请求, Request Header 带上 if-none-match 给服务器检查是否更新, 如果图片资源未更新, 直接返回 304, 否则正常返回.

    1
    2
    3
    4
    5
    6
    7
    // Request Header
    if-none-match: "bd6d0ff25778318b238ac530cb147247"
    //

    // Response (未改变)
    Statue Code : 304
    //

客户端实现

客户端需要

  1. 本地缓存 url 对应的 etag, 同时在后续请求的时候带上.
  2. 加载图片的时候需要支持给 url 设置 Header
  3. 在图片加载成功后, 如果是远程 url 也需要读取 Response 的 Header.

由于客户端一般都有通用的图片加载库, 所以 2,3 点需要图片库支持

本地缓存 url 对应的 etag

这个使用通用的 Key-Value 缓存都能实现, 可以用 SharedPrference, sqlite, MMKV 或 DataStore 都能实现, 我比较推荐用 MMKV 的形式, 最主要的特点是不需要切线程, 同时也是属于能够接受丢失的数据.

图片库的支持

这里以 Glide 举例, 常见的写法是

1
2
3
4
5
Glide.with()
.asDrawable()
.load(url)
.signature(etag)
.into()
  1. 设置 Header, 这个比较简单, 可以通过自带的 GlideUrl 设置
    1
    2
    3
    4
    5
    val header = LazyHeaders.Builder()
    .addHeader("if-none-match", etag)
    .build()
    val glideUrl = GlideUrl(url, header)
    Glide.load(glideUrl)
  2. Glide 取出 Http Response Header 中的 etag.
    我这边想到的办法比较麻烦:
    1. 调整 Glide 的网络库实现为 OKHttp
    2. 给 Glide 设置 OKHttpClient 时添加自定义的 Interceptor , 然后就可以取出 Response 中的 etag.

但是这样有个问题, 就是 etag 无法与 Glide 的 Request 进行关联, 导致同一个 url 必定会有两次网络请求.

  1. 第一次加载, 由于没有 etag 缓存, signature 传入的 etag 为 null. 此时 Glide 图片以 url 作为缓存 key.
  2. 第一次加载后, response 中取出 etag , 记录与 url 的映射关系.
  3. 第二次加载, signature 传入 url 对应的 etag, 由于该 etag 缓存 key 未找到对应的缓存图片, 所以又会发起一次网络请求.

所以纯粹修改 Glide 图片库似乎不太好.

先发起一次网络请求

由于图片正常返回时, Http Body 就是所需加载的图片资源 byte, 所以可以先发起一次网络请求, 拿到 etag 后, 设置 signature 并使用 Glide.load(byte[]) 方法直接加载 Http 的 Body, 这样只有一次网络请求.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
// Error
}

override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
return
}
val eTag = response.header("etag") ?: url
Glide.with(getContext()).asDrawable()
.load(response.body?.bytes() ?: ByteArray(0)))
.signature(ObjectKey(etag))
.into()
}

但是这样测试之后, 发现有新的问题, 即第二次请求虽然 etag 都匹配传入了, 但是依然发起了网络请求. 排查之后发现 Glide.load(byte[]) 方法使用的 ByteArrayLoader 中定义的 DataSource 为 Local, 所以使用该方法就不会写入本地缓存, 第二次请求就不会命中缓存.

解决办法也比较简单, 可以仿照 ByteArrayLoader 写一个自定义的 Loader , 然后注册到 Glide 中, 注意替换 DataSource 为 Remote.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
* 主要用于直接加载 HttpBody
* 配合 ETag 使用
*/
class ETagByteModel(val eTag: String, val data: ByteArray): Key {

override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(eTag.toByteArray(Key.CHARSET))
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as ETagByteModel

if (eTag != other.eTag) return false

return true
}

override fun hashCode(): Int {
return eTag.hashCode()
}
}

/**
* 参考 ByteArrayLoader 实现
* 调整 DataSource 为 remote, 这样可以走缓存逻辑
*/
class ETagByteLoader<Data>(val converter: ByteArrayLoader.Converter<Data>) : ModelLoader<ETagByteModel, Data> {

override fun buildLoadData(model: ETagByteModel, width: Int, height: Int, options: Options): ModelLoader.LoadData<Data>? {
return ModelLoader.LoadData(model, Fetcher(model.data, converter))
}

override fun handles(model: ETagByteModel): Boolean {
return true
}

private class Fetcher<Data>
/**
* @param model We really ought to copy the model, but doing so can be hugely expensive and/or
* lead to OOMs. In practice it's unlikely that users would pass an array into Glide and
* then mutate it.
*/ internal constructor(private val model: ByteArray, private val converter: ByteArrayLoader.Converter<Data>) : DataFetcher<Data> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Data>) {
val result = converter.convert(model)
callback.onDataReady(result)
}

override fun cleanup() {
// Do nothing.
}

override fun cancel() {
// Do nothing.
}

override fun getDataClass(): Class<Data> {
return converter.dataClass
}

override fun getDataSource(): DataSource {
// 注意 DataSource 的值
return DataSource.REMOTE
}
}

}

/**
* 对应 InputStream 解析
*/
class StreamFactory : ModelLoaderFactory<ETagByteModel, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<ETagByteModel, InputStream> {
return ETagByteLoader<InputStream>(
object : ByteArrayLoader.Converter<InputStream> {
override fun convert(model: ByteArray?): InputStream {
return ByteArrayInputStream(model)
}

override fun getDataClass(): Class<InputStream> {
return InputStream::class.java
}
})
}

override fun teardown() {
// Do nothing.
}
}

然后注册到 Glide

1
2
3
4
5
6
7
@GlideModule
public final class EtagGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(Context context, Glide glide, Registry registry) {
registry.replace(ETagByteModel.class, InputStream.class, new StreamFactory());
}
}

然后加载的时候调用

1
2
3
4
5
val eTag = response.header("etag") ?: url
Glide.with(getContext()).asDrawable()
.load(EtagByteModel(etag, response.body?.bytes() ?: ByteArray(0)))
.signature(ObjectKey(etag))
.into()

这样即可以实现 etag 更新, 也可以利用到 Glide 的缓存, 对项目修改比较小.

最终流程

  1. 首先检查是否有 url 对应的 etag 缓存
    • 如果没有缓存, 先发起一次网络请求, 成功之后利用 Glide 设置 signature 加载 http body.
    • 如果有缓存
      1. 先通过Glide 设置 signature 加载 url
      2. 在 Glide 的成功回调里面带上 if-none-match 的 Header 发起网络请求, 用于检查服务器是否更新了图片.

预加载与缓存的坑

如果使用 Glide 的 preload 方法并且缓存了 preload 返回的 Drawable, 需要注意 Drawable 中的 Bitmap 对象在 preload 完成后会释放掉, 此时 Bitmap 会被 Glide 回收后复用给其他请求.
如果自己缓存了该 Bitmap, 会导致两种结果:

  1. Bitmap 错误展示了其他地方请求的结果, 即展示的图片与实际的 url 对不上.
  2. Bitmap 复用 Crash
    1
    2
    3
    4
    5
    6
    7
    8
    9
    java.lang.RuntimeException
    Canvas: trying to use a recycled bitmap android.graphics.Bitmap@111bacc
    android.graphics.BaseCanvas.throwIfCannotDraw(BaseCanvas.java:77)
    android.graphics.MiuiCanvas.throwIfCannotDraw(MiuiCanvas.java:329)
    android.graphics.RecordingCanvas.throwIfCannotDraw(RecordingCanvas.java:277)
    android.graphics.BaseRecordingCanvas.drawBitmap(BaseRecordingCanvas.java:88)
    android.graphics.drawable.BitmapDrawable.draw(BitmapDrawable.java:548)
    android.widget.ImageView.onDraw(ImageView.java:1434)
    ...
    解决办法是自己缓存的场景不要用 preload, preload 的实现也是 CustomTarget, 并且在 preload 成功后 clear 了资源. 所以我们使用 into CustomTarget 即可.
1
2
3
4
5
6
7
8
9
10
.into(object: CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
// 保存 Drawable
}

override fun onLoadCleared(placeholder: Drawable?) {
// 释放 Drawable
}

})