8、存储机制兼容性:Scoped Storage演进,MediaStore与SAF框架的升级适配策略
存储权限这块,说实话是Android近几个大版本里改动最折腾人的地方。我最早做Android 4.4适配时,读写SD卡就是加个权限声明的事。到了Android 10强制Scoped Storage,很多老项目直接崩了。今天咱们就把这个演进脉络理清楚,顺便聊聊我在实际项目中踩过的坑。
8.1 为什么Google要搞Scoped Storage?
你想想看,在Android 10之前,一个应用只要声明了READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE,就能扫遍整个SD卡。用户的照片、下载文件、应用数据,全裸奔。Google觉得这样不行,于是推出了Scoped Storage——说白了就是给每个应用划了个"自留地",不能随便串门。
我个人习惯把这种变化理解为:以前是开放式大通铺,现在变成了独立单间加公共客厅。应用自己的文件放单间里,想访问公共区域的照片、文档,得走专门的通道。
- Android 10(API 29):强制Scoped Storage,但提供
requestLegacyExternalStorage向后兼容 - Android 11(API 30):彻底移除兼容模式,必须适配新机制
- Android 12+(API 31+):进一步收紧,增加
MANAGE_EXTERNAL_STORAGE特殊权限
8.2 MediaStore:访问公共媒体文件的标准姿势
MediaStore是Google推荐用来访问公共媒体文件的API。说白了,它就是系统维护的一个媒体数据库,你的相册、音乐播放器都通过它来读取文件。
8.2.1 查询媒体文件
我在项目中遇到过一个问题:用户反馈App里看不到新拍的照片。查了半天,发现是查询时忘了处理MediaStore的游标。这里给个标准写法:
// 查询所有图片
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED
)
val cursor = contentResolver.query(
uri,
projection,
null,
null,
"${MediaStore.Images.Media.DATE_ADDED} DESC"
)
cursor?.use {
while (it.moveToNext()) {
val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
val name = it.getString(it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME))
// 注意:这里拿到的uri需要拼接ID
val fileUri = Uri.withAppendedPath(uri, id.toString())
}
}
8.2.2 插入新文件
往公共目录写文件,现在也得走MediaStore。我习惯用ContentResolver.openOutputStream来写:
// 插入一张图片到DCIM目录
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "my_photo_${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MyApp")
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
uri?.let {
contentResolver.openOutputStream(it)?.use { outputStream ->
// 将Bitmap写入outputStream
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
}
}
嗯,这里要注意:RELATIVE_PATH这个字段是Android 10才加的,老版本没有。如果你要兼容Android 9以下,得用Environment.getExternalStoragePublicDirectory那套老办法。
8.3 SAF框架:当MediaStore不够用时
MediaStore只能访问媒体文件。如果你要操作文档、下载任意格式的文件,或者让用户自己选一个文件,就得用Storage Access Framework(SAF)。
说白了,SAF就是系统自带的文件选择器。用户通过它授权后,你的App就能读写选中的文件或目录。我最早接触SAF是在做文件管理器App时,当时觉得这玩意儿真麻烦,后来发现它其实是保护用户数据最优雅的方式。
8.3.1 使用ACTION_OPEN_DOCUMENT选择文件
// 启动文件选择器
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*" // 所有类型
// 如果要限制类型,可以指定MIME,比如 "application/pdf"
}
startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT)
// 在onActivityResult中处理
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_OPEN_DOCUMENT && resultCode == Activity.RESULT_OK) {
data?.data?.let { uri ->
// 获取持久化权限
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
// 现在可以读写这个文件了
contentResolver.openInputStream(uri)?.use { inputStream ->
// 处理文件
}
}
}
}
takePersistableUriPermission,你只能在当前Activity生命周期内访问这个Uri。App重启后就失效了。我曾经因为这个被测试提了bug,说"上次选的文件下次打开App就找不到了"。
8.4 升级适配策略:一张图看懂
说了这么多,咱们用一张图把整个适配策略串起来。这是我做项目时自己画的决策流程图,帮你快速判断该用哪种方案:
8.5 兼容性适配清单
最后,我整理了一份适配清单。每次做存储相关功能时,我都会对照着检查一遍:
| 场景 | Android 9及以下 | Android 10 | Android 11+ |
|---|---|---|---|
| 读取公共图片 | 文件路径 + READ_EXTERNAL_STORAGE |
MediaStore + 权限 | MediaStore + 权限 |
| 保存图片到DCIM | 文件路径 + WRITE_EXTERNAL_STORAGE |
MediaStore + RELATIVE_PATH |
MediaStore + RELATIVE_PATH |
| 读取任意文件 | 文件路径 + 权限 | SAF或requestLegacyExternalStorage |
SAF或MANAGE_EXTERNAL_STORAGE |
| 应用私有文件 | getExternalFilesDir() |
getExternalFilesDir() |
getExternalFilesDir() |
AndroidManifest.xml里声明android:requestLegacyExternalStorage="true"(仅限targetSdkVersion 29时有效)。但别依赖这个,Android 11以后就不认了。最好的策略是:代码里做版本判断,不同API走不同分支。
嗯,存储这块的适配确实繁琐,但核心逻辑其实不复杂:私有文件用getFilesDir,媒体文件用MediaStore,其他文件用SAF。记住这个原则,大部分场景都能搞定。我在做项目时,一般会封装一个StorageHelper工具类,把版本判断和API调用都包在里面,业务层只管调用,不用关心底层是哪个API。