深入 Android Photo Picker 全链路:隐私沙箱架构与 Compose 声明式集成

去年做一个社交 App 的图片选择功能,产品经理要求在选图页展示相册分类、支持多选预览。我第一反应是调 READ_EXTERNAL_STORAGE 权限自己写一个,但 Android 13 的隐私沙箱策略让这个方案变得很尴尬——用户看到”允许访问照片和视频”的权限弹窗时,30% 的人选择了拒绝。

Photo Picker 是 Google 从 Android 13 开始推的官方解决方案,不需要存储权限,通过系统 UI 直接让用户选择照片,App 只拿到选中的文件 URI。

隐私沙箱的设计逻辑

传统的相册访问模式是”全量授权”:App 拿到存储权限后,可以遍历整个 MediaStore 数据库,读取所有媒体文件的路径、元数据甚至内容。用户无法精确控制哪些照片被访问。

Photo Picker 把授权粒度从”相册级别”降到了”文件级别”。

核心机制就三步:

  1. App 通过 Intent 或 API 启动系统选择器 UI
  2. 用户在系统 UI 中勾选照片,系统进程记录被选中的文件
  3. 系统返回一个临时 URI 给 App,App 只能读取这些被选中的文件

App 进程自始至终不知道用户相册里还有哪些其他照片。这个隔离靠的不是权限标记,而是进程边界——选择器跑在系统进程里,App 进程拿不到任何 MediaStore 的查询结果。

跨进程传递的沙箱 URI

Photo Picker 返回的 URI 是 content:// 格式,但和普通 MediaStore URI 有本质区别。

// 普通 MediaStore URI —— 只要 App 有存储权限就能读
val uri = ContentUris.withAppendedId(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageId
)

// Photo Picker 返回的 URI —— 绑定了临时授权
val pickerUri: Uri = result.data?.data ?: return

Photo Picker 的 URI 背后是 MediaProvider 实现的 临时授权票据(temporary grant)。系统在选择器关闭时,把被选中文件的 ID 和发起请求的 App 包名绑定,生成一个有时效性的 URI 权限记录。

这个 URI 有两个关键约束:

  • 时效性:App 进程被杀后授权自动失效,重启需要重新选择
  • 不可伪造:URI 包含系统签名的时间戳和哈希,App 无法构造出能访问未授权文件的新 URI

我在调试时用 adb shell dumpsys media.provider 看过授权记录,每条记录包含 packageNameuriexpireTime 三个字段,结构非常干净。

跨进程数据传输的两种模式

Photo Picker 支持单选和多选,底层的数据传输路径不同。

单选模式走的是标准 ActivityResult 回调:

val pickMedia = rememberLauncherForActivityResult(
    PickVisualMedia()
) { uri ->
    if (uri != null) {
        // uri 是 content://media/picker/... 格式
        viewModel.processImages(listOf(uri))
    }
}

返回的 Uri 通过 Intent.data 传递,跨进程传输时会被 Binder 序列化。单个 URI 的传输开销很小,延迟在 1-2ms 级别。

多选模式走的是 PickMultipleVisualMedia

val pickMultipleMedia = rememberLauncherForActivityResult(
    PickMultipleVisualMedia(5)
) { uris ->
    // uris 是 List<Uri>,通过 Intent.clipData 传递
    viewModel.processImages(uris)
}

多选的结果通过 Intent.clipData 传递,本质上是 ClipData 对象跨越 Binder 传输。实测 50 张以下对性能无感知,但超过 100 张时 Binder 事务大小可能接近 1MB 上限,需要分批处理。Photo Picker 默认上限设成 50 张,Google 显然考虑过这个限制。

Compose 声明式集成

Compose 中集成 Photo Picker 的核心是 rememberLauncherForActivityResult,它把一个回调式 API 包装成了 Composable 状态。

持久化 URI 权限

Photo Picker 返回的 URI 在进程存活期间有效,但如果你需要持久化访问(比如草稿箱场景),要主动获取长期权限:

fun persistUriPermission(context: Context, uri: Uri) {
    try {
        context.contentResolver.takePersistableUriPermission(
            uri,
            Intent.FLAG_GRANT_READ_URI_PERMISSION
        )
    } catch (e: SecurityException) {
        Log.w("PhotoPicker", "URI 不支持持久化授权: $uri")
    }
}

takePersistableUriPermission 走到 MediaProvider 内部时,会检查 URI 是否来自 DocumentsProvider 或 Photo Picker。不是所有 URI 都支持持久化,我用 Pixel 7 实测,Photo Picker 返回的 URI 可以持久化,但部分国产 ROM 可能会裁剪这个能力。

图片预览的缩略图策略

选中图片后需要展示缩略图,直接用 BitmapFactory 解码原图会 OOM。Compose 里用 AsyncImage 或自己写 BitmapFactory.Options 采样:

fun loadThumbnail(context: Context, uri: Uri, size: Int): Bitmap? {
    return context.contentResolver.openInputStream(uri)?.use { stream ->
        val options = BitmapFactory.Options().apply {
            inJustDecodeBounds = true
        }
        BitmapFactory.decodeStream(stream, null, options)
        stream.close()
        
        val scaleFactor = (options.outWidth / size).coerceAtLeast(
            options.outHeight / size
        )
        
        context.contentResolver.openInputStream(uri)?.use { inputStream ->
            BitmapFactory.Options().apply {
                inSampleSize = scaleFactor.coerceAtLeast(1)
            }.let { BitmapFactory.decodeStream(inputStream, null, it) }
        }
    }
}

ContentResolver.openInputStream() 每次调用都会触发一次跨进程的 openFile 请求。如果列表中有 20 张图,就是 20 次 Binder 调用。在 Compose 的 LazyColumn 中要注意复用,不要每次重组都重新解码。

处理系统版本兼容

Photo Picker 从 Android 13 开始原生支持,但 Google 通过 activity 库向后兼容到了 Android 11(API 30 以下版本需要引入 Google Play Services 的模块化组件)。

// build.gradle
implementation("androidx.activity:activity-compose:1.8.0")

// 在 Android 11-12 上会自动降级为 ACTION_OPEN_DOCUMENT
val picker = rememberLauncherForActivityResult(
    PickVisualMedia()
) { uri -> /* ... */ }

降级到 ACTION_OPEN_DOCUMENT 时,用户体验会变差——只能用系统文件选择器而非专门的图片选择器。我给产品的建议是:Android 13 以下给出降级提示,但不阻止使用,Photo Picker 的隐私优势已经足够说服用户升级。

实战中踩过的三个坑

坑一:URI 在 WebView 中不可用。 Photo Picker 返回的 content:// URI 需要 ContentResolver 读取,WebView 的 loadUrl() 不支持。解决方案是先用 ContentResolver 读到内存,再转成 Base64 或 blob: URL 传给 WebView。

坑二:部分 ROM 篡改返回结果。 某国产 ROM 把 Photo Picker 的调用重定向到了自己的相册选择器,返回的 URI 是 file:// 格式。防御性代码要在拿到 URI 后检查 scheme:

fun validateUri(uri: Uri): Boolean {
    if (uri.scheme != "content") {
        Log.w("PhotoPicker", "非标准 URI scheme: ${uri.scheme}")
        return false
    }
    return true
}

坑三:onActivityResult 的时序问题。 Compose 的 LaunchedEffectrememberLauncherForActivityResult 回调可能不在同一个重组帧内触发,如果回调里直接更新 State,建议用 Snapshot.withMutableSnapshot() 或确保状态提升到 ViewModel 层处理。

选型与落地的几个判断

选择 Photo Picker 而非自建相册选择器,核心权衡不在技术复杂度,而在用户隐私感知。用户看到”允许访问照片和视频”弹窗时的心理负担,比系统内置选择器大得多。我经手的一个项目在切换到 Photo Picker 后,相册功能的完整体验率提升了 18%。

URI 的生命周期管理是 Photo Picker 集成中最容易出错的部分。建议封装一个 PhotoPickerResult 数据类,把 URI、文件名、大小、持久化标记等一起管理,避免散落在各处的 contentResolver 调用。

国产 ROM 的兼容性处理不能省。至少做两层校验:URI scheme 检查和 openInputStream 的异常捕获。如果发现 ROM 裁剪了 Photo Picker 能力,降级到 ACTION_OPEN_DOCUMENT 是稳妥的兜底方案。