在做圖片/視頻素材選擇頁時,很多項目會直接接入系統 Picker 或第三方相冊庫。但在一些業務場景裡,我們需要更強的可控性:列表裡要展示自己的選中狀態,要支持多選上限,要把拍照入口放在網格第一位,還要兼容 Android 13/14 的媒體權限變化。這篇文章結合一個實際的 SystemPictureFragment,總結一次“本地相冊選擇 + 調用相機拍照”的實現思路。
頁面職責
這個頁面承擔三件事:
- 從系統相冊讀取圖片或視頻,並以 4 列網格展示。
- 在圖片模式下,把“拍照”入口插入到列表第一個位置。
- 用戶選中素材後,通過共享的
ImageVideoViewModel維護選擇態,最終由外層 Activity 回傳。
整體流程可以概括為:
進入頁面 -> 檢查媒體權限 -> 查詢 MediaStore -> 展示網格 -> 點擊圖片切換選中 / 點擊相機拍照 -> 更新列表和選擇結果
一、用 Activity Result API 管理權限和拍照
頁面沒有再使用舊的 startActivityForResult,而是統一通過 Activity Result API 註冊三個 Launcher:
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 媒體權限適配
相冊權限的變化是這類頁面最容易踩坑的地方。當前實現按系統版本分三層處理:
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 判斷用戶是否只授權了部分資源:
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。圖片查詢使用:
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:
val contentUri = ContentUris.withAppendedId(type, id)
然後組裝成業務層統一使用的 AssetsBean.ImageListBean。這樣做的收益很明顯:本地相冊、服務端資產、拍照新增圖片都可以收斂成同一套 AssetsBean,Adapter 和選擇邏輯不需要關心來源。
四、分頁:用 DATE_ADDED + ID 做穩定游標
系統相冊可能有幾千張圖,一次性查完既慢又浪費內存。當前頁面每頁加載 100 條,並按 DATE_ADDED DESC, _ID DESC 排序。
加載更多時,不是簡單靠 offset,而是保存上一頁最後一條數據的 DATE_ADDED 和 _ID,再構建游標條件:
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
拍照入口沒有單獨寫在頁面外層,而是定義成一個特殊數據類型:
data class CameraBean(val type: String = "camera") : AssetsBean()
當頁面處於圖片模式時,觀察相冊數據變化,並把 CameraBean 插入第一位:
val newList = mutableListOf<AssetsBean>()
if (mImageVideoAdapter.showCameraEntry) {
newList.add(AssetsBean.CameraBean())
}
newList.addAll(dataList)
mImageVideoAdapter.setList(newList)
Adapter 遇到 CameraBean 時展示相機 UI,點擊後回調 Fragment:
if (showCameraEntry && item is AssetsBean.CameraBean) {
holder.setGone(R.id.group_camera, false)
holder.itemView.setOnClickListener { onCameraClick?.invoke() }
return
}
這個設計很輕巧:列表佈局、滾動、緩存、點擊區域都複用 RecyclerView,本地圖片和相機入口也不會分散在兩個不同層級裡。
六、FileProvider + TakePicture:先造 Uri,再交給相機寫入
拍照流程分兩步。
第一步,在應用外部私有圖片目錄創建臨時文件:
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:
currentPhotoUri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.android7.fileprovider",
photoFile
)
cameraLauncher.launch(currentPhotoUri)
配套的 FileProvider 需要在 Manifest 中聲明,並在 file_paths.xml 中允許 Pictures 目錄:
<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 回傳:
putParcelableArrayListExtra(
"selected_items",
ArrayList<AssetsBean>(mViewModel.selectedItems.value ?: emptyList())
)
這讓 Fragment 只負責“展示和選擇”,Activity 負責“選擇結果如何結束頁面”,職責比較清晰。
八、幾個實踐建議
- Android 14 的部分媒體權限不要當成失敗處理,能展示多少先展示多少,再引導用戶補充授權。
- 相冊分頁儘量用游標條件,不建議直接使用 offset。
- 拍照前一定先創建目標 Uri,
TakePicture只負責把照片寫進去,不會替我們生成 Uri。 FileProvider的 authority 要和 Manifest 中一致,路徑也要覆蓋實際創建文件的目錄。- 相機入口做成列表數據項,比額外疊一個按鈕更容易維護滾動、緩存和點擊狀態。
總結
這套實現的關鍵不在 API 有多複雜,而在幾個邊界處理:權限按版本拆分、相冊查詢可分頁、拍照 Uri 由應用自己管理、UI 入口和真實素材用同一套數據模型承載。這樣做之後,本地相冊圖片、視頻資源、拍照新增圖片都可以被統一展示和選擇,後續上傳、預覽、排序、多選上限也都能自然接上。










