【项目踩坑实录】并发环境下,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加载图片文件,在启用缓存的情况下,如果文件名不变但文件内容发生变化,是不会读取更新后的内容的。
相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui