慣性聚合 高效追蹤和閱讀你感興趣的部落格、新聞、科技資訊
閱讀原文 在慣性聚合中打開

推薦訂閱源

WordPress大学
WordPress大学
M
MIT News - Artificial intelligence
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
T
Tailwind CSS Blog
T
The Blog of Author Tim Ferriss
Engineering at Meta
Engineering at Meta
Jina AI
Jina AI
Last Week in AI
Last Week in AI
I
InfoQ
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
人人都是产品经理
人人都是产品经理
MongoDB | Blog
MongoDB | Blog
The Cloudflare Blog
月光博客
月光博客
爱范儿
爱范儿
D
Docker
罗磊的独立博客
博客园 - 叶小钗
博客园 - 司徒正美

掘金

Win 安装Claude Code FastAPI 的 CORSMiddleware 跨域中间件 Java 自研 ReAct Agent 半年后,我用 LangGraph 验证了这些设计取舍 🚀AI编程工作流终极形态:GitNexus!零Token消耗实现代码知识图谱化!让Claude Code和Codex拥有上帝视角彻底告别盲目改代码,复杂项目重 LeetCode 72. 编辑距离:动态规划经典题解 被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了 (AI) 编写简单 AI 助手 (ds-agent) 别再让 pnpm 跟着 nvm 跑了!独立安装终极指南 Claude Code 为什么这么顺?Anthropic 最新复盘:真正撑住它的不是模型,而是缓存 从 /simplify 指令深挖 Claude Code 多 Agent 协同机制 Function-Calling与工具使用 新手上路(六):Claude code装上ECC全家桶:38 个子代理、156 个技能、生产级 Hooks 与 Rules 体系 我在 Claude、Kimi、opencode 三个 AI 之间搭了一条自动协作管道 【技能篇】OpenClaw Skill 详解:给 AI 装上"专业外挂" wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑 两周浅学 RAG 我把 Python re 模块比喻成摸金手套 新手上路(三):Claude Code Skills 装了一堆没用?20+ 个 Skill 横向对比 + 三套组合方案,按需抄 K2.6、DeepSeek V4、GPT-5.5 都来了,组合拳打起来 Claude Code 进阶之路:从记忆系统到子代理编排 [java] 编译之后的记录类(Record Classes)长什么样子(上) 国产大模型能力大比拼,社区有话说 我研读了 500 个 Spring Boot 生产级代码库,90% 都犯了这 7 个致命错误 JAVA重点难点 转发-中央网信办部署开展“清朗·整治AI应用乱象”专项行动 合同同步逻辑 【合并已排序数组的三种实现策略,哪一种更可取?】 30天减20斤挑战:少一斤发100红包(2) 我竟然被JavaScript的隐式类型转换坑了三天! 二十五.Electron 初体验与进阶 本地到生产,解决 AI 全栈最后一公里——构建&部署&运维 程序员创业半年:顺的事、不顺的事,和我一直没想清楚的事 UI组件库elementplus 像使用 Redis 一样操作 LocalStorage 向量检索的流程是怎样的?Embedding 和 Rerank 各自的作用? LangChain DeepAgents 速通指南(七)—— DeepAgents使用Agent Skill 为什么越来越多的大厂抛弃MCP,转向CLI? 【节点】[SquareRoot节点]原理解析与实际应用 juejin.cn juejin.cn 从 “存得下” 到 “算得快”:工业物联网需要新一代时序数据平台越来越多工业用户开始意识到一个问题:**数据是存下来了, - 掘金 放弃 Claude 订阅?我用 8 年前的服务器,强跑 Google 最强开源模型 Gemma 4 真实测评! Python开发者狂喜!200+课时FastAPI全栈实战合集,10大模块持续更新中🔥 从 Claw-Code 看 AI 驱动的大型项目开发:2 人 + 10 个自治 Agent 如何产出 48K 行 Rust 代码 秒级创建实例,火山引擎 Milvus Serverless 让 AI Agent 开发更快更省火山引擎MilvusSer MediaPlayer 播放器架构:NuPlayer 的 Source/Decoder/Renderer 三驾马车 juejin.cn juejin.cn juejin.cn juejin.cn
Android 相冊選擇與拍照接入實踐:MediaStore 分頁、權限適配與 FileProvider
rocpp · 2026-05-27 · via 掘金

在做圖片/視頻素材選擇頁時,很多項目會直接接入系統 Picker 或第三方相冊庫。但在一些業務場景裡,我們需要更強的可控性:列表裡要展示自己的選中狀態,要支持多選上限,要把拍照入口放在網格第一位,還要兼容 Android 13/14 的媒體權限變化。這篇文章結合一個實際的 SystemPictureFragment,總結一次“本地相冊選擇 + 調用相機拍照”的實現思路。

頁面職責

這個頁面承擔三件事:

  1. 從系統相冊讀取圖片或視頻,並以 4 列網格展示。
  2. 在圖片模式下,把“拍照”入口插入到列表第一個位置。
  3. 用戶選中素材後,通過共享的 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_IMAGESREAD_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 以上通過 BundleQUERY_ARG_SQL_SORT_ORDERQUERY_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 負責“選擇結果如何結束頁面”,職責比較清晰。

八、幾個實踐建議

  1. Android 14 的部分媒體權限不要當成失敗處理,能展示多少先展示多少,再引導用戶補充授權。
  2. 相冊分頁儘量用游標條件,不建議直接使用 offset。
  3. 拍照前一定先創建目標 Uri,TakePicture 只負責把照片寫進去,不會替我們生成 Uri。
  4. FileProvider 的 authority 要和 Manifest 中一致,路徑也要覆蓋實際創建文件的目錄。
  5. 相機入口做成列表數據項,比額外疊一個按鈕更容易維護滾動、緩存和點擊狀態。

總結

這套實現的關鍵不在 API 有多複雜,而在幾個邊界處理:權限按版本拆分、相冊查詢可分頁、拍照 Uri 由應用自己管理、UI 入口和真實素材用同一套數據模型承載。這樣做之後,本地相冊圖片、視頻資源、拍照新增圖片都可以被統一展示和選擇,後續上傳、預覽、排序、多選上限也都能自然接上。