跨端开发的相册选择:弃用 uni-app 原生选图 API,直调 Android MediaStore 实现自定义相册架构

项目需要一个完全自定义的相册选择器(多选、时间分组、5000+ 张照片流畅滚动),uni.chooseImage 只能调起系统选图弹窗,UI 不可定制、无法分页、大相册性能不可控。
方案:在 uni-app 的 Android Plus 环境下,用 plus.android.importClass 直调原生 MediaStore.Images + ContentResolver 查询系统媒体库,拿到图片 ID、文件路径、添加时间。
核心思路:用「时间戳游标 + 遍历提前终止」实现逻辑分页 ------非首页查询时用 DATE_ADDED < 上次时间戳 缩小查询范围,遍历时读到 pageSize 条就 break,每一页只把一页数据构建进内存。

一、前提:为什么弃用 uni-app 原生选图 API

uni.chooseImage 本质是调起系统相册让用户单选/多选,存在几个绕不开的限制:

  • UI 不可定制:无法实现自定义的九宫格预览、时间分组、角标多选等交互;

  • 无法分页:5000+ 张照片时一次性全部渲染,列表卡顿、内存吃紧;

  • 拿不到完整的媒体元数据:无法按添加时间排序过滤,也无法拿到稳定 ID 做增量加载。

自定义相册需要直接读取系统媒体库,Android 可以通过 plus.android 桥接直调 MediaStore,这也是本文要分享的内容(iOS本文不展开)。

二、解决方案总览

Android 系统用 MediaStore(媒体库内容提供者)统一管理相册数据,任何拿到 READ_EXTERNAL_STORAGE(Android 13+ 为 READ_MEDIA_IMAGES)权限的应用都可以通过 ContentResolver.query() 查询它。

uni-app 的 Plus 运行时提供了 plus.android.importClass() 桥接能力,可以在 JS 里直接反射调用原生类。于是整体链路是: plus.android.importClass(MediaStore) → 拿到原生媒体库类

runtimeMainActivity().getContentResolver() → 拿到内容解析器

resolver.query(URI, 列, WHERE, 参数, 排序) → 等价于一条 SQL 查询

cursor.moveToNext() 遍历 → 逐条读取结果

关于 importClass 的作用:plus 桥接需要先在 JS 侧给原生对象注入方法映射,importClass(resolver)、importClass(cursor) 之后,才能调用 query()、moveToNext()、getString() 这些原生方法。这是本方案最容易忽略、也最容易踩坑的一步。

三、第一版:先跑通全量读取

有解决方案之后,第一步是写用例验证「能读到数据」。第一版不做任何分页,把查询结果全部读出来:

ts 复制代码
// 1. 导入 MediaStore 类
const MediaStore = plus.android.importClass('android.provider.MediaStore')

// 2. 定义需要查询的字段列
const photoColumns = [
  MediaStore.Images.Media._ID,          // 图片唯一 ID
  MediaStore.Images.Media.DATA,         // 图片文件路径
  MediaStore.Images.Media.DATE_ADDED    // 图片添加时间戳(单位:秒)
]

// 3. 获取 ContentResolver(Android 内容解析器,用于查询媒体库)
const main = plus.android.runtimeMainActivity()
const resolver = main.getContentResolver()
plus.android.importClass(resolver)

// 4. 执行查询(第一版:无任何过滤条件,全量查询)
const cursor = resolver.query(
  MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
  photoColumns,
  null,  // WHERE 条件:暂无
  null,  // WHERE 参数:暂无
  MediaStore.Images.Media.DATE_ADDED + " DESC"  // 降序:最新的在前(ASC 为升序:最旧的在前)
)

// 5. importClass(cursor) 后才能在 JS 侧调用游标方法
plus.android.importClass(cursor)

// 6. 遍历结果
const allImages: any[] = []
while (cursor.moveToNext()) {
  const _id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
  const filePath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
  const dateAdded = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED))

  allImages.push({ id: _id, filePath, dateAdded })
}

// 7. 关闭游标,释放资源
cursor.close()

// 8.获取到整体的数据
console.log(imageList)

第一版能跑通,验证了桥接链路没有问题。但它有两个明显短板:

  1. 遍历耗时随图片数量线性增长 :每次打开相册都要 moveToNext() 走完全部记录;
  2. 全量对象构建导致内存压力 :allImages 会把每一张图片的对象都构建出来,5000 张照片时内存占用吃紧。

四、改进版:时间戳游标 + 提前终止

针对上述两个问题,改进思路分两层:

  • 缩小查询范围 :翻页时带上上一页最后一条记录的 DATE_ADDED,用 WHERE DATE_ADDED < ? 过滤掉已经读过的部分;
  • 遍历提前终止 :ContentResolver.query() 不支持 SQL 的 LIMIT/OFFSET 参数 ,所以用计数器实现逻辑分页------读到 pageSize 条就 break,不再构建后续对象。

口径说明:MediaStore 没有数据库层的 LIMIT,本方案本质是「WHERE 缩小范围 + 遍历提前终止」的逻辑分页 ,而非真正的数据库分页。cursor 本身是窗口式读取,真正的开销在逐条 getString 与对象构建,所以提前终止依然有效。

改进版代码

ts 复制代码
const getimageFile = (params: any) => {
  const pageNumber = params.pageNumber || 1
  const pageSize = params.pageSize || 20
  const sortOrder = params.sortOrder || 'date_added_DESC'

  // 智能分页:优先使用调用方传入的 lastDateAdded,否则使用缓存值
  let lastDateAdded = params.lastDateAdded
  if (!lastDateAdded && pageNumber > 1) {
    lastDateAdded = imageLastDateAdded // 上一页最后一条记录的时间戳
  }
  if (pageNumber === 1) {
    imageLastDateAdded = null // 第一页时清空缓存
  }

  // 1. 导入 MediaStore 并定义查询列
  const MediaStore = plus.android.importClass('android.provider.MediaStore')
  const photoColumns = [
    MediaStore.Images.Media._ID,        // 图片唯一 ID
    MediaStore.Images.Media.DATA,       // 图片文件路径
    MediaStore.Images.Media.DATE_ADDED  // 图片添加时间戳(秒)
  ]

  // 2. 获取 ContentResolver
  const main = plus.android.runtimeMainActivity()
  const resolver = main.getContentResolver()
  plus.android.importClass(resolver)

  // 3. 构建排序规则
  const orderBy = sortOrder === 'date_added_DESC'
    ? MediaStore.Images.Media.DATE_ADDED + " DESC"  // 降序:最新的在前
    : MediaStore.Images.Media.DATE_ADDED + " ASC"   // 升序:最旧的在前

  // 4. 核心:非第一页时,用时间戳缩小查询范围
  let selection: string | null = null      // WHERE 条件
  let selectionArgs: string[] | null = null // WHERE 参数
  if (lastDateAdded) {
    if (sortOrder === 'date_added_DESC') {
      // 降序:只查比 lastDateAdded 更旧的记录
      selection = MediaStore.Images.Media.DATE_ADDED + " < ?"
      selectionArgs = [String(lastDateAdded)]
    } else {
      // 升序:只查比 lastDateAdded 更新的记录
      selection = MediaStore.Images.Media.DATE_ADDED + " > ?"
      selectionArgs = [String(lastDateAdded)]
    }
  }

  // 5. 执行查询
  const cursor = resolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    photoColumns,
    selection,
    selectionArgs,
    orderBy
  )

  plus.android.importClass(cursor)

  // 6. 遍历:读到 pageSize 条就提前终止
  const imageList: any[] = []
  let index = 0
  let currentPageLastDateAdded: any = null // 当前页最后一条时间戳

  while (cursor.moveToNext()) {
    if (index >= pageSize) break // 提前终止:只取前 pageSize 条

    const _id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
    const filePath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
    const dateAdded = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED))

    imageList.push({
      index,
      id: _id,
      dateAdded,                          // 秒级时间戳
      date: formatDate(dateAdded * 1000), // 转毫秒后格式化为 YYYY年MM月DD日
      img: filePath,                      // 图片路径(用于显示)
      file: filePath,                     // 文件路径(用于上传)
    })

    currentPageLastDateAdded = dateAdded
    index++
  }

  // 7. 关闭游标,释放资源
  cursor.close()

  // 8. 更新缓存:保存当前页最后一条时间戳,供下一页使用
  if (imageList.length > 0) {
    imageLastDateAdded = currentPageLastDateAdded
  }

  // 9.读取的数据整体返回
  return imageList
}

五、已知边界:同秒多图会漏读

时间戳游标有一个容易忽略的边界:同一秒内新增多张照片 (连拍、批量导入)时,DATE_ADDED 会完全相同。

假设当前页最后一条时间戳为 T,下一页查询条件 DATE_ADDED < T 会把所有时间戳等于 T 的其他照片全部跳过,出现漏图。

完整的修复思路是用复合游标 ,时间戳相同时改用 _ID 继续向下翻:

ini 复制代码
// 降序场景:时间相同(同秒新增多张)时,用 _ID 兜底,保证不漏
selection = DATE_ADDED + " < ? OR (" + DATE_ADDED + " = ? AND " + _ID + " < ?)"
selectionArgs = [String(lastDateAdded), String(lastDateAdded), String(lastId)]

六、注意事项

  1. 权限按 Android 版本区分:

    • Android 12 及以下:需要 READ_EXTERNAL_STORAGE;
    • Android 13+:改为细分权限,图片需 READ_MEDIA_IMAGES,视频需 READ_MEDIA_VIDEO;
    • targetSdk 29 时建议在 manifest 声明 android:requestLegacyExternalStorage 兼容旧分区存储。
  2. DATA 列在 Android 10+ 已废弃 :MediaStore.Images.Media.DATA 是 deprecated 字段,Android 10+ 上对非本应用创建的媒体文件可能返回 null 。替代方案是使用 _ID 拼接 content URI:

ini 复制代码
   const uri = "content://media/external/images/media/" + _id

如果你的 targetSdk 较低或设备兼容,DATA 仍可正常返回;新项目建议直接按 URI 方案设计。

  1. DATE_ADDED 的单位是秒 (不是毫秒):传给 new Date() 前需要 dateAdded * 1000。这是最容易抄错的点。
  2. selectionArgs 传字符串是安全的 :虽然 DATE_ADDED 是 long 列,但 SQLite 会对字符串参数做数值比较,不影响结果;传 String(lastDateAdded) 即可。
  3. importClass 不能省 :resolver 和 cursor 都必须先 importClass 才能调用原生方法,漏掉这一步在 JS 侧不会报语法错误,运行时会抛「方法不存在」。
  4. 本文方案仅适用 Android。

七、返回结构

perl 复制代码
{
  index: 0,                              // 页内序号
  id: "1234",                            // MediaStore 唯一 ID
  dateAdded: 1759334400,                 // 添加时间戳(秒)
  date: "2025年10月1日",                  // 格式化后的日期
  img: "/storage/emulated/0/DCIM/Camera/IMG_001.jpg", // 图片路径(用于显示)
  file: "/storage/emulated/0/DCIM/Camera/IMG_001.jpg", // 文件路径(用于上传)
}

参考链接

前作参考


写在最后 :文章主要供自己总结和记录用,方案的核心价值不在「能读相册」,而在用最少的原生桥接成本拿到了相册的完整数据控制权------排序、过滤、分页、元数据都回到 JS 侧自由编排。如果你也在 uni-app 里做过类似的桥接实践,欢迎交流踩坑心得。

相关推荐
数据掘金2 小时前
小程序埋点方案的埋点覆盖率怎么算?
前端
数据掘金2 小时前
多AI平台回答快照的差异校验
前端
于航2 小时前
从 Multi-Agent 到 Skill+CLI:个人助理 1.0 → 2.0 架构演进
前端
2301_786756602 小时前
2026 JCR 秋季数据重载解读:影响因子为什么会变?附期刊影响力数据批量追踪方案
java·服务器·前端
数据掘金2 小时前
小程序埋点方案的埋点灰度发布怎么做?
前端
AiMan2 小时前
读取Meta Muse列表并导出这个功能,是没办法做到的是吗?
前端
老于11112 小时前
AI 生成内容标识落地:显式角标 + EXIF 隐式标识 + 内容编号台账
前端
RaaS1002 小时前
AI网关选型指南:MAI Gateway(魔芋企业级AI网关)vs 开源方案全面对比
前端·后端
小画家~2 小时前
vue3 里面的 itemList.reduce
前端·数据结构·算法