Android 相册选择与拍照接入实践:MediaStore 分页、权限适配与 FileProvider

在做图片/视频素材选择页时,很多项目会直接接入系统 Picker 或第三方相册库。但在一些业务场景里,我们需要更强的可控性:列表里要展示自己的选中状态,要支持多选上限,要把拍照入口放在网格第一位,还要兼容 Android 13/14 的媒体权限变化。这篇文章结合一个实际的 SystemPictureFragment,总结一次"本地相册选择 + 调用相机拍照"的实现思路。

页面职责

这个页面承担三件事:

  1. 从系统相册读取图片或视频,并以 4 列网格展示。
  2. 在图片模式下,把"拍照"入口插入到列表第一个位置。
  3. 用户选中素材后,通过共享的 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_IMAGESREAD_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 以上通过 BundleQUERY_ARG_SQL_SORT_ORDERQUERY_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 负责"选择结果如何结束页面",职责比较清晰。

八、几个实践建议

  1. Android 14 的部分媒体权限不要当成失败处理,能展示多少先展示多少,再引导用户补充授权。
  2. 相册分页尽量用游标条件,不建议直接使用 offset。
  3. 拍照前一定先创建目标 Uri,TakePicture 只负责把照片写进去,不会替我们生成 Uri。
  4. FileProvider 的 authority 要和 Manifest 中一致,路径也要覆盖实际创建文件的目录。
  5. 相机入口做成列表数据项,比额外叠一个按钮更容易维护滚动、缓存和点击状态。

总结

这套实现的关键不在 API 有多复杂,而在几个边界处理:权限按版本拆分、相册查询可分页、拍照 Uri 由应用自己管理、UI 入口和真实素材用同一套数据模型承载。这样做之后,本地相册图片、视频资源、拍照新增图片都可以被统一展示和选择,后续上传、预览、排序、多选上限也都能自然接上。

相关推荐
Flynt5 小时前
升级Flutter 3.44,我踩了HCPP和AGP 9的坑
android·flutter·dart
白色牙膏5 小时前
Cocos Creator 2.4.x 接入 AdMob 插件的迁移实践
android
我命由我123457 小时前
C++ - 面向对象 - 常成员函数
android·java·linux·c语言·开发语言·c++·算法
tryqaaa_8 小时前
学习日志(四)【php反序列化魔术方法以及pop构造配实战】
android
Java小学生丶9 小时前
记录一下我的 Gradle 开发环境配置过程
android·java·gradle·maven·安卓
问心无愧051310 小时前
ctf show web 入门256
android·前端·笔记
霸道流氓气质10 小时前
MySQL 索引设计实战指南
android·数据库·mysql
R语言爱好者10 小时前
叠氮酸介绍
android
方白羽10 小时前
Android WebView 中实现第三方 QQ 登录的架构与流程详解
android·app