15、权限与存储:Scoped Storage、MANAGE_EXTERNAL_STORAGE、MediaStore权限
存储权限这块,可以说是Android权限演进中最折腾人的部分了。我从Android 6.0做到现在,每次大版本更新,存储这块几乎都要改一遍。尤其是Android 10推出的Scoped Storage,当时我们团队好几个项目都因为这个改了架构。
说白了,Google就是想管住那些乱读乱写文件的App。你想想看,一个手电筒App凭什么要读你SD卡里的所有照片?这确实不合理。但对我们开发者来说,这改动确实不小。
15.1 Scoped Storage 到底是什么
Scoped Storage,中文叫「分区存储」。它的核心思想很简单:每个App只能自由访问自己的专属目录,以及公共媒体目录中的文件。
具体来说,App能直接读写的地方就两个:
- 内部存储的私有目录:
Context.getFilesDir()、Context.getCacheDir() - 外部存储的私有目录:
Context.getExternalFilesDir()
至于其他目录,比如Download、Documents,或者别的App的私有目录,你就别想了。除非用户通过系统文件选择器主动授权。
核心变化:Android 10及以上,READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE这两个权限,只能让你访问公共媒体目录(Music、Movies、Pictures等)。想访问其他目录?得用MANAGE_EXTERNAL_STORAGE。
我记得刚适配Android 10时,有个功能需要读取Download目录下的文件。按老思路直接拿路径去读,结果一直报权限错误。查了半天才发现,这条路已经被堵死了。
15.2 MediaStore 的正确用法
既然不能直接拿路径操作文件了,那怎么办?Google给出的方案就是MediaStore。它相当于一个内容提供者,帮你管理公共媒体文件。
举个例子,你想读取所有图片:
// 查询所有图片
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATA
)
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
val name = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME))
// 注意:DATA字段在Android 10+可能为空
val data = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
}
}
这里有个坑,我必须要提醒你。DATA字段(也就是文件路径)在Android 10以上可能返回null。你不能依赖它来访问文件。正确的做法是用Uri来操作:
// 通过Uri读取文件内容
val uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
val inputStream = contentResolver.openInputStream(uri)
// 然后读取流数据...
写入文件也是类似的套路:
// 插入一条新记录到MediaStore
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "my_photo.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
uri?.let {
contentResolver.openOutputStream(it)?.use { outputStream ->
// 写入图片数据
}
}
个人建议:尽量用RELATIVE_PATH字段来指定存储位置,而不是拼绝对路径。这样兼容性更好,也符合Scoped Storage的设计思路。
15.3 MANAGE_EXTERNAL_STORAGE 权限
有些App确实需要访问所有文件,比如文件管理器、杀毒软件、备份工具。Google给这类App留了一个后门——MANAGE_EXTERNAL_STORAGE权限。
这个权限在Android 11引入,申请后可以获得「所有文件访问权限」。但门槛很高:
- 你的App必须属于特定的核心功能类别
- Google Play审核时会严格审查
- 用户需要手动去设置里开启
申请方式如下:
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
然后在代码中检查并引导用户:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!Environment.isExternalStorageManager()) {
// 引导用户去设置页面开启
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
data = Uri.parse("package:${context.packageName}")
}
startActivity(intent)
}
}
警告:千万别为了省事随便申请这个权限。Google Play对滥用此权限的App处罚很重。我曾经见过一个App因为非必要场景申请了MANAGE_EXTERNAL_STORAGE,直接被下架了。
15.4 不同Android版本的兼容策略
现实情况是,你的App可能要同时支持Android 9、10、11、12、13……每个版本的存储策略都不一样。怎么办?
我的做法是分层处理:
| Android版本 | 存储策略 | 推荐做法 |
|---|---|---|
| Android 9及以下 | 传统存储 | 直接使用文件路径,申请READ/WRITE_EXTERNAL_STORAGE |
| Android 10 | Scoped Storage(可关闭) | 在manifest中设置requestLegacyExternalStorage="true"可临时关闭 |
| Android 11及以上 | 强制Scoped Storage | 必须使用MediaStore或SAF |
对于Android 10,Google留了一个过渡方案:
<application
android:requestLegacyExternalStorage="true"
...>
加上这个属性后,你的App在Android 10上可以继续使用传统文件路径。但注意,这只是临时方案。Android 11开始这个属性就失效了。
我建议你尽早迁移到MediaStore方案。别拖到用户都升级到Android 11以上了才改,那时候就手忙脚乱了。
15.5 知识体系总览
下面这张图总结了本章的核心逻辑:
15.6 避坑指南
最后,分享几个我踩过的坑:
- 不要用File API直接操作公共目录:Android 11以上会直接抛异常。老老实实用MediaStore。
- 注意Uri的持久化:用户通过文件选择器选中的Uri,记得用
takePersistableUriPermission()保存权限,否则App重启后就失效了。 - 测试时多换几台设备:不同厂商对Scoped Storage的实现有差异。我遇到过某品牌手机在Android 10上仍然允许直接读写公共目录,但另一台就不行。
- 不要假设用户会给你MANAGE_EXTERNAL_STORAGE权限:很多用户看到这个权限就直接拒绝了。你的App要做好降级方案。
我的经验:如果你只是读写图片、视频、音频这类媒体文件,用MediaStore就够了。别去碰MANAGE_EXTERNAL_STORAGE。这个权限就像一把大锤,不是所有钉子都需要它来敲。
嗯,存储权限这块内容确实不少。但只要你理解了Scoped Storage的设计初衷——保护用户隐私,很多设计就说得通了。适配的时候别急,一步步来,先从MediaStore入手,把核心功能跑通,再考虑特殊场景。