动态权限
动态权限的引入
Android 6.0(API 23)的发布,为平台带来了诸多新特性和改进,其中最为显著的变化之一便是动态权限机制的引入。
在此之前,Android应用所需的权限通常在安装时一次性授予,用户对于应用的权限管理相对粗放。
然而,随着用户对隐私和数据安全的日益关注,这种“一揽子授权”的方式显然已无法满足需求。
动态权限机制的推出,正是为了解决这一问题。它允许应用在运行时向用户请求所需的权限,而不是在安装时一次性获取。这意味着,应用需要在适当的时机向用户说明为何需要某项权限,并在用户明确同意后才能使用该权限。这种方式的引入,不仅提高了用户对权限管理的精细度,也促使开发者更加谨慎地处理用户数据,从而提升了整个Android平台的安全性和隐私保护能力。
对于Android开发者而言,动态权限机制的引入带来了一定的挑战。首先,开发者需要充分了解并遵循新的权限管理机制,确保应用在请求和使用权限时符合规范。其次,开发者需要在应用中合理设计权限请求的流程,以避免频繁打扰用户或造成用户体验的下降。此外,由于用户可以随时撤销已授予的权限,开发者还需要在应用中做好相应的异常处理,确保应用在权限被撤销后仍能正常运行。
尽管动态权限机制增加了开发者的负担,但它也为应用带来了更高的安全性和用户信任度。通过合理运用动态权限机制,开发者可以打造出更加安全、可靠且用户友好的Android应用。同时,随着用户对隐私和数据安全的重视程度不断提升,动态权限机制也将成为未来Android应用开发的必备技能之一。
运行时请求权限
在Android 6.0(API 23)及以上版本中,应用需要在运行时请求敏感权限,而不是在安装时一次性授予所有权限。这种动态权限机制有助于提升用户隐私保护,并要求开发者更加谨慎地处理权限请求。使用ActivityCompat.requestPermissions()方法是实现运行时权限请求的关键。
当应用需要访问某个受保护的资源时,首先应检查是否已经获得了相应的权限。如果没有获得权限,则需要向用户显示一个权限请求对话框,解释为什么需要这些权限,并请求用户授权。这可以通过调用ActivityCompat.requestPermissions()方法实现。
以下是一个使用ActivityCompat.requestPermissions()请求用户权限的示例代码:
// 检查是否已经获得了权限
if (ContextCompat.checkSelfPermission(thisActivity, Manifest.permission.READ_CALENDAR)
!= PackageManager.PERMISSION_GRANTED) {
// 如果还没有获得权限,则向用户请求权限
ActivityCompat.requestPermissions(thisActivity,
new String[]{Manifest.permission.READ_CALENDAR},
MY_PERMISSIONS_REQUEST_READ_CALENDAR);
// MY_PERMISSIONS_REQUEST_READ_CALENDAR 是自定义的权限请求码,用于在回调方法中识别权限请求结果
}
在调用requestPermissions()方法后,系统会弹出一个对话框,向用户显示权限请求信息。用户可以选择“允许”或“拒绝”权限请求。无论用户做出何种选择,系统都会调用应用的onRequestPermissionsResult()回调方法,并传入权限请求码和请求结果。
开发者需要在onRequestPermissionsResult()方法中处理权限请求结果。如果用户授予了权限,则应用可以继续执行需要该权限的操作。如果用户拒绝了权限请求,则应用需要适当地处理这种情况,例如向用户显示一条解释信息,或者禁用需要该权限的功能。
以下是一个处理权限请求结果的示例代码:
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
switch (requestCode) {
case MY_PERMISSIONS_REQUEST_READ_CALENDAR: {
// 如果请求成功,则grantResults数组的长度会大于等于1,并且数组中的每个元素都会是PackageManager.PERMISSION_GRANTED
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 权限被授予,执行需要该权限的操作
} else {
// 权限被拒绝,向用户显示解释信息或禁用功能
}
return;
}
// 处理其他权限请求的结果...
}
}
通过合理使用运行时权限请求机制,开发者可以在保护用户隐私的同时,确保应用能够正常地访问所需的资源。
处理权限结果
在Android 6.0及以上版本中,用户在安装应用时不再需要授予所有权限,而是在应用运行过程中,根据需要动态请求权限。这为用户提供了更大的隐私保护,同时也对开发者提出了新的挑战。当用户响应权限请求后,开发者需要适当地处理这些结果,以确保应用的正常运行。
当用户对权限请求作出响应后,系统会调用onRequestPermissionsResult()方法,并将用户的选择作为参数传递。开发者需要重写这个方法以处理权限请求的结果。
以下是一个处理权限结果的基本示例:
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
switch (requestCode) {
case MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 权限被授予,可以进行相关操作
readFileFromExternalStorage();
} else {
// 权限被拒绝,向用户解释为什么需要这个权限
showExplanation();
}
return;
}
}
}
在这个例子中,MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE是我们在请求权限时定义的请求码。当收到权限请求的结果时,我们首先检查请求码以确定是哪个权限请求的结果。然后,我们检查grantResults数组以确定用户是否授予了权限。如果用户授予了权限,我们可以继续执行需要该权限的操作,如读取外部存储。如果用户拒绝了权限,我们可能需要向用户解释为什么应用需要这个权限,并可能再次请求权限。
在实际开发中,根据权限结果调整应用功能是非常重要的。如果用户拒绝了某个关键权限,应用可能需要提供替代方案或者限制某些功能的使用。例如,如果一个应用需要访问用户的位置信息来提供服务,但用户拒绝了位置权限,那么应用可能需要提供一个不需要位置信息的简化版本的服务。
开发者还需要注意,不要频繁地请求权限,以免引起用户的反感。如果用户多次拒绝某个权限请求,应用应该尊重用户的选择,并考虑提供不需要该权限的替代功能。
权限请求的注意事项
在Android应用中请求权限是确保应用正常运行的关键环节,但同时也涉及到用户隐私和体验的重要方面。因此,在请求权限时,开发者需要特别注意以下几个方面,以确保合理使用权限、保护用户隐私,并提供良好的用户体验。
开发者应明确应用所需的权限,并避免请求不必要的权限。过多的权限请求可能会让用户产生疑虑,降低应用的信任度。因此,在开发过程中,应对应用的功能需求进行仔细分析,确定真正需要的权限,并在应用中仅请求这些权限。
开发者需要关注用户的隐私保护。在请求涉及用户敏感信息的权限时,如相机、麦克风或位置信息等,应向用户明确说明权限的用途和必要性,并遵守相关的隐私政策和法规。同时,应采取必要的安全措施,确保用户数据的安全性和保密性。
用户体验也是请求权限时需要考虑的重要因素。开发者应选择合适的时机向用户请求权限,避免在用户未明确了解应用功能或正在执行关键任务时打断用户。同时,应提供清晰的权限请求说明和友好的用户界面,帮助用户理解权限的作用并做出决策。
在请求权限后,开发者还需要妥善处理用户的响应。如果用户拒绝了权限请求,应用应能够优雅地处理这种情况,并向用户提供相应的反馈或替代方案。避免在用户拒绝权限后导致应用崩溃或功能受限,从而确保应用的稳定性和可用性。
请求权限是Android开发中的重要环节,但同时也需要开发者谨慎处理。通过合理使用权限、保护用户隐私和提供良好的用户体验,开发者可以构建出更加安全、可靠且受欢迎的应用。
分区存储(Scoped Storage)
分区存储(Scoped Storage)是 Android 10(API 29)引入的核心存储权限改革,Android 11(API 30)强化、Android 13(API 33)完成最终定型,核心目标是限制应用对外部存储的无差别访问,保护用户隐私,同时统一文件管理逻辑。
分区存储的核心是 “隐私优先、用户可控”,适配的关键是放弃对外部存储的无差别访问,转向 “专属目录 + MediaStore + 文件选择器” 的组合方案,既能满足合规要求,也能保证应用在各版本的兼容性。
以下是从核心原理、适配规则、开发实践到兼容处理的全维度详解:
一、分区存储核心概念
1. 存储分区划分
Android 外部存储(External Storage)被划分为两大区域,应用访问逻辑完全不同:
| 存储区域 | 访问范围 | 权限要求 | 数据生命周期 |
|---|---|---|---|
| 应用专属目录 | /Android/data/包名/(文件)、/Android/media/包名/(媒体) | Android 10+ 无需 READ/WRITE_EXTERNAL_STORAGE,直接访问 | 应用卸载时自动删除 |
| 共享媒体目录 | DCIM/、Pictures/、Videos/、Audio/ 等系统媒体目录 | Android 10-12:需 READ_EXTERNAL_STORAGE;Android 13+:需 READ_MEDIA_* 细分权限 | 应用卸载后数据保留 |
| 下载目录(特殊) | Download/ | Android 10+ 需 MANAGE_EXTERNAL_STORAGE(仅系统 / 工具类应用可申请)或通过文件选择器 | 数据永久保留(除非用户手动删除) |
2. 核心设计原则
- 沙盒化:应用默认只能访问自己的专属目录,无法直接读写其他应用的专属目录;
- 媒体文件访问限制:仅能通过系统提供的
MediaStoreAPI 访问共享媒体文件,而非直接操作文件路径; - 权限简化:废弃
WRITE_EXTERNAL_STORAGE权限(Android 10+ 标记为废弃,Android 13+ 完全失效); - 用户可控:应用修改 / 删除共享媒体文件需用户确认,避免静默操作。
二、分区存储关键版本变化
| Android 版本 | 分区存储规则 | 兼容开关(requestLegacyExternalStorage) |
|---|---|---|
| Android 10 | 默认开启分区存储,可通过 requestLegacyExternalStorage=true 回退到旧逻辑 | 有效(仅临时兼容) |
| Android 11 | 强制开启分区存储,requestLegacyExternalStorage 仅对升级应用生效,新应用无效 | 部分失效(仅升级场景) |
| Android 12+ | 完全移除回退开关,必须适配分区存储逻辑 | 无效 |
| Android 13+ | 拆分媒体权限为 READ_MEDIA_IMAGES/VIDEO/AUDIO,彻底废弃 READ_EXTERNAL_STORAGE | 无 |
三、核心适配规则(开发必知)
1. 应用专属目录操作(推荐优先使用)
应用专属目录是分区存储中最易适配、无权限限制的区域,适合存储应用私有数据(如缓存、日志、下载的私有文件)。
(1)获取专属目录路径
// 方式1:通过 Context 获取(推荐,适配所有版本)
// 私有文件目录(/Android/data/包名/files/)
val privateFilesDir = context.getExternalFilesDir(null)
// 私有图片目录(/Android/data/包名/files/Pictures/)
val privatePicDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
// 私有缓存目录(/Android/data/包名/cache/)
val privateCacheDir = context.externalCacheDir
// 方式2:通过 MediaStore 访问媒体专属目录(/Android/media/包名/)
// Android 10+ 新增,用于存储应用对外共享的媒体文件
val mediaDir = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.getExternalMediaDirs().firstOrNull()
} else {
privatePicDir // 低版本降级到 files 目录
}
(2)读写专属目录文件(无需权限)
// 写入文件到专属目录
fun writeToPrivateDir(context: Context, fileName: String, content: String) {
val file = File(context.getExternalFilesDir(null), fileName)
file.writeText(content, Charsets.UTF_8)
}
// 读取专属目录文件
fun readFromPrivateDir(context: Context, fileName: String): String? {
val file = File(context.getExternalFilesDir(null), fileName)
return if (file.exists()) file.readText(Charsets.UTF_8) else null
}
2. 共享媒体文件操作(通过 MediaStore API)
访问 DCIM/、Pictures/ 等共享目录的媒体文件,必须使用 MediaStore API(而非直接操作 File 类),避免文件路径失效。
(1)读取共享媒体文件
步骤 1:声明权限(AndroidManifest.xml)
<!-- Android 10-12 声明 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Android 13+ 声明细分权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
步骤 2:动态申请权限(参考前文权限适配逻辑)
步骤 3:通过 MediaStore 查询媒体文件
// 查询所有图片文件(适配 Android 10+)
fun queryImages(context: Context): List<Uri> {
val imageUris = mutableListOf<Uri>()
// 定义要查询的列
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED
)
// 排序:按添加时间降序
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
// 执行查询
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
while (cursor.moveToNext()) {
// 构建图片 Uri(content:// 格式,而非文件路径)
val id = cursor.getLong(idColumn)
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
imageUris.add(uri)
}
}
return imageUris
}
// 通过 Uri 读取图片内容
fun loadImageFromUri(context: Context, uri: Uri): Bitmap? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)
} catch (e: Exception) {
null
}
}
(2)写入 / 修改共享媒体文件
// 保存图片到 DCIM 目录(Android 10+)
fun saveImageToDCIM(context: Context, bitmap: Bitmap, fileName: String): Uri? {
val contentValues = ContentValues().apply {
// 文件名称
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
// 文件类型
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
// Android 10+ 必须设置相对路径
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/MyApp/")
}
// 文件修改时间
put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000)
}
// 插入到 MediaStore 并获取 Uri
return context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)?.also { uri ->
// 通过 Uri 写入图片数据
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
}
}
}
// 删除共享媒体文件(需用户确认,Android 10+)
fun deleteMediaFile(context: Context, uri: Uri): Boolean {
return try {
// Android 11+ 需申请 DELETE_PHOTO_PERMISSION 或用户手动确认
val rowsDeleted = context.contentResolver.delete(uri, null, null)
rowsDeleted > 0
} catch (e: Exception) {
false
}
}
3. 访问下载目录(Download/)
Android 10+ 禁止应用直接读写 Download/ 目录,仅两种合法方式:
方式 1:使用文件选择器(ACTION_OPEN_DOCUMENT)
让用户手动选择文件,无需特殊权限:
// 打开文件选择器,选择 Download 目录的文件
fun openFilePicker(activity: Activity) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
// 指定文件类型(*/* 表示所有类型)
type = "*/*"
// 限定在 Download 目录(可选)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI)
}
}
// 注册 ActivityResultLauncher 接收结果
filePickerLauncher.launch(intent)
}
// 处理文件选择结果
private val filePickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
uri?.let {
// 读取文件内容(需申请 READ_EXTERNAL_STORAGE/READ_MEDIA_* 权限)
val inputStream = contentResolver.openInputStream(it)
// 处理文件...
}
}
}
方式 2:申请 MANAGE_EXTERNAL_STORAGE 权限(仅特殊应用)
仅文件管理器、备份工具、杀毒软件等系统级应用可申请,普通应用会被应用商店驳回:
步骤 1:声明权限
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
步骤 2:引导用户到设置页授权
fun requestManageStoragePermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${context.packageName}")
context.startActivity(intent)
}
}
四、兼容处理(Android 6-16)
1. 分版本适配逻辑
// 通用文件写入工具(适配 Android 6-16)
fun saveFileCompat(context: Context, content: String, isPublic: Boolean, fileName: String) {
if (isPublic) {
// 写入共享目录(媒体/Download)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+:使用 MediaStore
saveToMediaStore(context, content, fileName)
} else {
// Android 6-9:使用 File 类 + WRITE_EXTERNAL_STORAGE 权限
val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), fileName)
file.writeText(content)
}
} else {
// 写入专属目录(全版本兼容)
writeToPrivateDir(context, fileName, content)
}
}
2. 临时回退开关(仅 Android 10)
若短期内无法适配,可在 AndroidManifest.xml 中添加开关(仅 Android 10 有效):
<application
android:requestLegacyExternalStorage="true">
<!-- 其他配置 -->
</application>
⚠️ 注意:Android 11+ 该开关失效,且应用商店可能拒绝审核,仅作为临时过渡方案。
五、常见坑点与避坑指南
1. 文件路径失效问题
- Android 10+ 禁止通过
File(Environment.getExternalStorageDirectory(), "test.jpg")直接访问共享目录,需改用MediaStore; - 避免硬编码文件路径(如
/sdcard/DCIM/),不同设备路径可能不同。
2. 权限申请错误
- Android 13+ 申请
READ_EXTERNAL_STORAGE无效,需替换为READ_MEDIA_*细分权限; - 写入共享媒体文件无需
WRITE_EXTERNAL_STORAGE,但修改 / 删除需用户确认。
3. 媒体文件扫描问题
低版本(Android 9-)写入文件后需触发媒体扫描,否则系统无法识别:
// 触发媒体扫描 fun scanFile(context: Context, file: File) { MediaScannerConnection.scanFile( context, arrayOf(file.absolutePath), null, null ) }
4. 国内 ROM 适配
- MIUI/OriginOS/ColorOS 对
MANAGE_EXTERNAL_STORAGE权限管控更严格,需引导用户到 ROM 专属设置页; - 华为 HarmonyOS 兼容分区存储逻辑,但
MediaStore查询结果可能延迟,需添加重试逻辑。
六、最佳实践总结
- 优先使用应用专属目录:私有数据(缓存、日志)一律存储到
/Android/data/包名/,无权限限制; - 按需申请媒体权限:仅访问图片时申请
READ_MEDIA_IMAGES,避免申请多余权限; - 使用 FileProvider 共享文件:应用间共享文件需通过
FileProvider生成content://格式 Uri,而非文件路径; - 避免依赖 MANAGE_EXTERNAL_STORAGE:普通应用尽量通过文件选择器让用户手动选择文件,而非申请全存储权限;
- 分版本测试:至少覆盖 Android 10(API 29)、Android 13(API 33)、Android 16(API 36)三个版本,验证存储逻辑。
Android 10+(API 29+)中访问共享媒体目录
在 Android 10+(API 29+)中访问共享媒体目录(DCIM、Pictures、Videos、Audio 等)的权限申请逻辑,随版本迭代有明确差异(核心是 Android 13+ 拆分媒体权限),需分版本适配。以下是完整的申请流程、代码示例和关键注意事项:
一、核心权限规则(先明确版本差异)
| Android 版本 | 权限类型 | 申请逻辑 |
|---|---|---|
| 10-12(29-32) | READ_EXTERNAL_STORAGE | 动态申请该权限,授予后可访问所有共享媒体文件 |
| 13+(33+) | READ_MEDIA_* 细分权限 | 拆分为 READ_MEDIA_IMAGES(图片)、READ_MEDIA_VIDEO(视频)、READ_MEDIA_AUDIO(音频),需按需申请 |
| 16+(36+) | 同 13+ 规则 | 新增 “按类型授权”(用户可仅授予 “仅图片” 权限),申请逻辑不变但需适配弹窗文案 |
⚠️ 关键:
- Android 10+ 废弃
WRITE_EXTERNAL_STORAGE,写入 / 修改共享媒体文件无需该权限(通过MediaStore直接操作); - 仅读取共享媒体文件需要动态申请权限,写入 / 修改无需(但删除 / 修改需用户确认)。
二、完整申请流程(适配 Android 10-16)
步骤 1:在 AndroidManifest.xml 声明权限
<!-- 基础声明:适配 Android 10-12 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> <!-- 仅在 32 及以下版本生效 -->
<!-- Android 13+ 细分媒体权限(按需声明,不要全加) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
<!-- 可选:如需写入媒体文件,无需声明 WRITE 权限(Android 10+ 废弃) -->
步骤 2:封装权限申请工具类(核心代码)
兼容 Android 10-16,包含 “权限检查→动态申请→拒绝引导” 全流程:
import android.Manifest
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
/**
* Android 10+ 共享媒体权限申请工具类
*/
class MediaPermissionHelper(private val context: Context) {
// 1. 定义不同版本的媒体权限数组
private fun getMediaPermissions(): Array<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+
// 按需选择(示例:仅申请图片+视频权限)
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10-12
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
} else { // 低版本(Android 6-9,兼容用)
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
// 2. 检查权限是否已授予
fun hasMediaPermission(): Boolean {
val permissions = getMediaPermissions()
return permissions.all {
ContextCompat.checkSelfPermission(context, it) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
}
// 3. 动态申请权限(需传入 Activity 的权限启动器)
fun requestMediaPermission(
launcher: ActivityResultLauncher<Array<String>>,
onGranted: () -> Unit, // 权限授予回调
onDenied: () -> Unit // 权限拒绝回调
) {
val permissions = getMediaPermissions()
if (hasMediaPermission()) {
onGranted()
return
}
// 申请权限(Android 13+ 会按细分权限弹窗,用户可选择仅授予部分)
launcher.launch(permissions) { result ->
val allGranted = result.all { it.value }
if (allGranted) {
onGranted()
} else {
onDenied()
// 引导用户到设置页开启(Android 13+ 首次拒绝后二次申请直接跳设置)
showPermissionDeniedGuide()
}
}
}
// 4. 权限拒绝后引导用户到设置页
private fun showPermissionDeniedGuide() {
AlertDialog.Builder(context)
.setTitle("需要媒体权限")
.setMessage(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
"需开启图片/视频访问权限以加载本地媒体文件(Android 13+ 可仅授予部分权限)"
} else {
"需开启存储权限以访问本地图片/视频文件"
}
)
.setPositiveButton("去设置") { _, _ ->
// 跳转到应用权限设置页(适配国内 ROM)
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}
.setNegativeButton("取消", null)
.show()
}
}
步骤 3:在 Activity/Compose 中使用
方式 1:Activity 中使用(View 体系)
class MediaPermissionActivity : AppCompatActivity() {
private lateinit var mediaPermissionHelper: MediaPermissionHelper
// 注册权限启动器(AndroidX 推荐,替代旧版 onRequestPermissionsResult)
private val mediaPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
// 该回调会在工具类中处理,此处无需重复逻辑
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_media_permission)
mediaPermissionHelper = MediaPermissionHelper(this)
// 点击按钮申请权限
findViewById<android.widget.Button>(R.id.btn_request_media).setOnClickListener {
mediaPermissionHelper.requestMediaPermission(
launcher = mediaPermissionLauncher,
onGranted = {
// 权限授予成功:读取媒体文件
loadMediaFiles()
},
onDenied = {
// 权限拒绝:提示用户
android.widget.Toast.makeText(this, "权限拒绝,无法访问媒体文件", android.widget.Toast.LENGTH_SHORT).show()
}
)
}
}
// 读取共享媒体文件(权限授予后执行)
private fun loadMediaFiles() {
// 此处调用 MediaStore 查询图片/视频(参考前文分区存储的 MediaStore 示例)
val images = queryImages(this) // 该方法见前文分区存储的 MediaStore 查询示例
android.widget.Toast.makeText(this, "读取到 ${images.size} 张图片", android.widget.Toast.LENGTH_SHORT).show()
}
}
方式 2:Compose 中使用(声明式)
@Composable
fun MediaPermissionCompose() {
val context = LocalContext.current
val mediaPermissionHelper = remember { MediaPermissionHelper(context) }
// 注册 Compose 权限启动器
val mediaPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val allGranted = result.all { it.value }
if (allGranted) {
// 权限授予:加载媒体文件
loadMediaFiles(context)
} else {
// 权限拒绝:显示引导弹窗
AlertDialog(
onDismissRequest = {},
title = { Text("权限申请失败") },
text = { Text("需开启媒体权限以访问本地图片/视频") },
confirmButton = {
TextButton(onClick = {
// 跳转到设置页
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intent)
}) {
Text("去设置")
}
},
dismissButton = {
TextButton(onClick = {}) { Text("取消") }
}
)
}
}
// UI 按钮
Column(modifier = Modifier.padding(16.dp)) {
Button(
onClick = {
mediaPermissionHelper.requestMediaPermission(
launcher = mediaPermissionLauncher,
onGranted = { loadMediaFiles(context) },
onDenied = {}
)
}
) {
Text(
if (mediaPermissionHelper.hasMediaPermission()) "已授予媒体权限"
else "申请媒体权限(Android 10-16)"
)
}
}
}
// Compose 中读取媒体文件
private fun loadMediaFiles(context: Context) {
val images = queryImages(context) // 复用前文 MediaStore 查询图片的方法
android.widget.Toast.makeText(context, "读取到 ${images.size} 张图片", android.widget.Toast.LENGTH_SHORT).show()
}
三、关键注意事项
1. 避免申请多余权限
- Android 13+ 按需申请细分权限(如仅需图片则只申请
READ_MEDIA_IMAGES),申请多余权限会增加用户拒绝概率; - 无需申请
WRITE_EXTERNAL_STORAGE(Android 10+ 废弃),写入共享媒体文件直接通过MediaStore即可。
2. 处理 “部分授权” 场景(Android 13+)
用户可能仅授予 READ_MEDIA_IMAGES 但拒绝 READ_MEDIA_VIDEO,需在代码中判断并降级功能:
// 检查是否仅授予图片权限
fun hasOnlyImagePermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
val hasImage = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) == android.content.pm.PackageManager.PERMISSION_GRANTED
val hasVideo = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VIDEO) == android.content.pm.PackageManager.PERMISSION_GRANTED
return hasImage && !hasVideo
}
3. 国内 ROM 适配
- MIUI/OriginOS/ColorOS:权限设置页路径与原生不同,可复用前文的
RomUtils跳转到 ROM 专属设置页; - 华为 HarmonyOS:权限申请弹窗文案需符合华为审核要求(明确说明权限用途),否则会被驳回。
4. 替代方案:文件选择器(无需权限)
若仅需让用户选择单个媒体文件,可使用 ACTION_OPEN_DOCUMENT 打开系统文件选择器,无需申请任何权限:
// 打开文件选择器选择图片(无需权限)
fun openImagePicker(activity: Activity, launcher: ActivityResultLauncher<Intent>) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "image/*" // 限定图片类型
}
launcher.launch(intent)
}
// 处理选择结果
private val imagePickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val uri = result.data?.data // 获取选中图片的 Uri
uri?.let { loadImageFromUri(context, it) } // 读取图片
}
}
👉 推荐场景:仅需用户手动选择少量文件(如上传头像、选择视频),优先用文件选择器,减少权限申请。
四、总结
Android 10+ 访问共享媒体目录的权限申请核心:
- 分版本声明权限:10-12 用
READ_EXTERNAL_STORAGE,13+ 用READ_MEDIA_*细分权限; - 动态申请 + 拒绝引导:首次申请弹窗,拒绝后引导到设置页;
- 优先用文件选择器:非批量读取媒体文件时,无需申请权限,提升用户体验;
- 适配部分授权:Android 13+ 处理用户仅授予部分媒体权限的场景,避免功能崩溃。
核心是通过 MediaPermissionHelper 封装版本差异,降低适配成本。
RxPermissions(已经不维护)
RxPermissions,API23以上Android 6.0项目分为普通权限和危险权限,该库在项目运行时动态进行权限请求,支持RxJava2。
EasyPermissions(已经不维护)
dependencies {
// For developers using AndroidX in their applications
implementation 'pub.devrel:easypermissions:3.0.0'
// For developers using the Android Support Library
implementation 'pub.devrel:easypermissions:2.0.1'
}
在BaseActivity中设置
abstract class BaseActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks, IBaseView {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(layoutId())
// 不自动弹出键盘
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN)
immersionBar {
statusBarColor(R.color.white)
autoStatusBarDarkModeEnable(true)
}
}
/**
* 打卡软键盘
*/
fun openKeyBoard(mEditText: EditText, mContext: Context) {
val imm = mContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(mEditText, InputMethodManager.RESULT_SHOWN)
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
}
/**
* 关闭软键盘
*/
fun closeKeyBoard(mEditText: EditText, mContext: Context) {
val imm = mContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(mEditText.windowToken, 0)
}
/**
* 重写要申请权限的Activity或者Fragment的onRequestPermissionsResult()方法,
* 在里面调用EasyPermissions.onRequestPermissionsResult(),实现回调。
*
* @param requestCode 权限请求的识别码
* @param permissions 申请的权限
* @param grantResults 授权结果
*/
override fun onRequestPermissionsResult(requestCode: Int, @NonNull permissions: Array<String>, @NonNull grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this)
}
/**
* 当权限被成功申请的时候执行回调
*
* @param requestCode 权限请求的识别码
* @param perms 申请的权限的名字
*/
override fun onPermissionsGranted(requestCode: Int, perms: List<String>) {
Log.e("EasyPermissions", "获取成功的权限$perms")
}
/**
* 当权限申请失败的时候执行的回调
*
* @param requestCode 权限请求的识别码
* @param perms 申请的权限的名字
*/
override fun onPermissionsDenied(requestCode: Int, perms: List<String>) {
//处理权限名字字符串
val sb = StringBuffer()
for (str in perms) {
sb.append(str)
sb.append("\n")
}
sb.replace(sb.length - 2, sb.length, "")
//用户点击拒绝并不在询问时候调用
if (EasyPermissions.somePermissionPermanentlyDenied(this, perms)) {
Toast.makeText(this, "已拒绝权限" + sb + "并不再询问", Toast.LENGTH_SHORT).show()
AppSettingsDialog.Builder(this)
.setRationale("此功能需要" + sb + "权限,否则无法正常使用,是否打开设置")
.setPositiveButton("好")
.setNegativeButton("不行")
.build()
.show()
}
}
open fun showSystemBar(isFullScreen: Boolean) {
//显示
SystemBarUtils.showUnStableStatusBar(this)
if (isFullScreen) {
SystemBarUtils.showUnStableNavBar(this)
}
}
open fun hideSystemBar(isFullScreen: Boolean) {
//隐藏
SystemBarUtils.hideStableStatusBar(this)
if (isFullScreen) {
SystemBarUtils.hideStableNavBar(this)
}
}
}
继承BaseActivity的页面中使用
/**
* 检查图片权限
*/
private fun checkPerm() {
val params = arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
if (EasyPermissions.hasPermissions(this, *params)) {
ZenPublishActivity.open(this)
} else {
EasyPermissions.requestPermissions(
this,
"需要相机、读写文件权限",
PermissionCode.ZEN,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
}
}
/**
* 检查小视频权限
*/
private fun checkSmallVideoPerm() {
val params = arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
if (EasyPermissions.hasPermissions(this, *params)) {
SmallVideoRecordNewActivity.open(this)
} else {
EasyPermissions.requestPermissions(
this,
"需要相机、录音、读写文件权限",
PermissionCode.SMALL_VIDEO,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
}
}
override fun onPermissionsGranted(requestCode: Int, perms: List<String>) {
super.onPermissionsGranted(requestCode, perms)
if (requestCode == PermissionCode.ZEN && EasyPermissions.hasPermissions(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
) {
ZenPublishActivity.open(this)
}
if (requestCode == PermissionCode.SMALL_VIDEO && EasyPermissions.hasPermissions(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
) {
SmallVideoRecordNewActivity.open(this)
}
}