先把结论放在前面:图片选择的核心变化是"权限从入口移走了"——从 Photo Picker 开始,用户选图不再需要存储权限,App 也因此拿不到真实路径,只能拿 content:// URI。 这个变化带来两个必答后果:① URI 的持久化需要显式申请持久授权,否则进程重启后失效;② 相机调用必须处理 FileProvider 的 URI 与低版本回退。能把这条因果链讲出来,这题就通了。
这题面试里问的是"你怎么做图片上传",但真正考察的是有没有处理过 URI 权限、生命周期、内存与兼容这三件事。 答"调用系统相册选图然后上传"属于会用。
Photo Picker:权限模型的分水岭
| 方案 | 需要存储权限 | 拿到的 URI | 适用 |
|---|---|---|---|
ACTION_PICK / ACTION_GET_CONTENT |
旧版本需要 | content:// |
兼容老系统 |
Photo Picker(PickVisualMedia) |
不需要 | content:// |
API 19+,推荐 |
直接读 MediaStore |
需要(API 33 分区) | content:// |
需要图库范围数据时 |
ACTION_OPEN_DOCUMENT |
不需要 | content:// |
需要长期持有文件时 |
// Photo Picker:三行启动,零权限
private val pickImage = registerForActivityResult(PickVisualMedia()) {
uri: Uri? ->
uri?.let {
persistIfNeeded(it); upload(it) }
}
fun launch() {
pickImage.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
}
它的价值不只是"不用申请权限"这一层(虽然这是最直接的收益),更重要的是:用户只授权选中的那一张或几张,而不是整个图库,隐私模型上是一次结构性改进。面试里说得出这层,就不只是记住 API。
Photo Picker 在低版本上的行为:PickVisualMedia 契约在 API 19 以上都会回调到系统提供的选择器界面(Google Play 服务提供),但部分定制系统或无 GMS 的设备上不可用,因此规范是保留 ActivityResultContracts.GetContent() 作为回退分支。
持久化授权:最常见的线上事故
// 错误:只保存 URI 字符串,不申请持久授权
db.insert("avatar", uri.toString())
// 后果:进程被杀后 URI 失效,图片显示不出来
// 正确:申请持久化权限后再保存
private fun persistIfNeeded(context: Context, uri: Uri) {
if (uri.scheme != "content") return
try {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: SecurityException) {
// Photo Picker 返回的 URI 不支持持久授权,只能本次会话内使用
// 正确做法:把图片复制到 App 私有目录
copyToPrivateStorage(context, uri)
}
}
这个 SecurityException 分支是本篇最重要的细节:Photo Picker 返回的 URI 是一次性授权,takePersistableUriPermission 会失败;只有 ACTION_OPEN_DOCUMENT 这类 SAF 路径返回的 URI 才支持持久授权。 所以真正的稳妥方案是把选中的图片复制到 App 私有目录,用自己管理的文件路径。
面试时主动说"Photo Picker 的 URI 不可持久授权,因此要复制到私有目录",是这题最强的加分点。
FileProvider:相机调用的必答题
// 相机输出必须通过 FileProvider 暴露,不能给 file:// 直接 Intent
private fun createImageUri(): Uri {
val dir = File(requireContext().cacheDir, "camera").apply {
mkdirs() }
val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
return FileProvider.getUriForFile(
requireContext(), "${requireContext().packageName}.fileprovider", file
)
}
fun openCamera() {
val uri = createImageUri()
pendingUri = uri // 记下来,onActivityResult 时要用
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, uri)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) // 授予相机应用写权限
}
cameraLauncher.launch(intent)
}
四条硬要求,缺一条相机就会失败或返回空:① 用 FileProvider 生成 content:// URI,file:// 在新版本被拒;② 给外部应用(相机)授予写权限,用 addFlags 或 ClipData(部分机型只认 ClipData);③ 在 Manifest 里注册 FileProvider 并声明 paths.xml;④ EXTRA_OUTPUT 的目标文件要在外部可访问,因此放在 cacheDir/externalCacheDir 而不是 filesDir 也不行时需配合 external-path。
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
大图处理:内存与压缩
选中的图片往往远超显示需要,直接解码会 OOM:
// 两段采样:先取边界,再按目标尺寸解码
private fun decodeSampled(uri: Uri, reqW: Int, reqH: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply {
inJustDecodeBounds = true }
requireContext().contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, bounds)
}
var sample = 1
while (bounds.outWidth / (sample * 2) >= reqW && bounds.outHeight / (sample * 2) >= reqH) {
sample *= 2 // 2 的幂次,跳过 inSampleSize 的像素抖动
}
val opts = BitmapFactory.Options().apply {
inSampleSize = sample }
return requireContext().contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, opts)
}
}
三条配套规范:① inSampleSize 必须是 2 的幂次,否则系统会用插值算法补足,等于白做;② 旋转方向在 inSampleSize 之后处理,用 ExifInterface 读 orientation;③ 大图上传前按质量压缩(Bitmap.compress(JPEG, 80, out)),用户感知不到差别但流量省一半。
权限与生命周期的收口
运行时权限只对"直接访问图库/相机"这条老路径有意义。走 Photo Picker 不需要任何权限;相机调用时,Android 11 以下的 CAMERA 权限需要显式申请(且必须在 Manifest 声明),Android 11 及以上如果只通过 Intent 调起系统相机,则不需申请(如果自己实现 CameraX 则需要)。这条版本差异是面试的高频追问。
生命周期上两个要点:① 相机返回时 Activity 可能已被重建,必须通过 pendingUri(或 savedInstanceState)恢复目标路径,否则 EXTRA_OUTPUT 写的是一个已失效路径;② 处理大图不要在主线程解码,放到协程或 Dispatchers.IO。
最常见的坑是
第一层坑:把选取的 URI 持久化却未申请持久授权,重启后失效。
规范是优先复制到 App 私有目录;确需持久 URI 时用 ACTION_OPEN_DOCUMENT 并 takePersistableUriPermission。
第二层坑:低版本无 Photo Picker 又未做回退,功能直接不可用。
规范是用 Build.VERSION 与能力探测走 GetContent 回退分支;注意 Photo Picker 在无 GMS 设备上不可用。
第三层坑:相机输出用 file:// 或未授予写权限,返回空 Intent 结果。
现场表现是 result.data == null,但 EXTRA_OUTPUT 明明设了。规范是用 FileProvider + addFlags(部分机型补 ClipData),Manifest 注册 provider 与 paths。
这题在面试里怎么问、怎么答
问:怎么让用户选图片?为什么不需要存储权限了?
答:用 Photo Picker(PickVisualMedia / PickVisualMediaRequest),它由系统(或 Play 服务)提供界面,用户只授权选中的具体图片而不是整个图库,因此 App 不需要存储权限。旧版本用 ACTION_PICK/ACTION_GET_CONTENT 回退,那条路径在低版本上需要权限。再补隐私视角的判断:这是权限模型的结构性改进,不只是少申请一个权限。
问:拿到 URI 后要注意什么?
答:三点。① URI 是 content:// 而非文件路径,不能直接当文件读,要用 contentResolver.openInputStream。② Photo Picker 返回的 URI 是一次性授权,takePersistableUriPermission 会抛 SecurityException,要长期持有就得复制到 App 私有目录;ACTION_OPEN_DOCUMENT 返回的 URI 才支持持久授权。③ 进程被杀后 URI 可能失效,所以入库的应该是自己管理的文件路径而不是 URI 字符串。
问:调用相机为什么拿不到图片?
答:四个可能。① 用了 file:// URI,新版本被拒,必须走 FileProvider。② 没给相机应用写权限(addFlags(FLAG_GRANT_WRITE_URI_PERMISSION),部分机型只认 ClipData)。③ Manifest 没注册 FileProvider 与 paths.xml。④ 目标路径外部不可写,需要放在 externalCacheDir 并声明对应 external-path。
问:大图怎么处理?
答:两段采样——先用 inJustDecodeBounds 读边界,按目标尺寸算出 inSampleSize(取 2 的幂次,避免插值),再正式解码;之后用 ExifInterface 校正旋转;上传前按质量压缩。规范是解码放 IO 线程,主线程只收结果。
问:Android 11 之后还需要申请相机权限吗?
答:取决于用法。只通过 ACTION_IMAGE_CAPTURE 调起系统相机,不需要 CAMERA 权限;自己用 CameraX 实现拍照才需要。Android 11 以下即使调起系统相机也需要显式申请并声明。这条版本差异能答出来,说明是按版本梳理过而不是照抄文档。
落到项目里怎么做
一条能写进规范的红线:所有图片选择走统一封装(ImagePicker 组件),内部完成 Photo Picker 优先 + GetContent 回退 + 复制到私有目录 + 采样解码 + 方向校正,App 业务侧只拿到一个可长期持有的本地文件路径。 这一条能覆盖本篇四类问题。
配套实践三条:① 入库只存自己管理的文件路径,不存外部 URI,避免权限失效与会话过期;② 压缩策略定成统一参数(长边 2048、质量 80),并按机型给出上限,避免单张大图打满内存;③ 相机与选图两条路径都做"Activity 被重建后仍能取到结果"的验证,因为这是最容易在真机上出现的边界。
给正在准备面试的你
这题的答法要落到"图片选择的权限模型变了,随之而来的是 URI 生命周期问题"这条主线。推荐这条线:先给四种方案的权限对照表 → 讲 Photo Picker 的权限模型变化 → 讲 URI 一次性授权与"复制到私有目录"这个正确解 → 讲 FileProvider 的四条硬要求 → 讲大图两段采样 → 补三个坑 → 收在"统一封装"。能被追问"URI 重启后失效怎么办"并答出"Photo Picker 的 URI 不可持久授权,所以复制到私有目录",说明真踩过这个坑;能补出"部分机型只认 ClipData 授权"这类细节,说明处理过多机型兼容。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:软键盘与焦点适配:IME-Insets-的现代方案
下一篇预告:通知进阶:渠道、样式与权限
有任何问题欢迎在评论区留言交流。