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

背景简述
在维护智能手表主题管理功能时,我遇到过一个十分有趣的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)
}
写在最后的反思
- 这种"先写tmp文件,然后重命名"的模式,适用于大多数写文件的场景。笔者之前开发apk下载工具时,也处理过类似问题,没有下载完成的apk文件,也会被资源管理器识别成安装包,但解析必定失败。
- 缓存虽好,使用要谨慎。使用Glide加载图片文件,在启用缓存的情况下,如果文件名不变但文件内容发生变化,是不会读取更新后的内容的。