项目需要一个完全自定义的相册选择器(多选、时间分组、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)
第一版能跑通,验证了桥接链路没有问题。但它有两个明显短板:
- 遍历耗时随图片数量线性增长 :每次打开相册都要
moveToNext()走完全部记录; - 全量对象构建导致内存压力 :
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)]
六、注意事项
-
权限按 Android 版本区分:
- Android 12 及以下:需要
READ_EXTERNAL_STORAGE; - Android 13+:改为细分权限,图片需
READ_MEDIA_IMAGES,视频需READ_MEDIA_VIDEO; - targetSdk 29 时建议在 manifest 声明
android:requestLegacyExternalStorage兼容旧分区存储。
- Android 12 及以下:需要
-
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 方案设计。
DATE_ADDED的单位是秒 (不是毫秒):传给new Date()前需要dateAdded * 1000。这是最容易抄错的点。selectionArgs传字符串是安全的 :虽然DATE_ADDED是 long 列,但 SQLite 会对字符串参数做数值比较,不影响结果;传String(lastDateAdded)即可。importClass不能省 :resolver和cursor都必须先importClass才能调用原生方法,漏掉这一步在 JS 侧不会报语法错误,运行时会抛「方法不存在」。- 本文方案仅适用 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 里做过类似的桥接实践,欢迎交流踩坑心得。