rokevin
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
  • 动态权限

  • 动态权限的引入
  • 运行时请求权限
  • 处理权限结果
  • 权限请求的注意事项
  • 分区存储(Scoped Storage)
    • 一、分区存储核心概念
      • 1. 存储分区划分
      • 2. 核心设计原则
    • 二、分区存储关键版本变化
    • 三、核心适配规则(开发必知)
      • 1. 应用专属目录操作(推荐优先使用)
      • 2. 共享媒体文件操作(通过 MediaStore API)
      • 3. 访问下载目录(Download/)
    • 四、兼容处理(Android 6-16)
      • 1. 分版本适配逻辑
      • 2. 临时回退开关(仅 Android 10)
    • 五、常见坑点与避坑指南
      • 1. 文件路径失效问题
      • 2. 权限申请错误
      • 3. 媒体文件扫描问题
      • 4. 国内 ROM 适配
    • 六、最佳实践总结
  • Android 10+(API 29+)中访问共享媒体目录
    • 一、核心权限规则(先明确版本差异)
    • 二、完整申请流程(适配 Android 10-16)
      • 步骤 1:在 AndroidManifest.xml 声明权限
      • 步骤 2:封装权限申请工具类(核心代码)
      • 步骤 3:在 Activity/Compose 中使用
    • 三、关键注意事项
      • 1. 避免申请多余权限
      • 2. 处理 “部分授权” 场景(Android 13+)
      • 3. 国内 ROM 适配
      • 4. 替代方案:文件选择器(无需权限)
    • 四、总结
  • RxPermissions(已经不维护)
  • EasyPermissions(已经不维护)

动态权限

动态权限的引入

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. 核心设计原则

  • 沙盒化:应用默认只能访问自己的专属目录,无法直接读写其他应用的专属目录;
  • 媒体文件访问限制:仅能通过系统提供的 MediaStore API 访问共享媒体文件,而非直接操作文件路径;
  • 权限简化:废弃 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 查询结果可能延迟,需添加重试逻辑。

六、最佳实践总结

  1. 优先使用应用专属目录:私有数据(缓存、日志)一律存储到 /Android/data/包名/,无权限限制;
  2. 按需申请媒体权限:仅访问图片时申请 READ_MEDIA_IMAGES,避免申请多余权限;
  3. 使用 FileProvider 共享文件:应用间共享文件需通过 FileProvider 生成 content:// 格式 Uri,而非文件路径;
  4. 避免依赖 MANAGE_EXTERNAL_STORAGE:普通应用尽量通过文件选择器让用户手动选择文件,而非申请全存储权限;
  5. 分版本测试:至少覆盖 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+ 访问共享媒体目录的权限申请核心:

  1. 分版本声明权限:10-12 用 READ_EXTERNAL_STORAGE,13+ 用 READ_MEDIA_* 细分权限;
  2. 动态申请 + 拒绝引导:首次申请弹窗,拒绝后引导到设置页;
  3. 优先用文件选择器:非批量读取媒体文件时,无需申请权限,提升用户体验;
  4. 适配部分授权:Android 13+ 处理用户仅授予部分媒体权限的场景,避免功能崩溃。

核心是通过 MediaPermissionHelper 封装版本差异,降低适配成本。

RxPermissions(已经不维护)

RxPermissions,API23以上Android 6.0项目分为普通权限和危险权限,该库在项目运行时动态进行权限请求,支持RxJava2。

GitHub

EasyPermissions(已经不维护)

GitHub

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)
    }
}
最近更新:: 2025/12/26 19:38
Contributors: luokaiwen