【项目踩坑实录】并发环境下,Glide缓存引起的图片加载异常

在现实主义者身上,并不是奇迹产生信仰,而是信仰产生奇迹。------《卡拉马佐夫兄弟》

背景简述

在维护智能手表主题管理功能时,我遇到过一个十分有趣的bug,从测试首次发现问题时感到十分困惑且不解,到自己我不断尝试并成功复现,直至最终找到根本原因与解决方案,历经一周左右时间。虽然是存在已久的历史问题,但仍有记录和总结的意义,同时也警醒自己在设计并发模块时,一定要心存敬意、考虑周全。

问题现象

问题的表现如上,用户编辑相册表盘后,返回到表盘列表页,预期是可以展示出新设置的相册表盘的预览图,但实际效果却是,图片确实有刷新出来,但又没有完全刷新,只展示了上半部分,下半部分是黑色。

技术设计方案

这个模块是我中途接手的,在初期接手时就惊讶其功能复杂之高、逻辑嵌套之深。为了更好地理解问题,有必要介绍一下这个功能的技术方案设计。

暂且称之为"手表主题模块",用来管理智能手表主题的样式,用户可以自定义智能手表上的字体、颜色、布局、背景图等,当用户完成设置后,生成当前配置下的预览图,并保存在应用数据目录下(Android/data/packageName/files/aaa_111.png),其它页面(例如表盘列表页)监听到预览图变化的事件后,更新 UI,展示出最新的预览图。

  • WatchThemePreviewManager:单例类,提供接口更新并保存预览图png,并通知监听者。
  • WatchThemeView:继承自 FrameLayout,是预览图的展示View,在 onAttachedToWindow()/onDetachedFromWindow() 中进行注册/反注册,监听预览图变化。

预期效果是,用户在APP中操作完主题样式设置后,其它所有展示这个手表表盘的页面,都会加载新的样式图。绝大多数场景下,的确表现如预期所想。但测试偶尔会发现前文图中的bug,新的预览图只展示了上半部分,其下半部分是纯黑色。查看应用数据目录后,发现生成的图片是完整的,并不存在缺失现象;从日志中也可以看到回调确实发生了,结果让人百思不得其解。

归结为2个问题

  • Q1:为什么图片只展示了局部,另一部分是纯黑的?
  • Q2:为什么回调发生后,没有把正常的图片刷新出来?

最终分析结论

略去中间繁琐的分析过程(无非就是在关键节点增加日志、打断点逐步调试等),直接将结论奉上。

  • A1:使用 Glide 显示 png 文件时,文件虽然存在,但其内容尚未完全写入。
  • A2:Glide 加载同名文件,命中磁盘缓存,不会重新读取文件。

Q1:图片加载不完整的原因

虽然已经在代码里考虑到,当 png 文件保存完成(大约300ms)后,才回调通知监听者。但存在极限场景,即在"表盘编辑页"编辑后,快速返回到上一级的"表盘列表页",由于"表盘列表页"在onRestart()时刷新界面,会读取到最新的预览图png路径,文件此时已存在,但尚未完成写入,因此 Glide 加载到的是只写入部分内容的 png,从而发生了图中的错误场景。

A1:解决方案-先写tmp文件再rename

实践中,对于这种保存数据到文件的场景,一般采用"保存临时文件->rename"的方案,先把数据写入到临时文件f.tmp中,写入完成后,再将其rename为f,可以避免外部发生"读取部分文件"的场景。

这里还使用了 FileOutputStream.flush()FileOutputStream.fd.sync(),对于 高频写入&读取 的场景,这样做可以保障文件被迅速推给内核和落盘持久化。但 sync() 函数有性能损耗,在工程中慎用。

样例代码如下:

kotlin 复制代码
/**
 * 原子性保存图片文件,外部不会读取到一部份文件
 */
fun saveBitmapToFileAtomic(bitmap: Bitmap, path: String): Boolean {
    val dst = File(path)
    dst.parentFile?.mkdirs()

    val tmp = File(dst.parentFile, dst.name + ".tmp")

    return try {
        FileOutputStream(tmp).use { out ->
            if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) return false
            out.flush() // 把文件推给内核
            out.fd.sync() // 把文件强制落盘(更稳,但更慢,勿大批量调用),适用于写完立刻读的场景
        }

        // renameTo 在同一分区通常是"原子替换"效果:读者要么看到旧文件,要么看到新完整文件
        if (dst.exists()) dst.delete()
        tmp.renameTo(dst)
    } catch (e: Exception) {
        tmp.delete()
        false
    }
}

Q2:回调后没有触发重新加载图片的原因

对于同一个 ImageView,使用 Glide 加载同名文件,如果不增加文件签名校验,会导致直接复用前一次产生的缓存。这也就解释了,为什么当真正完成 png 文件保存后,回调触发时,预览图并没有刷新为正常版本。

A2:解决方案-增加signature

解决方法是,在调用 Glide 加载图片时,使用 signature() 函数,用于在原有的缓存Key计算方法上增加唯一性校验,其内部使用参数的 equals()hashCode() 实现。接口文档如下:

plaintext 复制代码
public RequestOptions signature(@NonNull Key signature)

Sets some additional data to be mixed in to the memory and disk cache keys allowing the caller more control over when cached data is invalidated.

Note - The signature does not replace the cache key, it is purely additive.

Parameters:
signature - A unique non-null Key representing the current state of the model that will be mixed in to the cache key.

Returns:
This request builder.

See Also:
ObjectKey

因此,在使用Glide为此 ImageView 加载图片时,对于文件名不变但内容可能发生变化的场景,建议进一步增加签名校验,常见的 signature 参数有 file.lastModified()、文件字节数、md5等。

这里我使用 文件长度_更新时间,作为唯一key,可以解决文件内容更新的问题。

kotlin 复制代码
private fun loadPngIntoImageView(imageView: ImageView, pngFile: File) {
    glide.with(context)
        .load(pngFile)
        .signature(ObjectKey("${file.length()}_${file.lastModified()}")) // 文件长度_更新时间戳,解决文件更新问题
        // 略
        .into(imageView)
}

写在最后的反思

  1. 这种"先写tmp文件,然后重命名"的模式,适用于大多数写文件的场景。笔者之前开发apk下载工具时,也处理过类似问题,没有下载完成的apk文件,也会被资源管理器识别成安装包,但解析必定失败。
  2. 缓存虽好,使用要谨慎。使用Glide加载图片文件,在启用缓存的情况下,如果文件名不变但文件内容发生变化,是不会读取更新后的内容的。
相关推荐
my_power5205 小时前
检出git项目到android studio该如何配置
android·git·android studio
三少爷的鞋8 小时前
Repository 方法设计:suspend 与 Flow 的决选择指南(以朋友圈为例)
android
阿里云云原生9 小时前
Android App 崩溃排查指南:阿里云 RUM 如何让你快速从告警到定位根因?
android·java
cmdch201711 小时前
手持机安卓新增推送按钮功能
android
攻城狮201511 小时前
【rk3528/rk3518 android14 kernel-6.10 emcp sdk】
android
何妨呀~11 小时前
mysql 8服务器实验
android·mysql·adb
QuantumLeap丶12 小时前
《Flutter全栈开发实战指南:从零到高级》- 25 -性能优化
android·flutter·ios
木易 士心13 小时前
MVC、MVP 与 MVVM:Android 架构演进之路
android·架构·mvc
百锦再13 小时前
国产数据库的平替亮点——关系型数据库架构适配
android·java·前端·数据库·sql·算法·数据库架构