选完照片,系统却记了这么多次访问
这次是在一个有 256 张照片的相册里选图。使用过程中没发现异常,照片展示、切换相册、选完返回都正常。
但打开小米的应用行为记录,看到的却是这样: 
两条「照片和视频」记录,分别是 87 次和 9 次。
我只是打开相册找了几张照片,怎么会留下这么多访问记录?如果用户看到这个页面,会不会以为应用一直在读自己的图库?
项目用的是 wechat_assets_picker,底层通过 photo_manager 访问系统媒体库。我顺着这两个包往下查,发现有些数据刚查过,换个方法又查了一遍。后面尝试了两种改法:缓存分页返回的实体列表,或者保留查询用的 Cursor。
系统记录没法直接告诉我是哪段代码触发的,所以先看插件调用了多少次 ContentResolver.query()。这里的 query 次数和系统行为记录不是同一个计数,后面的对比也都以插件日志为准。
本文基于 wechat_assets_picker 10.1.3、photo_manager 3.12.0,设备是 Android 16 的小米 23127PN0CC。以下缓存接口属于本地实验实现。
这些查询是从哪里来的
我先重复打开选择器、切换相册和翻页这几种操作,沿着调用链找到真正访问媒体库的位置:
text
wechat_assets_picker
DefaultAssetPickerProvider
→ AssetPathEntity / AssetEntity
→ photo_manager MethodChannel
→ PhotoManagerPlugin.kt
→ PhotoManager.kt
→ AndroidQDBUtils.kt
→ ContentResolver.query()
在原生查询入口记录参数后,可以把查询分成四类:
| 查询类型 | 主要用途 | 排查时看什么 |
|---|---|---|
| 目录查询 | 相册名称、bucket、数量等 | 请求类型、筛选和目录选项 |
| 数量查询 | 当前相册或全库总数 | 相册 ID、统计条件 |
| 分页/范围查询 | 首屏、下一页、封面资源 | 相册、offset、size、筛选、排序 |
| 单资源查询 | 按 ID 获取资源元数据 | 资源 ID、入口、存在性检查 |
把日志和页面操作对上后,先发现了相册数量的重复查询。
数量已经查到了,为什么还在查
相册目录通常要显示名称、数量和封面。上层逻辑可以简化为:
dart
for (final path in paths) {
await getAssetCountFromPath(path);
await getThumbnailFromPath(path);
}
看起来很顺:先拿数量,再拿封面。
但数量查询结束时,代码通过 copyWith 更新了列表:
dart
_paths[index] = _paths[index].copyWith(
assetCount: assetCount,
);
更新后的引用关系是这样的:
text
局部变量 path → 旧 wrapper,assetCount = null
列表中的元素 → 新 wrapper,assetCount = 11
紧接着传给封面方法的 path 还是旧对象。封面发现它没有数量,就又调用了一次 assetCountAsync。
我打印局部对象与列表当前对象,得到过这样的日志:
text
[picker-count] id=-1617409521 old=null list=1 same=false
same=false 说明两边已经不是同一个对象:数量写进了新对象,封面方法拿到的却还是旧对象。
这里不用改掉原来的不可变模型,查完数量后,把更新后的 wrapper 传给封面方法就可以复用这个数量。
不过,封面方法里还有一次数量查询。它用这个接口取第一张图片:
dart
await path.path.getAssetListRange(start: 0, end: 1);
这个版本的 getAssetListRange 在 Dart 层会先查数量,用来修正 end。封面只需要一张资源,可以改成:
dart
final assets = await path.path.getAssetListPaged(
page: 0,
size: 1,
);
if (assets.isEmpty) {
return null;
}
也就是说,给一个相册补数量和封面,原来可能查三次数量:先主动查一次,旧 wrapper 再查一次,range 接口又查一次。
之前对 16 个相册的数量和封面加载做过一次对比:
| 改动 | 数量 query |
|---|---|
| 原实现 | 48 |
| 传递更新后的 wrapper | 32 |
| 再将封面改为 paged | 16 |
改完后,这部分数量查询从每个相册三次变成了一次。
分页命中了,缩略图还在查
修完上层冗余调用后,我给原生分页加了缓存。返回刚才看过的页面,日志里已经出现 HIT,但媒体查询仍然不少。
问题出在另一条链:
text
getThumb
→ dbUtils.getAssetEntity(id)
→ 查询资源元数据
→ ThumbnailUtil
→ Glide 加载缩略图
加载缩略图之前,插件会先按 ID 查询资源元数据。
即使 Glide 里已经有这张图,执行到 Glide 之前,getAssetEntity 也已经查过一次了。分页缓存只管分页方法,缩略图方法并没有用到它。
开启缓存后的一段日志里,168 次 query 中有 128 次是按 ID 查单个资源。只给分页加缓存,显然还漏了这一部分。
分页结果里已经有 ID、路径、宽高这些信息,缩略图方法再按 ID 来取时,能不能直接用这份数据?
把缓存放进 photo_manager
数量重复调用在 picker 里修。分页和缩略图都要用到的元数据,则缓存在 photo_manager 里,避免每个方法各查各的。
具体是在 IDBUtils 外包一层代理,需要缓存的方法在这里处理,其余方法继续调用原实现:
kotlin
private inner class DBUtilsDelegate(
private val utils: IDBUtils
) : IDBUtils by utils {
// 覆盖目录、数量、分页和单资源等需要复用的入口。
}
这个代理要保存下来,不能每次访问 getter 都重新创建,否则缓存也跟着重新建了。关闭缓存后,方法直接调用原来的查询实现。
缓存结构如下: 
缓存实体列表,还是缓存 Cursor
方案一:缓存已经转换好的实体列表
第一次查询时,把结果转换成 List<AssetEntity>,关闭 Cursor,然后保存列表。
下一次请求相同窗口,直接返回列表副本。
text
A 相册第 0 页 → query → 实体列表 → 关闭 Cursor → 缓存
B 相册第 0 页 → query → 实体列表 → 关闭 Cursor → 缓存
返回 A 第 0 页 → 直接复用实体列表
它适合频繁切相册、返回已读页面的场景:缓存还在时,不需要重新查询,也不需要再次从 Cursor 转换实体。
但第一次从第 0 页读到第 1 页,新窗口还没缓存,通常仍然需要一次 query。
缓存 key 包含相册 ID、offset、size、媒体类型、where、绑定参数和排序。页码先换算成 offset,这样分页和范围查询可以共用缓存。
如果已经缓存了完整的 [0,80),取封面的 [0,1) 也可以直接从中截取,不必再查一次。
这里有个细节:原生转换可能跳过不存在的文件。如果请求 80 行,只返回 79 个实体,就不能默认这 79 个实体仍与原始行号一一对应。因此本地只从完整窗口复用子范围;短结果保留精确请求复用。
方案二:缓存相册查询的 Cursor
第一次打开相册,执行不带分页 LIMIT 的查询并保存 Cursor;后续分页在同一个结果集上移动位置、读取目标行:
kotlin
// 读取核心片段,定位和转换过程需要在同一读取临界区内。
cursor.moveToPosition(start - 1)
for (index in 0 until size) {
if (!cursor.moveToNext()) break
cursor.toAssetEntity(
context,
throwIfNotExists = false
)?.let { result.add(it) }
}
Cursor key 保留相册、媒体类型、筛选参数和排序,去掉页码和页大小。同一个查询结果可以服务不同页面。
text
A 相册第 0 页 → query → 保存 Cursor → 读第 0 页
A 相册第 1 页 → 复用 Cursor → 移动位置并读取
A 相册第 2 页 → 复用 Cursor → 移动位置并读取
它更适合连续翻页,新的页面也能复用已经建立的查询结果。
Cursor 底层会按需填充 CursorWindow,所以保留 Cursor 并不会一次性把整个相册加载进内存。后续虽然没有再次调用 query,仍可能发生窗口读取。
两种方案的区别
| 维度 | 实体分页缓存 | Cursor 缓存 |
|---|---|---|
| 保存内容 | 已转换好的某个窗口的实体列表 | 某个相册及筛选条件的查询 Cursor |
| 首次连续读新页 | 每个未覆盖窗口通常查询一次 | Cursor 有效时继续复用查询结果 |
| 返回已读页 | 直接复用实体 | 重新定位、读取、转换实体 |
| 切相册再返回 | 对应实体页未淘汰即可复用 | 对应 Cursor 未淘汰即可复用 |
| 主要资源管理 | 列表容量与对象持有 | Cursor 容量、读取互斥和关闭时机 |
| 更匹配的操作 | 频繁切相册、复访页面 | 同一相册连续翻页 |
切换相册后再回来,两种方案都能复用。实体列表省下了再次转换的过程,Cursor 则能继续读还没访问过的页面。
分页查过的数据,缩略图直接用
两种分页方案拿到实体后,都按 ID 存一份,供 getAssetEntity 使用:
kotlin
private val assetCache = LruCache<String, AssetEntity>(256)
// 在缓存锁内调用;外层同时核对 generation。
private fun seedAssets(assets: List<AssetEntity>) {
if (utils !== AndroidQDBUtils) return
assets.forEach { assetCache.put(it.id.toString(), it) }
}
后续 getAssetEntity(id) 先检查索引,命中返回副本;未命中再查 MediaStore,成功后回填。
索引由各数据库代理独立持有。命中后仍检查文件是否存在,显式刷新时重新查询,查询失败或返回 null 时不写入缓存。
当前实现把单资源查询串行执行。几个请求同时查同一个 ID 时,第一个查完写入缓存,后面的就能直接取到。不过,不同 ID 的查询也会排队,后续还可以把锁的范围缩小到单个 ID。
缩略图的加载过程就变成了:
text
分页取得元数据 → 填充资源索引 → 缩略图请求复用元数据 → 加载图片
目录里的数量,哪些能直接用
目录查询已经取得各 bucket 的数量,后续相同条件的 bucket 数量查询,可以直接使用目录结果。
当前 Android 实现中,目录查询会排除 bucket_id IS NULL 的记录,而全库数量查询没有这个限制。直接将目录数量相加替代全库数量,可能漏数据。因此,全库数量保留独立查询和缓存。
Recent 也是一样:如果已经查过目录,且筛选条件一致,就用已有结果;还没查过目录时,就按原来的方式查询,不提前做目录分组。
缓存清了,旧查询又把数据写回来怎么办
只监听媒体变化并 clear() 还不够。
考虑下面的时序:
text
请求 A 开始查询
图库发生变化 → 清缓存
请求 A 完成 → 把变化前的结果重新写回
结果就是刚清完,缓存又旧了。
我给缓存加了一个版本号 generation。查询前记下版本,查完后再比较一次:
kotlin
val generation = synchronized(cacheLock) {
if (cacheEnabled) cacheGeneration else null
}
// 数据库查询在 cacheLock 之外执行。
val result = queryOriginal()
synchronized(cacheLock) {
if (cacheEnabled && generation == cacheGeneration) {
cache.put(key, result)
}
}
每次清缓存,先让 generation 加一。旧查询完成时发现版本变了,就不再把结果写进缓存。
图库变化、授权回调、部分授权选择变化、应用回到前台,以及主动刷新、释放或关闭缓存时,都会触发清理。插件销毁时也会释放缓存。
这里拦住的是旧数据写回缓存。已经返回给 Dart 的对象仍在页面里,权限被收回时,页面状态和正在执行的请求还需要另外处理。
Cursor 不能说关就关
缓存 Cursor 比缓存列表麻烦的一点是:清理缓存时,可能还有线程正在读它。
两个典型竞态:
text
线程 A 正在读取第 0 页
线程 B moveToPosition 到第 1 页
→ A 后续读取的位置被改变
text
线程 A 正在读 Cursor
媒体变化,清理线程直接 close
→ A 继续访问已关闭 Cursor
第一种,把移动位置和整页读取放在同一个临界区。
第二种,用租约记录还有多少请求正在读。Cursor 被淘汰时,先从缓存移除,不再接受新读者;已有读者全部结束后,再执行 close。
下面是 LeasedResource 中负责借用和关闭的部分:
kotlin
@Synchronized
fun acquire(): Lease? {
if (retired) return null
readers++
return Lease()
}
@Synchronized
fun retire() {
retired = true
closeIfUnused()
}
private fun closeIfUnused() {
if (retired && readers == 0 && !closed) {
closed = true
value.close()
}
}
读取侧的使用方式:
kotlin
try {
val result = lease.read { cursor ->
AndroidQDBUtils.readAssetCursor(context, cursor, offset, size)
}
// generation 有效时,用 result 填充资源索引。
return result
} finally {
lease.close()
}
lease.read 内部串行访问 Cursor;lease.close 减少读者数,并在退役且无读者时完成关闭。读取异常也会淘汰对应缓存,避免后续继续使用异常 Cursor。
这样,清理线程移除缓存后就可以返回,最后一个读者负责关闭 Cursor。
改完之后有什么变化?
用设备上 Recent 中的 127 张图片,每页 80,共两页做对照,三个操作的查询次数如下:
| 操作 | 不缓存 | 实体分页 | Cursor |
|---|---|---|---|
| 连续读取两页 | 2 | 2 | 1 |
| 返回已读两页 | 2 | 0 | 0 |
| 分页后请求缩略图 | 1 | 0 | 0 |
表里统计的是插件调用 query 的次数。连续读两页时,Cursor 只查了一次;返回已读页时,两种方案都没有再查。分页后请求缩略图,也用上了之前缓存的元数据。
选图结束后,记得释放
我把缓存的使用范围放在一次选图流程内:打开选择器前开启,退出后释放。下面这些接口是本地新增的,默认不开缓存:
dart
await PhotoManager.configurePageCache(
enabled: true,
strategy: 'cursor', // 改为 'pages' 使用实体分页缓存
maxEntries: 32,
maxCursors: 4,
);
try {
await openPicker(); // 这里代表业务中的选图流程
} finally {
await PhotoManager.releaseCache();
}
maxEntries 限制各结果缓存的条目数,maxCursors 单独限制 Cursor 数量。关闭缓存或切换策略时清理旧结果,关闭后回到原查询路径。Cursor 策略当前用于 Android Q+ 非 legacy 存储路径,其余 Android 路径回退实体分页。
写在最后
最开始以为给分页加个缓存就差不多了,查下去才发现,数量、封面、缩略图各有各的查询。分页命中了,别的方法还是会重新访问媒体库。
这次先去掉了上层重复的数量查询,再让分页和缩略图共用元数据。两种分页方案也各有用处:连续翻页可以保留 Cursor,频繁切相册、返回已读页面则可以直接缓存实体列表。