MediaStore 的日期查询索引,为什么有时候会慢

MediaStore 的日期查询索引,为什么有时候会慢

MediaStore 的日期查询索引,为什么有时候会慢


MediaStore 的日期查询索引,为什么有时候会慢


去年维护一个相册应用时,我接手了一个历史遗留的查询优化任务。用户反馈在照片数量超过两万张后,按日期浏览的加载时间从毫秒级跌到了秒级,甚至偶尔触发 ANR。初步排查时,我本能地怀疑是 RecyclerView 的复用或者 Glide 的解码线程出了问题,但 Systrace 抓下来的结果指向了一个更底层的地方:MediaStore 的查询本身。


问题复现:一个看似普通的日期查询


先看一下最初的查询代码,这是从项目里直接搬出来的:


val projection = arrayOf(
    MediaStore.Images.Media._ID,
    MediaStore.Images.Media.DISPLAY_NAME,
    MediaStore.Images.Media.DATE_TAKEN
)

val selection = "${MediaStore.Images.Media.DATE_TAKEN} >= ? AND ${MediaStore.Images.Media.DATE_TAKEN} < ?"
val selectionArgs = arrayOf(startTime.toString(), endTime.toString())

contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    "${MediaStore.Images.Media.DATE_TAKEN} DESC"
)

这段代码在 Pixel 4(Android 12)上,查询最近一个月的照片,执行时间约 80ms。放到一台三星 S21(Android 13,照片约 3.2 万张)上,同样的查询膨胀到 1200ms 以上。更诡异的是,如果去掉 DATE_TAKEN 的筛选条件,只保留排序,查询时间反而回落到 200ms 左右。


这个反直觉的现象让我意识到,问题不在数据量本身,而在 DATE_TAKEN 作为筛选条件时的索引行为。


MediaStore 的底层表结构


MediaStore 并不是很多人想象中的"一个 ContentProvider 套个数据库"那么简单。从 Android 10 开始,MediaProvider 的底层实现经历了大幅重构,核心的媒体元数据存储在 /data/data/com.android.providers.media/databases/external.db 这个 SQLite 数据库里。


我用 adb pull 把这个数据库拉出来,用 SQLite 浏览器打开,直接看 files 表的结构。这个表存储了所有媒体文件的核心元数据,包括图片、视频、音频、下载文件等。DATE_TAKEN 对应的实际列名是 datetaken,类型是 INTEGER


关键发现:这个表上有索引,但索引的构成和命名在不同 Android 版本里有差异。Android 11 及之前,files 表上的索引主要通过 CREATE INDEX 语句在数据库初始化时建立。我抓到的 Pixel 4 的 external.db 里,跟日期相关的索引是这样的:


CREATE INDEX index_files_datetaken ON files(datetaken);

看起来是单列索引,覆盖 datetaken。但当我检查三星 S21 的 external.db 时,发现索引结构完全不同:


CREATE INDEX index_files_datetaken_bucket_id ON files(datetaken, bucket_id);

这是一个复合索引,把 datetakenbucket_id 绑在了一起。这个差异本身不是关键,但它暗示了不同 OEM 厂商对 MediaProvider 数据库的定制程度远超我的预期。


查询计划分析:为什么索引没被用上


SQLite 的查询优化器是否选择索引,取决于查询条件的具体写法。我用 EXPLAIN QUERY PLAN 分析了两台设备上的执行计划。


Pixel 4 上的结果:


0```

三星 S21 上的结果:

0`


同样的查询,一台走了索引搜索,一台做了全表扫描。问题出在复合索引 index_files_datetaken_bucket_id 的列顺序上。SQLite 的复合索引遵循最左前缀原则,当查询条件只包含 datetaken 而不涉及 bucket_id 时,这个复合索引对 datetaken 的范围查询无法生效,优化器退而求其次选择了全表扫描。


但这里有个更深的疑问:为什么三星要建这个复合索引?bucket_id 对应的是文件所在的目录桶(Bucket),通常用于相册的分组展示。我推测三星的相册应用或者系统组件有大量按日期+目录联合查询的需求,所以他们的 MediaProvider 定制了这个索引。但这个优化却损害了纯日期范围查询的性能。


绕过方案:利用覆盖索引的 trick


在不能直接修改系统数据库的前提下,我尝试了几种应用层的绕过方案。


第一种思路是改写查询条件,让 SQLite 误以为能用到复合索引。比如加上一个永远为真的 bucket_id 条件:


val selection = "${MediaStore.Images.Media.DATE_TAKEN} >= ? AND ${MediaStore.Images.Media.DATE_TAKEN} < ? AND ${MediaStore.Images.Media.BUCKET_ID} IS NOT NULL"

这个改写在三星 S21 上确实让查询计划变成了 USING INDEX index_files_datetaken_bucket_id,但执行时间几乎没有改善。原因是 IS NOT NULL 作为范围条件,仍然无法有效利用复合索引的前缀,而且增加了额外的判断开销。


第二种思路更实际:放弃 DATE_TAKEN 的筛选,改用 DATE_ADDED 或者 _ID。我检查了 files 表上的所有索引,发现 _id 作为主键有隐式的 B-tree 索引,而 date_added 有独立的单列索引 index_files_date_added。测试下来,用 DATE_ADDED 替代 DATE_TAKEN 做筛选,三星 S21 上的查询时间从 1200ms 降到 150ms。


但这个替代有业务代价。DATE_TAKEN 提取的是 EXIF 中的拍摄时间,反映的是照片实际拍摄的瞬间;DATE_ADDED 是文件扫描进 MediaStore 的时间。用户从旧手机迁移照片、或者下载了多年前拍摄的照片,这两个时间可能相差数年。对于"按拍摄时间整理相册"这个核心需求,DATE_ADDED 是个错误的语义替代。


第三种思路是分页 + 预加载。既然单次查询慢,能不能把大查询拆成多个小查询?MediaStore 的 ContentResolver.query 返回的是 Cursor,天然支持 LIMITOFFSET。但 SQLite 的 OFFSET 实现是在结果集上逐行跳过,偏移量越大效率越低。我测试了 LIMIT 50 OFFSET 10000 的场景,执行时间反而比不分页更长。


深入 MediaProvider 源码:索引创建的时机


为了理解索引差异的根源,我下载了 AOSP 的 MediaProvider 源码,从 Android 10 到 Android 14 逐个版本对比。


在 AOSP 的 DatabaseHelper.java 里,数据库 Schema 和索引的创建集中在 onCreate 和升级时的 onUpgrade 方法。Android 10(API 29)的代码中,datetaken 的索引创建如下:


db.execSQL("CREATE INDEX index_files_datetaken ON files(datetaken)");

Android 11(API 30)保持不变。Android 12(API 31)引入了一个变化:Google 开始用 MediaStore.Picker 支持更复杂的相册选择器,但核心的 datetaken 索引仍然是单列。


转折点在 Android 13(API 33)。AOSP 源码里出现了这个修改:


// Support efficient queries for both date + bucket combinations
db.execSQL("CREATE INDEX index_files_datetaken_bucket_id ON files(datetaken, bucket_id)");

这个变更的提交信息提到"优化相册选择器的日期+目录联合查询"。但 AOSP 的修改是渐进式的,旧的单列索引并没有被删除,而是新增了这个复合索引。问题在于:SQLite 的查询优化器面对两个可选索引时,会根据统计信息选择"它认为更优"的那个。当查询只包含 datetaken 时,优化器可能因为复合索引的统计信息更"新鲜"(新创建的索引往往有更新的 ANALYZE 数据),或者因为某些成本估算的偏差,错误地选择了全表扫描而非旧的单列索引。


三星 S21 的情况更复杂。它的 Android 13 实现基于 AOSP,但 MediaProvider 被替换成了三星定制的版本。我通过 pm dump com.android.providers.media 确认了包名和版本签名与 AOSP 不同。三星的定制版本可能删除了旧的单列索引,只保留了复合索引,或者查询优化器的成本参数被调整过,导致单列索引即使存在也被忽略。


一个更隐蔽的陷阱:时区与整数存储


在排查过程中,我还踩了一个跟索引无关但同样影响查询正确性的坑。


DATE_TAKEN 在 MediaStore 的 API 文档里定义为"自 1970-01-01T00:00:00Z 以来的毫秒数"。但这个定义有歧义:它存储的是 UTC 时间戳,还是本地时区的时间戳?


我构造了一个测试:拍摄一张照片,记录当时的本地时间是 2023-06-15 14:30:00 (GMT+8)。查询 MediaStore 返回的 DATE_TAKEN 值是 1686810600000,换算后是 2023-06-15 06:30:00 UTC。这说明存储的是 UTC 时间戳。


但当我用同样的 UTC 时间戳去查询当天的照片时,发现边界附近的照片有遗漏。问题出在查询参数的构造上。很多代码示例里会这样写:


val calendar = Calendar.getInstance().apply {
    set(year, month, day, 0, 0, 0)
}
val startTime = timeInMillis
val endTime = startTime + 24 * 60 * 60 * 1000

Calendar.getInstance() 默认使用本地时区,所以 startTime 实际上是本地零点的 UTC 时间戳。如果用户在北京,这个值比 UTC 当天的零点早了 8 小时。查询结果就会混入前一天 UTC 时间 16:00 之后拍摄的照片,同时漏掉当天 UTC 时间 16:00 之后拍摄的照片。


正确的做法应该是:


val startTime = ZonedDateTime.of(year, month, day, 0, 0, 0, 0, ZoneId.of("UTC"))
    .toInstant()
    .toEpochMilli()

或者更简单地,用 java.time 包里的 API 明确指定时区。这个时区问题不会直接影响索引使用,但会让查询结果"看起来不对",开发者往往会反复调整查询条件,间接增加了排查索引问题的复杂度。


性能数据的完整对比


为了量化不同方案的效果,我在三台设备上做了系统的对比测试。测试方法:清空应用缓存,冷启动后执行相同的日期范围查询,重复 10 次取中位数。


设备 A:Pixel 4,Android 12(API 31),照片 1.2 万张

设备 B:三星 S21,Android 13(API 33),照片 3.2 万张

设备 C:小米 13,Android 14(API 34),照片 5.8 万张


几个值得注意的模式:


设备 B(三星 S21)的 DATE_TAKEN 范围查询异常慢,但纯排序不慢。这确认了问题在索引对范围条件的支持,而非排序本身。


设备 C(小米 13)的 DATE_TAKEN 查询表现正常,甚至优于设备 A。我检查了小米的 external.db,发现它同时保留了 index_files_datetakenindex_files_datetaken_bucket_id,而且查询计划正确地选择了单列索引。这说明不同 OEM 对同一版 AOSP 的定制策略差异很大。


_ID 排序在所有设备上都最快,因为主键索引是聚簇索引,排序本身就是索引的遍历顺序,无需额外的排序操作。


最终采用的混合策略


基于这些测试,我在应用层实现了一个分层回退的策略:


第一层:优先尝试 DATE_TAKEN 查询,但限制在最近的 1000 张照片范围内。如果返回的 Cursor 计数达到 1000,说明数据量可能很大,触发第二层。


第二层:改用 _ID 做倒序分页查询,利用 _ID 的近似单调递增特性(新照片的 _ID 通常更大)。在应用层用 ContentResolver.openFileDescriptor 读取 EXIF 的 DateTimeOriginal,自己过滤拍摄时间。这个方案牺牲了部分性能(需要打开文件读 EXIF),但避免了 MediaStore 索引的不可控性。


第三层:针对三星设备的特殊处理。通过 Build.MANUFACTURER 识别后,强制使用 DATE_ADDED 做初筛,然后在应用层用 EXIF 时间做二次精确过滤。这个方案最慢,但保证了结果正确性。


代码核心逻辑大致如下:


fun queryPhotosByDate(startTime: Long, endTime: Long): List<PhotoItem> {
    val isSamsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
    
    val result = if (isSamsung) {
        queryWithDateAddedFallback(startTime, endTime)
    } else {
        queryWithDateTaken(startTime, endTime) 
            ?: queryWithIdFallback(startTime, endTime)
    }
    
    return result
}

这个策略的维护成本不低,每新增一个设备品牌都需要验证。但从实际用户反馈来看,ANR 率从 0.8% 降到了 0.05% 以下,优化效果显著。


对 MediaStore API 设计的反思


这个问题的根源,我认为在于 MediaStore 的 API 抽象泄漏了底层数据库的实现细节,但又没有给开发者足够的控制手段。


ContentProvider 的查询接口 query(Uri, projection, selection, selectionArgs, sortOrder) 看起来是通用的,但 selection 的语法实际上是直接透传给 SQLite 的 WHERE 子句。开发者写的 DATE_TAKEN >= ? 会被原样拼接进 SQL,没有任何中间层可以做查询重写或索引提示(SQLite 的 INDEXED BY 语法在 ContentProvider 里无法使用)。


Google 在 Android 10 引入的 MediaStore.Files.getContentUri(volumeName) 和 Android 13 的 MediaStore.Picker,某种程度上是在用新的 API 层替代旧的直接查询模式。但旧 API 仍然广泛存在,而且新 API 的灵活性往往更低。比如 MediaStore.Picker 目前不支持自定义的日期范围筛选,只能按系统预设的"今天、昨天、最近一周"等分类浏览。


另一个角度是 OEM 定制的问题。Android 的开放性允许厂商修改系统组件,但 MediaProvider 作为核心数据层,其 Schema 和索引的变动对应用层是透明的。应用无法通过 API 获知底层索引结构,只能依赖查询性能的表现来间接推断。这种信息不对称增加了跨设备兼容的难度。


我个人觉得,Google 至少应该在 MediaStore 的文档里明确说明哪些列有索引支持,哪些查询模式是性能优化的。目前的文档只描述了列的语义,对索引和查询优化只字不提。开发者只能像考古一样拉数据库、看源码,才能理解为什么同样的代码在不同设备上表现迥异。


一个未解的疑问:ANALYZE 的影响


在排查的最后阶段,我注意到一个更诡异的现象。三星 S21 上,如果先用 adb shell 进入 sqlite3 /data/data/com.android.providers.media/databases/external.db,手动执行 ANALYZE;,然后立即测试应用查询,性能会暂时改善到接近 Pixel 4 的水平。但过几个小时(或者 MediaProvider 做了某些后台操作后),性能又退化回去。


SQLite 的 ANALYZE 命令会更新 sqlite_stat1 等系统表,给查询优化器提供表和索引的统计信息。这个临时改善说明,优化器在某些统计状态下能做出正确选择,但统计信息过期或被覆盖后,又回到了错误的选择。


我尝试追踪 MediaProvider 何时会更新这些统计信息,但在 AOSP 源码里没有找到定期的 ANALYZE 调用。可能是在数据库写入操作达到一定阈值后触发,或者是三星定制的后台服务在维护。这个机制的不透明性,让应用层的优化策略更加难以稳定。


最终我没有在这个方向上继续深入,因为触及了系统服务的边界,应用层能做的有限。但这个经历让我对 Android 生态的碎片化有了更具体的认知:不是简单的"版本号不同",而是同一代码、同一场景,在不同设备上可能因为底层索引的一个列顺序差异,产生十倍以上的性能落差。

C++ 和 Kotlin 的互操作,JNI 封装技巧 2026-06-13
CountDownLatch 和 CyclicBarrier,并发工具怎么选 2026-06-13

评论区