第178篇 图片选择与相机调用:Photo Picker 与 SAF

简介: 本文详解Android图片选择的演进与实践:Photo Picker取消存储权限,仅授选定图片的临时URI;需复制到私有目录解决持久化问题;相机调用必用FileProvider+写权限授权;大图须两段采样防OOM。核心考察URI权限、生命周期、内存与兼容处理能力。

先把结论放在前面:图片选择的核心变化是"权限从入口移走了"——从 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-的现代方案

下一篇预告:通知进阶:渠道、样式与权限

有任何问题欢迎在评论区留言交流。

相关文章
|
1天前
|
XML Android开发 数据格式
第180篇 组件化 UI 收尾:从控件思维到组件思维
本文深入解析UI组件化本质:从“控件思维”跃迁至“组件思维”,核心在于将“状态+样式+行为”收敛为可复用契约。强调三条硬标准——不写死视觉token、不感知业务语义、不持有外部上下文。以Compose无状态组件与View体系实践对比,揭示状态归属是复用关键,并指出设计系统沉淀与常见落地坑。
24 0
|
1天前
|
定位技术 Android开发 容器
第175篇 BottomSheet 与抽屉:容器类交互组件
BottomSheet 与抽屉的核心差异不在方向,而在**返回键与手势的优先级逻辑**:BottomSheet 打开时返回键应优先关闭面板;抽屉则需优先响应边缘滑动。本质是三态状态机(Hidden/PartiallyExpanded/Expanded)与嵌套滚动、返回拦截、底层交互协同的设计问题。
22 0
|
1天前
|
安全 API Android开发
第179篇 通知进阶:渠道、样式与权限
本文深入解析Android通知机制的三层核心:渠道不可逆(用户设置优先于应用)、PendingIntent栈设计(避免点击错乱)、权限降级路径(引导而非强求)。涵盖8.0+渠道限制、13+运行时权限、兼容性实践及高频踩坑点,助你系统掌握通知开发与面试要点。
26 0
|
1天前
|
XML 前端开发 区块链
第171篇 无障碍与国际化:可达性是质量的一部分
本文深入剖析无障碍与国际化的本质关联——二者实为检验“默认假设边界”的同一枚硬币两面。聚焦Android开发,系统梳理4条无障碍硬指标(触摸目标、对比度、标签、焦点顺序)及验证方法,详解RTL适配、文本膨胀、字符串拼接等国际化关键坑点,并提供可落地的CI自动化、盲测与Code Review规范。
27 0
|
1天前
|
Android开发 C++ 容器
第174篇 NestedScrollView 与 CoordinatorLayout:联动滚动机制
CoordinatorLayout 与 NestedScrollView 构成“协商式”嵌套滚动协议:子View发起滚动,父View先消费(如AppBar折叠),再将剩余量回传。区别于传统“拦截式”事件分发,它按像素(dx/dy)协作、父子分工明确,核心在`onNestedPreScroll`/`onNestedScroll`等四回调及Behavior的轴判断、consumed回填、stop收尾三规范。
23 0
|
1天前
|
缓存 Java 定位技术
第173篇 Fragment 与 ViewModel 作用域:谁该持有谁
本文深入解析Fragment与ViewModel的作用域设计本质——“状态该活多久”。厘清Fragment两段生命周期(实例/视图)与ViewModel三档作用域(Fragment级、Activity级、NavBackStackEntry级)的对应关系,指出误用导致的状态串页、内存泄漏或参数丢失等线上难题,并强调UI收集必须绑定`viewLifecycleOwner`+`repeatOnLifecycle(STARTED)`,VM严禁持有Context/View。
27 0
|
1天前
|
缓存 编译器 定位技术
第172篇 组件与 UI 阶段通关地图:从生命周期到 Compose
本文揭示Android UI开发能力差异的核心在于**对组件默认行为的深度理解**,而非API调用本身。通过“五段能力地图”(生命周期、View体系、列表、Jetpack、Compose),每段仅设1个关键判定题(如MeasureSpec三模式、重建边界、重组机制等),聚焦“默认值何时必须改、改了有何副作用”。强调以问题驱动学习,直击面试与工程中的真实痛点。
28 0
|
2天前
|
XML 自然语言处理 Android开发
第167篇 ConstraintLayout 进阶:Barrier、Group 与辅助线
ConstraintLayout 进阶能力分三组:约束替代嵌套(提效降层)、Barrier/Chain/Guideline 表达动态关系(对齐、分布、比例)、ConstraintSet 实现布局复用与动画。关键在理解适用前提——如 Barrier 仅用于运行时宽高不确定场景,Chain 失效常因误用 0dp 或混用 weight。
26 0
|
2天前
|
缓存 大数据 API
第159篇 Compose 状态管理:remember 与状态提升
Compose状态管理核心就三件工具:`remember`(重组缓存)、`rememberSaveable`(跨配置保存)和“状态提升”(架构决策)。深度在于**明确状态归属层级**——依据四条准则判断:是否需跨配置保留、是否跨进程重建、是否兄弟共享、是否多页面共用。状态与事件必须分离(StateFlow vs Channel),高频值用`derivedStateOf`降频,避免滥用全局变量或大对象序列化。
23 0
|
2天前
|
缓存 移动开发 JavaScript
第170篇 WebView 性能优化:预加载与白屏治理
WebView性能优化核心:**聚焦“首屏可见内容”最快交付用户**。拆解为四段链路优化——复用实例(省创建)、拦截资源(省网络)、调优内核(省渲染)、治理白屏(省等待)。强调按链路归因(如`onPageCommitVisible`测首屏、`onRenderProcessGone`判崩溃),拒绝单点优化。需兼顾内存、版本校验与统一收口,方为落地关键。
33 0