权限处理与相机相册调用 —— 打通系统功能壁垒
掌握 Android 运行时权限,调用相机、相册实现图片选择
哈喽,各位坚持学习的小伙伴们!上一篇我们掌握了网络请求,App 已经能和服务器通信了。但一个真正「完整」的应用,还需要和手机硬件打交道 —— 拍照发朋友圈、从相册选头像、扫码识别……这些功能都离不开权限处理和系统功能调用。
从 Android 6.0 引入运行时权限,到 7.0 禁止 file:// Uri,再到 10.0 分区存储和 13.0 媒体权限细分,权限和文件访问的规则在不断收紧,这也成了新手最容易踩坑的地方。今天我们就系统攻克这一关,最后还会做一个「个人头像设置」综合案例,把拍照、选图、压缩、圆形显示全部串起来。
全程附 Kotlin 代码 + 避坑指南,我们直接开始!
一、Android 权限机制全景图
1.1 权限的三级分类
| 级别 | 风险程度 | 授权方式 | 示例 |
|---|---|---|---|
| 普通权限 | 低 | 安装时自动授权 | INTERNET、BLUETOOTH |
| 危险权限 | 涉及隐私 | 运行时动态申请,用户手动同意 | CAMERA、ACCESS_FINE_LOCATION |
| 特殊权限 | 极高 | 需跳转设置页手动开启 | 悬浮窗、安装未知应用 |
新手最容易碰到的就是危险权限,比如相机、定位、存储等,不申请就直接调用会崩溃。
1.2 危险权限分组
系统把功能相关的危险权限归入同一组,用户同意组内任一权限,即视为同意全组权限(Android 10 之前)。常见分组:
| 权限组 | 包含权限 |
|---|---|
| CAMERA | CAMERA |
| STORAGE | READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE |
| LOCATION | ACCESS_FINE_LOCATION、ACCESS_COARSE_LOCATION |
1.3 各版本核心变更速览
-
Android 6.0(API 23):引入运行时权限,危险权限必须动态申请。
-
Android 7.0(API 24):禁止 file:// Uri 在应用间传递,必须使用 FileProvider 生成 content:// Uri。
-
Android 10(API 29):引入分区存储,限制直接访问外部存储公共目录。
-
Android 11(API 30):强制开启分区存储,WRITE_EXTERNAL_STORAGE 权限失效。
-
Android 13(API 33):废弃 READ_EXTERNAL_STORAGE,拆分为 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO。
结论:优先使用 App 私有目录存储,通过系统选择器访问相册,避免硬啃过时的存储权限。
二、运行时权限标准申请流程
理解原生的「检查 → 解释 → 申请 → 回调」流程,是所有简化方案的基础。
2.1 完整申请示例(相机权限)
class PermissionActivity : AppCompatActivity() {
companion object {
private const val REQ_CAMERA = 1001
}
private fun checkCameraPermission() {
when {
// 1. 已授权 → 执行业务
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED -> openCamera()
// 2. 用户之前拒绝过(未勾选"不再询问")→ 解释原因
ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)
-> showPermissionRationale()
// 3. 首次申请或已永久拒绝 → 直接发起申请
else -> ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.CAMERA),
REQ_CAMERA
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_CAMERA) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
openCamera()
} else {
// 如果用户勾选了"不再询问"并拒绝,引导去设置页
if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
})
}
}
}
}
}
2.2 传统方式的局限
-
回调与页面绑定,代码分散
-
多权限申请时代码臃肿
-
需手动处理「永久拒绝」后的引导逻辑
因此,实际开发中更推荐官方 Activity Result API 或成熟的第三方权限库
三、简化方案:Activity Result API 与 XXPermissions
3.1 Activity Result API(官方推荐)
将权限申请和结果处理内聚在一起,无需覆写 onRequestPermissionsResult。
申请单个权限:
private val requestCamera = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) openCamera() else Toast.makeText(this, "需要相机权限", Toast.LENGTH_SHORT).show()
}
// 使用时
requestCamera.launch(Manifest.permission.CAMERA)
同时申请多个权限:
private val requestMultiple = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
if (allGranted) openCamera() else Toast.makeText(this, "部分权限未授权", Toast.LENGTH_SHORT).show()
}
requestMultiple.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE))
注意:launcher 必须在 onCreate 之前注册,不能在点击事件中动态创建。
3.2 XXPermissions(第三方库,更省心)
如果觉得 API 依然繁琐,XXPermissions 可以一行代码搞定全版本适配。
XXPermissions.with(this)
.permission(Manifest.permission.CAMERA)
.request { allGranted, _, _ ->
if (allGranted) openCamera()
}
它内置了「永久拒绝」判断和跳转设置页的能力,是企业项目中的主流选择。依赖:
implementation("com.github.getActivity:XXPermissions:18.8")
四、相机与相册调用全版本适配
4.1 前置知识:FileProvider 配置(必须!)
从 Android 7.0 开始,调用相机保存原图必须通过 FileProvider 提供 content:// Uri。
第一步:在 res/xml/file_paths.xml 中定义可共享目录。
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="pictures" path="Pictures/" />
<cache-path name="cache" path="." />
</paths>
第二步:在 AndroidManifest.xml 中注册 Provider。
<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>
注意:authorities 务必唯一,一般用 包名.fileprovider,重复会导致安装冲突。
4.2 拍照获取高清原图(核心要点)
很多新手通过 data.extras?.get("data") 拿到的只是缩略图。要获取原图,必须用 EXTRA_OUTPUT 指定输出文件。
使用 ActivityResultContracts.TakePicture 简化:
private var photoUri: Uri? = null
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success && photoUri != null) loadImageFromUri(photoUri!!)
}
private fun takePhoto() {
val file = File.createTempFile("IMG_", ".jpg", getExternalFilesDir(Environment.DIRECTORY_PICTURES))
photoUri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
takePicture.launch(photoUri!!)
}
拍照成功后发送广播通知图库刷新:
sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, photoUri))
4.3 相册选择与分区存储适配
从 Android 10 开始,不要尝试获取图片的绝对路径。正确做法是通过系统选择器返回的 content:// Uri,用 ContentResolver 打开输入流读取。
打开系统相册:
private val pickImage = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { loadImageFromUri(it) }
}
private fun pickFromGallery() {
pickImage.launch("image/*")
}
从 Uri 读取 Bitmap(全版本通用):
fun uriToBitmap(context: Context, uri: Uri): Bitmap? {
return context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
}
Android 13+ 存储权限适配:
- 使用系统选择器(GetContent / PickVisualMedia)时,不需要任何存储权限。
- 若仍需动态申请,则按版本区分:
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
五、新手必踩坑点清单
| 坑点 | 现象 | 解决 |
|---|---|---|
| 拍照只拿到缩略图 | 通过 data 获取的图片极模糊 | 使用 TakePicture 契约 + FileProvider 指定输出文件 |
| 高版本文件路径访问失败 | Permission Denial 崩溃 | 用 Uri + ContentResolver 读流,不拿绝对路径 |
| FileProvider authorities 冲突 | 安装时报 INSTALL_FAILED_CONFLICTING_PROVIDER | authorities 设为 ${applicationId}.fileprovider |
| 拍照后相册找不到图 | 拍完照片系统图库不显示 | 拍完后发送 ACTION_MEDIA_SCANNER_SCAN_FILE 广播 |
| 图片方向错误 | 图片旋转 90/270 度 | 读取 Exif 信息,用 Matrix 旋转校正 |
| 大图直接加载导致 OOM | 加载高清原图时闪退 | 使用 inSampleSize 压缩采样后再解码 |
| Launcher 未提前注册 | 点击事件中创建 Launcher 崩溃 | Launcher 必须作为成员变量在 CREATED 前注册 |
| 忘记声明 FileProvider | 拍照时崩溃 | 按 4.1 节完整配置 |
六、综合实战:个人头像设置
需求:点击圆形头像弹出菜单,选择「拍照」或「相册」;自动申请权限;对图片进行压缩、旋转校正,最终显示为圆形头像。
6.1 依赖与权限
build.gradle.kts:
implementation("de.hdodenhof:circleimageview:3.1.0")
implementation("com.github.bumptech.glide:glide:4.16.0")
AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<!-- 低版本存储权限,高版本自动忽略 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<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>
6.2 布局
<FrameLayout ...>
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/ivAvatar"
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@mipmap/ic_launcher"
android:clickable="true" />
</FrameLayout>
6.3 Activity 核心代码
class AvatarActivity : AppCompatActivity() {
private lateinit var ivAvatar: CircleImageView
private var photoUri: Uri? = null
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success && photoUri != null) processImage(photoUri!!)
}
private val pickImage = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { processImage(it) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_avatar)
ivAvatar = findViewById(R.id.ivAvatar)
ivAvatar.setOnClickListener { showMenu() }
}
private fun showMenu() {
AlertDialog.Builder(this).setItems(arrayOf("拍照", "相册")) { _, which ->
if (which == 0) takePhoto() else pickFromGallery()
}.show()
}
private fun takePhoto() {
val file = File.createTempFile("avatar_", ".jpg", getExternalFilesDir(Environment.DIRECTORY_PICTURES))
photoUri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
takePicture.launch(photoUri!!)
}
private fun pickFromGallery() {
pickImage.launch("image/*")
}
private fun processImage(uri: Uri) {
val inputStream = contentResolver.openInputStream(uri)
var bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
// 如果是拍照,纠正旋转
if (uri == photoUri && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
bitmap = rotateBitmapIfNeeded(bitmap, uri)
}
// 缩放至合适尺寸并显示
val scaled = Bitmap.createScaledBitmap(bitmap, 300, 300, true)
ivAvatar.setImageBitmap(scaled)
// 保存到私有目录
File(filesDir, "avatar.jpg").outputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
// 拍照后通知图库
if (uri == photoUri) sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, photoUri))
}
private fun rotateBitmapIfNeeded(bitmap: Bitmap, uri: Uri): Bitmap {
val path = File(uri.path ?: return bitmap).absolutePath
if (!File(path).exists()) return bitmap
val exif = ExifInterface(path)
val rotation = when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
if (rotation == 0f) return bitmap
val matrix = Matrix().apply { postRotate(rotation) }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
}
权限检查:本例使用系统选择器拍照/选图,相机权限由 TakePicture 契约内部处理,无需额外代码。若需适配全部场景,仍可结合 XXPermissions 进行精细化控制。
七、总结与下篇预告 今天我们系统掌握了权限处理与相机相册调用的完整链路:
-
✅ 权限机制:三级分类、危险权限分组、各版本核心变更
-
✅ 标准流程:检查 → 申请 → 回调,及永久拒绝后的引导
-
✅ 简化方案:ActivityResultContracts 让权限申请内聚,XXPermissions 进一步提效
-
✅ FileProvider:Android 7.0+ 必须配置,解决拍照原图获取
-
✅ 拍照原图:通过 TakePicture 契约 + EXTRA_OUTPUT,告别缩略图
-
✅ 相册适配:用 Uri + ContentResolver 完美绕过分区存储限制
-
✅ 避坑大全:缩略图、路径拒绝、旋转方向、OOM 等 8 大高频坑点
-
✅ 实战案例:个人头像设置,拍照/选图 → 旋转校正 → 圆形显示一气呵成
掌握了权限与相机相册,你的 App 就真正迈入了「实用工具」的门槛。下一篇,我们将深入 Android 视图体系,学习 常用 View 与自定义 View,掌握界面开发的核心绘制能力。
--
✨ 如果本文对你有帮助,欢迎点赞、收藏,让更多零基础的小伙伴少走弯路!