在做图片/视频素材选择页时,很多项目会直接接入系统 Picker 或第三方相册库。但在一些业务场景里,我们需要更强的可控性:列表里要展示自己的选中状态,要支持多选上限,要把拍照入口放在网格第一位,还要兼容 Android 13/14 的媒体权限变化。这篇文章结合一个实际的 SystemPictureFragment,总结一次"本地相册选择 + 调用相机拍照"的实现思路。
页面职责
这个页面承担三件事:
- 从系统相册读取图片或视频,并以 4 列网格展示。
- 在图片模式下,把"拍照"入口插入到列表第一个位置。
- 用户选中素材后,通过共享的
ImageVideoViewModel维护选择态,最终由外层 Activity 回传。
整体流程可以概括为:
进入页面 -> 检查媒体权限 -> 查询 MediaStore -> 展示网格 -> 点击图片切换选中 / 点击相机拍照 -> 更新列表和选择结果
一、用 Activity Result API 管理权限和拍照
页面没有再使用旧的 startActivityForResult,而是统一通过 Activity Result API 注册三个 Launcher:
kotlin
private lateinit var albumPermissionLauncher: ActivityResultLauncher<Array<String>>
private lateinit var cameraPermissionLauncher: ActivityResultLauncher<String>
private lateinit var cameraLauncher: ActivityResultLauncher<Uri>
相册权限使用 RequestMultiplePermissions,因为 Android 14 场景下可能同时请求完整媒体权限和部分访问权限;相机权限使用 RequestPermission;真正拍照则使用 ActivityResultContracts.TakePicture(),由调用方提前传入一个可写入的 content:// Uri。
这样拆分的好处是:权限结果、拍照结果都回到 Fragment 内部,生命周期安全,也不需要在 Activity 里维护一堆 requestCode。
二、Android 13/14 媒体权限适配
相册权限的变化是这类页面最容易踩坑的地方。当前实现按系统版本分三层处理:
kotlin
private fun getAlbumPermissions(): Array<String> {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> arrayOf(
getAlbumPrimaryPermission(),
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> arrayOf(
getAlbumPrimaryPermission()
)
else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
其中 getAlbumPrimaryPermission() 会根据页面当前媒体类型返回 READ_MEDIA_IMAGES 或 READ_MEDIA_VIDEO。也就是说,图片页只申请图片权限,视频页只申请视频权限,不做过度申请。
Android 14 还引入了"部分照片和视频访问权限"。页面通过 READ_MEDIA_VISUAL_USER_SELECTED 判断用户是否只授权了部分资源:
kotlin
private fun hasPartialAlbumPermission(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
!hasFullAlbumPermission() &&
hasPermission(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
}
如果只有部分权限,页面仍然先加载可访问资源,同时弹出说明,引导用户按需补充授权。这一点体验上比较重要:不要因为不是"完整授权"就把用户挡在页面外。
三、自己查询 MediaStore,而不是直接跳系统相册
这个页面的核心不是"打开系统选择器",而是自己查询 MediaStore 后渲染到 RecyclerView。图片查询使用:
kotlin
val type = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.DATE_ADDED
)
拿到 _ID 后,通过 ContentUris.withAppendedId(type, id) 拼出真正可访问的 content:// Uri:
kotlin
val contentUri = ContentUris.withAppendedId(type, id)
然后组装成业务层统一使用的 AssetsBean.ImageListBean。这样做的收益很明显:本地相册、服务端资产、拍照新增图片都可以收敛成同一套 AssetsBean,Adapter 和选择逻辑不需要关心来源。
四、分页:用 DATE_ADDED + ID 做稳定游标
系统相册可能有几千张图,一次性查完既慢又浪费内存。当前页面每页加载 100 条,并按 DATE_ADDED DESC, _ID DESC 排序。
加载更多时,不是简单靠 offset,而是保存上一页最后一条数据的 DATE_ADDED 和 _ID,再构建游标条件:
kotlin
val selection =
"(${MediaStore.MediaColumns.DATE_ADDED} < ?) OR " +
"(${MediaStore.MediaColumns.DATE_ADDED} = ? AND ${MediaStore.MediaColumns._ID} < ?)"
这个细节值得保留。相册数据会变化,offset 分页容易因为新增/删除资源导致重复或漏数据;使用排序字段 + 唯一 ID 做游标,稳定性更好。
Android O 以上通过 Bundle 传 QUERY_ARG_SQL_SORT_ORDER 和 QUERY_ARG_LIMIT,低版本则回退到传统 query(uri, projection, selection, args, sortOrder)。
五、把拍照入口伪装成一个普通列表 Item
拍照入口没有单独写在页面外层,而是定义成一个特殊数据类型:
kotlin
data class CameraBean(val type: String = "camera") : AssetsBean()
当页面处于图片模式时,观察相册数据变化,并把 CameraBean 插入第一位:
kotlin
val newList = mutableListOf<AssetsBean>()
if (mImageVideoAdapter.showCameraEntry) {
newList.add(AssetsBean.CameraBean())
}
newList.addAll(dataList)
mImageVideoAdapter.setList(newList)
Adapter 遇到 CameraBean 时展示相机 UI,点击后回调 Fragment:
kotlin
if (showCameraEntry && item is AssetsBean.CameraBean) {
holder.setGone(R.id.group_camera, false)
holder.itemView.setOnClickListener { onCameraClick?.invoke() }
return
}
这个设计很轻巧:列表布局、滚动、缓存、点击区域都复用 RecyclerView,本地图片和相机入口也不会分散在两个不同层级里。
六、FileProvider + TakePicture:先造 Uri,再交给相机写入
拍照流程分两步。
第一步,在应用外部私有图片目录创建临时文件:
kotlin
private fun createImageFile(): File {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile("JPEG_${timeStamp}_", ".jpg", storageDir)
}
第二步,用 FileProvider 把文件转换成可以授权给相机应用的 content:// Uri:
kotlin
currentPhotoUri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.android7.fileprovider",
photoFile
)
cameraLauncher.launch(currentPhotoUri)
配套的 FileProvider 需要在 Manifest 中声明,并在 file_paths.xml 中允许 Pictures 目录:
xml
<external-files-path
name="pictures"
path="Pictures" />
相机拍照成功后,TakePicture 会返回 success = true。这时页面把刚才的 currentPhotoUri 包装成一条本地图片数据,并插到相册列表最前面。随后调用 mViewModel.updateValue(currentList) 刷新列表,并滚动到位置 1。这里的位置 1 是有意为之,因为位置 0 永远是相机入口。
七、选择态交给共享 ViewModel
图片点击后,Adapter 不直接持有业务结果,而是调用 ImageVideoViewModel.toggleSelection(item)。ViewModel 内部根据图片或视频的 ID 判断是否已选,已选则移除,未选且没有超过上限则加入。
外层 Activity 观察 selectedItems,单选模式下选中即返回,多选模式下展示底部已选列表,并在点击确认时通过 Intent 回传:
kotlin
putParcelableArrayListExtra(
"selected_items",
ArrayList<AssetsBean>(mViewModel.selectedItems.value ?: emptyList())
)
这让 Fragment 只负责"展示和选择",Activity 负责"选择结果如何结束页面",职责比较清晰。
八、几个实践建议
- Android 14 的部分媒体权限不要当成失败处理,能展示多少先展示多少,再引导用户补充授权。
- 相册分页尽量用游标条件,不建议直接使用 offset。
- 拍照前一定先创建目标 Uri,
TakePicture只负责把照片写进去,不会替我们生成 Uri。 FileProvider的 authority 要和 Manifest 中一致,路径也要覆盖实际创建文件的目录。- 相机入口做成列表数据项,比额外叠一个按钮更容易维护滚动、缓存和点击状态。
总结
这套实现的关键不在 API 有多复杂,而在几个边界处理:权限按版本拆分、相册查询可分页、拍照 Uri 由应用自己管理、UI 入口和真实素材用同一套数据模型承载。这样做之后,本地相册图片、视频资源、拍照新增图片都可以被统一展示和选择,后续上传、预览、排序、多选上限也都能自然接上。