8、存储机制兼容性:Scoped Storage演进,MediaStore与SAF框架的升级适配策略

存储权限这块,说实话是Android近几个大版本里改动最折腾人的地方。我最早做Android 4.4适配时,读写SD卡就是加个权限声明的事。到了Android 10强制Scoped Storage,很多老项目直接崩了。今天咱们就把这个演进脉络理清楚,顺便聊聊我在实际项目中踩过的坑。

8.1 为什么Google要搞Scoped Storage?

你想想看,在Android 10之前,一个应用只要声明了READ_EXTERNAL_STORAGEWRITE_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())
    }
}
避坑指南: 我曾经在Android 11上发现,直接用文件路径访问公共目录会抛异常。必须用ContentResolver打开Uri,不能再用File API直接读路径了。

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 升级适配策略:一张图看懂

说了这么多,咱们用一张图把整个适配策略串起来。这是我做项目时自己画的决策流程图,帮你快速判断该用哪种方案:

Scoped Storage 适配决策流程图 你的App需要访问什么? 媒体文件 任意文件 应用私有文件 MediaStore API SAF框架 Context.getFilesDir() 查询/插入/更新媒体文件 使用ContentResolver操作Uri 注意:Android 10+需RELATIVE_PATH 用户选择文件/目录 使用ACTION_OPEN_DOCUMENT 记得takePersistableUriPermission 无需任何权限 直接读写,App卸载自动删除 最推荐的方式 核心原则:能用私有目录就用私有目录 必须访问公共文件时,优先MediaStore,其次SAF

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()
我的经验: 如果你要兼容Android 9到Android 14,建议在AndroidManifest.xml里声明android:requestLegacyExternalStorage="true"(仅限targetSdkVersion 29时有效)。但别依赖这个,Android 11以后就不认了。最好的策略是:代码里做版本判断,不同API走不同分支

嗯,存储这块的适配确实繁琐,但核心逻辑其实不复杂:私有文件用getFilesDir,媒体文件用MediaStore,其他文件用SAF。记住这个原则,大部分场景都能搞定。我在做项目时,一般会封装一个StorageHelper工具类,把版本判断和API调用都包在里面,业务层只管调用,不用关心底层是哪个API。


公众号:蓝海资料掘金营,微信deep3321