第 10 篇|权限处理与相机相册调用 —— 打通系统功能壁垒

6 阅读5分钟

权限处理与相机相册调用 —— 打通系统功能壁垒

掌握 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 之前)。常见分组:

权限组包含权限
CAMERACAMERA
STORAGEREAD_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE
LOCATIONACCESS_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_PROVIDERauthorities 设为 ${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,掌握界面开发的核心绘制能力。

--

✨ 如果本文对你有帮助,欢迎点赞、收藏,让更多零基础的小伙伴少走弯路!