只是选个照片,怎么留下了这么多访问记录?Flutter 图片选择器排查与优化实录

0 阅读12分钟

选完照片,系统却记了这么多次访问

这次是在一个有 256 张照片的相册里选图。使用过程中没发现异常,照片展示、切换相册、选完返回都正常。

但打开小米的应用行为记录,看到的却是这样: 选图后的系统应用行为记录:20:33 照片和视频允许 87 次,20:34 允许 9 次

两条「照片和视频」记录,分别是 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。以下缓存接口属于本地实验实现。

这些查询是从哪里来的

我先重复打开选择器、切换相册和翻页这几种操作,沿着调用链找到真正访问媒体库的位置:

wechat_assets_picker
  DefaultAssetPickerProvider
    → AssetPathEntity / AssetEntity
      → photo_manager MethodChannel
        → PhotoManagerPlugin.kt
          → PhotoManager.kt
            → AndroidQDBUtils.kt
              → ContentResolver.query()

在原生查询入口记录参数后,可以把查询分成四类:

查询类型主要用途排查时看什么
目录查询相册名称、bucket、数量等请求类型、筛选和目录选项
数量查询当前相册或全库总数相册 ID、统计条件
分页/范围查询首屏、下一页、封面资源相册、offset、size、筛选、排序
单资源查询按 ID 获取资源元数据资源 ID、入口、存在性检查

把日志和页面操作对上后,先发现了相册数量的重复查询。

数量已经查到了,为什么还在查

相册目录通常要显示名称、数量和封面。上层逻辑可以简化为:

for (final path in paths) {
  await getAssetCountFromPath(path);
  await getThumbnailFromPath(path);
}

看起来很顺:先拿数量,再拿封面。

但数量查询结束时,代码通过 copyWith 更新了列表:

_paths[index] = _paths[index].copyWith(
  assetCount: assetCount,
);

更新后的引用关系是这样的:

局部变量 path → 旧 wrapper,assetCount = null
列表中的元素 → 新 wrapper,assetCount = 11

紧接着传给封面方法的 path 还是旧对象。封面发现它没有数量,就又调用了一次 assetCountAsync。

我打印局部对象与列表当前对象,得到过这样的日志:

[picker-count] id=-1617409521 old=null list=1 same=false

same=false 说明两边已经不是同一个对象:数量写进了新对象,封面方法拿到的却还是旧对象。

这里不用改掉原来的不可变模型,查完数量后,把更新后的 wrapper 传给封面方法就可以复用这个数量。

不过,封面方法里还有一次数量查询。它用这个接口取第一张图片:

await path.path.getAssetListRange(start: 0, end: 1);

这个版本的 getAssetListRange 在 Dart 层会先查数量,用来修正 end。封面只需要一张资源,可以改成:

final assets = await path.path.getAssetListPaged(
  page: 0,
  size: 1,
);
if (assets.isEmpty) {
  return null;
}

也就是说,给一个相册补数量和封面,原来可能查三次数量:先主动查一次,旧 wrapper 再查一次,range 接口又查一次。

之前对 16 个相册的数量和封面加载做过一次对比:

改动数量 query
原实现48
传递更新后的 wrapper32
再将封面改为 paged16

改完后,这部分数量查询从每个相册三次变成了一次。

分页命中了,缩略图还在查

修完上层冗余调用后,我给原生分页加了缓存。返回刚才看过的页面,日志里已经出现 HIT,但媒体查询仍然不少。

问题出在另一条链:

getThumb
  → dbUtils.getAssetEntity(id)
    → 查询资源元数据
  → ThumbnailUtil
    → Glide 加载缩略图

加载缩略图之前,插件会先按 ID 查询资源元数据。

即使 Glide 里已经有这张图,执行到 Glide 之前,getAssetEntity 也已经查过一次了。分页缓存只管分页方法,缩略图方法并没有用到它。

开启缓存后的一段日志里,168 次 query 中有 128 次是按 ID 查单个资源。只给分页加缓存,显然还漏了这一部分。

分页结果里已经有 ID、路径、宽高这些信息,缩略图方法再按 ID 来取时,能不能直接用这份数据?

把缓存放进 photo_manager

数量重复调用在 picker 里修。分页和缩略图都要用到的元数据,则缓存在 photo_manager 里,避免每个方法各查各的。

具体是在 IDBUtils 外包一层代理,需要缓存的方法在这里处理,其余方法继续调用原实现:

private inner class DBUtilsDelegate(
    private val utils: IDBUtils
) : IDBUtils by utils {
    // 覆盖目录、数量、分页和单资源等需要复用的入口。
}

这个代理要保存下来,不能每次访问 getter 都重新创建,否则缓存也跟着重新建了。关闭缓存后,方法直接调用原来的查询实现。

缓存结构如下: image.png

缓存实体列表,还是缓存 Cursor

方案一:缓存已经转换好的实体列表

第一次查询时,把结果转换成 List<AssetEntity>,关闭 Cursor,然后保存列表。

下一次请求相同窗口,直接返回列表副本。

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;后续分页在同一个结果集上移动位置、读取目标行:

// 读取核心片段,定位和转换过程需要在同一读取临界区内。
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 保留相册、媒体类型、筛选参数和排序,去掉页码和页大小。同一个查询结果可以服务不同页面。

A 相册第 0 页 → query → 保存 Cursor → 读第 0 页
A 相册第 1 页 → 复用 Cursor → 移动位置并读取
A 相册第 2 页 → 复用 Cursor → 移动位置并读取

它更适合连续翻页,新的页面也能复用已经建立的查询结果。

Cursor 底层会按需填充 CursorWindow,所以保留 Cursor 并不会一次性把整个相册加载进内存。后续虽然没有再次调用 query,仍可能发生窗口读取。

两种方案的区别

维度实体分页缓存Cursor 缓存
保存内容已转换好的某个窗口的实体列表某个相册及筛选条件的查询 Cursor
首次连续读新页每个未覆盖窗口通常查询一次Cursor 有效时继续复用查询结果
返回已读页直接复用实体重新定位、读取、转换实体
切相册再返回对应实体页未淘汰即可复用对应 Cursor 未淘汰即可复用
主要资源管理列表容量与对象持有Cursor 容量、读取互斥和关闭时机
更匹配的操作频繁切相册、复访页面同一相册连续翻页

切换相册后再回来,两种方案都能复用。实体列表省下了再次转换的过程,Cursor 则能继续读还没访问过的页面。

分页查过的数据,缩略图直接用

两种分页方案拿到实体后,都按 ID 存一份,供 getAssetEntity 使用:

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。

缩略图的加载过程就变成了:

分页取得元数据 → 填充资源索引 → 缩略图请求复用元数据 → 加载图片

目录里的数量,哪些能直接用

目录查询已经取得各 bucket 的数量,后续相同条件的 bucket 数量查询,可以直接使用目录结果。

当前 Android 实现中,目录查询会排除 bucket_id IS NULL 的记录,而全库数量查询没有这个限制。直接将目录数量相加替代全库数量,可能漏数据。因此,全库数量保留独立查询和缓存。

Recent 也是一样:如果已经查过目录,且筛选条件一致,就用已有结果;还没查过目录时,就按原来的方式查询,不提前做目录分组。

缓存清了,旧查询又把数据写回来怎么办

只监听媒体变化并 clear() 还不够。

考虑下面的时序:

请求 A 开始查询
图库发生变化 → 清缓存
请求 A 完成 → 把变化前的结果重新写回

结果就是刚清完,缓存又旧了。

我给缓存加了一个版本号 generation。查询前记下版本,查完后再比较一次:

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 比缓存列表麻烦的一点是:清理缓存时,可能还有线程正在读它。

两个典型竞态:

线程 A 正在读取第 0 页
线程 B moveToPosition 到第 1 页
→ A 后续读取的位置被改变
线程 A 正在读 Cursor
媒体变化,清理线程直接 close
→ A 继续访问已关闭 Cursor

第一种,把移动位置和整页读取放在同一个临界区。

第二种,用租约记录还有多少请求正在读。Cursor 被淘汰时,先从缓存移除,不再接受新读者;已有读者全部结束后,再执行 close。

下面是 LeasedResource 中负责借用和关闭的部分:

@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()
    }
}

读取侧的使用方式:

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
连续读取两页221
返回已读两页200
分页后请求缩略图100

表里统计的是插件调用 query 的次数。连续读两页时,Cursor 只查了一次;返回已读页时,两种方案都没有再查。分页后请求缩略图,也用上了之前缓存的元数据。

选图结束后,记得释放

我把缓存的使用范围放在一次选图流程内:打开选择器前开启,退出后释放。下面这些接口是本地新增的,默认不开缓存:

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,频繁切相册、返回已读页面则可以直接缓存实体列表。

参考