背景
为了更好地管理和保护用户数据,Android 系统在 10.0 版本引入了分区存储机制。分区存储对应用访问外部存储的方式进行了限制和规范,同时也带来了一些适配问题。本文从分区存储的基本概念与核心原理出发,总结了适配过程中可能遇到的问题及相应的解决方案,如果读者也在进行分区存储适配工作,希望这篇文章能提供一些帮助,确保适配过程更为顺畅。
什么是分区存储
我们先来看一下官方的定义:为了让用户更好的控制自己的文件并减少混乱,Android 10 针对移动应用推出了一种新的存储范例,称为分区存储(Scoped Storage)。新的存储模式改变了应用在移动设备上外部存储空间的访问方式,在分区存储模式下,文件被分为两类,媒体文件和非媒体文件,且两类文件访问方式不同。
上述官方定义,总结来看有三个重点:
- 目的是让用户自行控制文件,减少混乱
- 分区存储针对的是外部存储,内部存储空间不受影响
- 媒体文件和非媒体文件访问方式不同
这三点内容可以辅助理解后面一些复杂的变更。
Android 存储空间
上面提到,分区存储针对的是设备外部存储空间,除了外部存储空间, Android 移动设备还有其它存储空间,这里介绍一下 Android 移动设备存储空间的划分。
通常情况下,Android 移动设备存储空间可以分为三个区域:外部存储空间、内部存储空间、系统存储空间,其中外部存储空间又分私有空间和公共空间,关于 Android 存储空间的详细内容,请参考下表总结:
Android 存储机制
在介绍了 Android 存储空间的特点之后,为了更好地理解分区存储,我们还需要深入探讨 Android 的存储机制。因为存储空间和存储方式的变更是分区存储的关键点,是理解该概念的基础。在 Android 系统中,常用的文件访问方式有三种:文件路径、MediaStore 和 Storage Access Framework(简称 SAF)。
使用文件路径访问
这是最常见的文件访问方式,在 Android 10 之前,大多数 APP 都是使用这种方式来管理文件。通过文件路径,我们可以直接使用 IO 流来读写文件。这种方法可以访问内部存储空间和外部存储空间。关于使用文件路径访问的具体方法,请参考下表总结。
使用 MediaStore 访问
MediaStore 是一个媒体库,它提供了经过优化的媒体集合索引,使用 MediaStore 可以更轻松的检索和更新媒体文件,即使应用已经卸载,这些文件仍会保留在用户的设备上。
关于 MediaStore 定义好的媒体集合总结在了如下表中:
MediaStore 基本原理:使用 Context 获取到 ContentResolver 对象,然后 ContentResolver 通过 Uri 即可取得各种媒体库的 ContentProvider,从而进行媒体文件的增删改查等基础操作。
我们用下面一段模版代码,演示一下如何使用 MediaStore 保存一张图片:
kotlin
fun savePicture(context: Context, file: File?) {
file?.let {
val values = ContentValues()
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
values.put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
values.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
val outputStream = context.contentResolver.openOutputStream(uri)
if (outputStream != null) {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
outputStream.close()
}
}
}
}
上述代码会将图片保存到公共媒体目录下,路径为:/sdcard/DCIM/childDir(不同手机可能会出现不一样的 Path,但都属于公共媒体目录)。
关于 MediaStore 权限说明
使用 MediaStore 管理自己应用创建的媒体文件,不需要权限,但如果要访问其它应用的共享文件需要申请读写权限。
使用 Storage Access Framework 访问
Android 4.4(API 级别 19)引入了 SAF 存储访问框架,借助 SAF 可轻松浏览和打开各种文档、图片及其他文件,而不用管这些文件来自哪一个应用程序。用户可通过易用的标准界面,跨所有应用和提供统一的方式浏览文件并访问最近用过的文件,使用 SAF 框架不需要申请任何权限。
SAF 的使用比较简单不过多介绍,下面以打开文件为例演示一下用法:
scss
//通过系统的文件浏览器选择一个文件
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
//这里可以过滤文件类型
//intent.setType("image/*");//过滤只显示图像类型文件
startActivityForResult(intent, FILE_CODE);
上述代码执行后,会启动如下文件管理的界面,用户可以选择一个文件,确认后系统会将文件信息返回。
然后使用如下代码处理返回的文件信息:
java
private final String[] PROJECTION = {
MediaStore.Images.Media.DISPLAY_NAME
};
@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
if (requestCode == FILE_CODE && resultCode == Activity.RESULT_OK) {
Uri uri = null;
if (resultData != null) {
// 获取选择文件Uri
uri = resultData.getData();
// 获取到文件信息在 uri 里面,只需要使用 ContentResolver 读取即可
Cursor cursor = this.getContentResolver()
.query(uri, PROJECTION, null, null, null, null);
}
}
}
分区存储适配方案
分区存储带来了哪些变化?
- 分区存储将外部存储分成了应用专属目录和公共目录两部分,公共目录不允许直接使用路径访问,可以使用 MediaStore 或 SAF 访问
- 分区存储将文件分为媒体文件和非媒体文件,且访问方式不同
下面针对以上两点变化介绍一下详细的适配方案。
处理媒体文件
在分区存储模式下,媒体文件允许存储到应用私有存储空间或共享存储空间中,共享存储空间指的是系统定义好的媒体目录,包括 DCIM、Pictures、Movies、Music、Download 等,应用中访问媒体文件也是比较常见的场景,例如拍照保存图片、访问图库等。但无论场景,分区存储模式下媒体文件的适配方案都是一致的,主要分为以下两步:
- 使用媒体目录存放媒体文件
- 使用 MediaStore 管理媒体文件
特别说明:Android 11 及以上系统,共享空间下媒体文件仍然可以使用路径访问,但这种做法并不推荐,后面会做详细的说明。
我们用下面一段模版代码,演示一下分区存储模式下拍照保存图片的实现方案。
arduino
public static File createTmpFile(Context context) {
//使用媒体目录存储媒体文件
dir = context.getExternalFilesDir(DIRECTORY_PICTURES);
//创建临时文件并返回
return File.createTempFile(JPEG_FILE_PREFIX, JPEG_FILE_SUFFIX, dir);
}
scss
//打开系统相机拍照
private fun openCamera() {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
if (intent.resolveActivity(requireActivity().packageManager) != null ) {
mCameraFile = createTmpFile(activity)
if (mCameraFile != null && mCameraFile!!.exists()) {
val cameraFileUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
FileProvider.getUriForFile(requireContext() , requireContext().packageName+ ".fileprovider" , mCameraFile!!)
} else {
Uri.fromFile(mCameraFile)
}
intent.putExtra(MediaStore.EXTRA_OUTPUT , cameraFileUri)
startActivityForResult(intent , PHOTO_FROM_CAMERA_REQUEST_CODE)
}
}
}
scss
//使用 MediaStore 保存图片
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues()
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
values.put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
values.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
val outputStream = context.contentResolver.openOutputStream(uri)
if (outputStream != null) {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
outputStream.close()
}
}
}
上述代码会使用系统相机将拍照后的图片存到共享 Pictures 目录下。
处理非媒体文件
在分区存储模式下,非媒体文件的主要变更是,不允许直接使用路径将非媒体文件存储在外部空间中,应用私有目录不受影响,这意味着 getExternalStorageDirectory() 和 getExternalStoragePublicDirectory() 这两个获取公共目录的方法会抛异常,非媒体文件的外部存储可以使用 SAF 框架,因此非媒体文件的适配有如下两个方案:
- 将公共存储目录改成应用私有目录
- 使用 SAF 框架管理非媒体文件
大多数应用都有应用内升级的功能,通常实现方案是将 APK 文件下载到外部公共目录下,下载完成后调用系统安装器安装新的 APK,但 APK 属于非媒体文件,直接使用路径将文件存储到外部公共目录下会抛异常,修改方案把公共目录改成私有目录,然后使用 FileProvider 对外提供即可,下面以应用升级为例演示一下非媒体文件如何适配:
kotlin
private fun startDownload(fileUrl: String, fileName: String, context: Context) {
//使用应用私有空间存储下载的文件
val file = new File(context.getExternalFilesDir(), fileName);
//省略部分下载代码...
DownloadTask.Builder(fileUrl, file)
.setFilename(fileName)
.start()
}
//下载完成后,用 FileProvider 将 APK 文件提供给系统安装器,模版代码如下
fun installApk(apkPath: String, context: Context) {
val file = File(apkPath)
if (file.exists()) {
val apkUr = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.N -> Uri.fromFile(file)
else -> FileProvider.getUriForFile(context, String.format("%s.provider", context.packageName), file)
}
context.startActivity(Intent().apply {
action = "android.intent.action.VIEW"
addCategory("android.intent.category.DEFAULT")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setDataAndType(apkUr, "application/vnd.android.package-archive")
} )
}
}
直接使用文件路径访问媒体文件
上文也提到了,分区存储模式下 Android 11 及以上系统媒体文件还是允许直接使用路径访问,这里介绍下怎么使用以及有哪些注意事项。我们先来看一下官方的说明:
为了帮助您的应用更顺畅地使用第三方媒体库,Android 11(API 级别 30)及更高版本允许您使用
MediaStore
API 以外的 API 来访问共享存储空间中的媒体文件,当您使用直接文件路径读取媒体文件时,其性能与MediaStore
API 相当。但是,当您使用直接文件路径随机读取和写入媒体文件时,进程的速度可能会慢一倍。在此类情况下,我们建议您改为使用MediaStore
API。
由于 Android 11 及更以上版本强制开启了分区存储,官方考虑到有很多应用和三方媒体库深度绑定,难以完全使用 MediaStore 进行切换,因此 Android 11 及更高版本又允许了直接使用路径访问共享目录下的媒体文件。这些共享目录在 MediaStore 内部类中都可以找到定义好的 Uri,下表总结了文件类型与目录的存储关系。
注意事项
- DCIM、Pictures、Movies、Music 这几个目录使用路径访问的情况下,只能存放媒体类型的文件,不能存放其它类型的文件,例如将 TXT 文件存放到 DCIM 目录中,系统会抛异常。媒体目录中的文件,应用卸载后存储的文件不会被自动删除
- MediaStore 中的 Download 是 Android 10 新增目录,仅在 Android 10 及以上系统才有,这个目录可以存放任意类型的文件,应用卸载后文件不会被自动删除
应用兼容模式
当然我们也可以不适配分区存储,让我们的应用继续以兼容模式运行,但是不推荐这么做,因为 Android 致力于提高用户隐私和安全,系统版本会一直持续更新,如果应用以兼容模式运行会出现无法上架应用市场的风险。
分区存储兼容模式有以下两种实现方法:
- targetSdkVersion 永远小于等于 28(Android 9)
- targetSdkVersion 等于 29(Android 10)可以在 manifest 文件中添加 requestLegacyExternalStorage = true,禁用分区存储,代表应用以兼容模式运行,模版代码如下:
xml
<manifest ... >
<application android:requestLegacyExternalStorage="true" ... >
...
</application>
</manifest>
特别说明:targetSdkVersion 一旦大于等于 30(Android 11),当您的应用运行在 Android 11 及以上系统时 requestLegacyExternalStorage 会失效,因为 Android 11 及以上版本强制开启了分区存储。
管理存储设备上的所有文件
分区存储限制了外部空间的访问,但如果我们开发的是文件选择器的应用或者是防病毒的应用,它是需要访问设备所有文件的,这种情况下让用户反复去选择就是一件非常糟糕的体验,为此 Android 官方提供了一种名为"所有文件访问权"的特殊权限:MANAGE_EXTERNAL_STORAGE。
如何申请 MANAGE_EXTERNAL_STORAGE 权限?
应用可通过执行以下操作向用户请求"所有文件访问权":
- 在清单中声明 MANAGE_EXTERNAL_STORAGE 权限
- 使用 ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION intent 操作将用户引导至一个系统设置页面,在该页面上,用户可以为您的应用启用改权限
检查您的应用是否已获得 MANAGE_EXTERNAL_STORAGE 权限,请调用 Environment.isExternalStorageManager()。
ini
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
if (!Environment.isExternalStorageManager()) {
//true : 应用以兼容模式运行;
//false:应用以分区存储特性运行
Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
startActivity(intent);
}
运行效果如下图:
获取 MANAGE_EXTERNAL_STORAGE 权限后可以执行哪些操作?
- 对共享存储空间中的所有文件的读写访问,注意 /sdcard/Android/media 目录是共享空间的一部分
- 对 MediaStore.Files 表的内容的访问
- 对 SD 卡的根目录的访问
- 除 /Android/data 、/sdcard/Android 、/sdcard/Android 的大多数子目录外,对所有内部存储目录的写入权限,该写入权限包括文件路径访问
- 获得该权限的应用仍然无法访问属于其他应用的应用私有目录,因为这些目录在存储卷上显示为 /Android/data 的子目录
当应用具有 MANAGE_EXTERNAL_STORAGE 权限时,它可以使用 MediaStore API 或文件路径访问这些文件和目录。
MANAGE_EXTERNAL_STORAGE 权限的特别说明
Google 对 MANAGE_EXTERNAL_STORAGE 权限的管理非常严格,只有特殊类型的几种应用才允许申请,例如防病毒应用、文件管理器应用等,如果我们在工程中声明了 MANAGE_EXTERNAL_STORAGE,开发者工具会提示如下警告:
Google Play 更新了关于 MANAGE_EXTERNAL_STORAGE 的隐私政策,如果应用不符合官方指定类型,将无法上架应用市场,因此如非必要不要在工程中申请 MANAGE_EXTERNAL_STORAGE 权限。
适配的两点建议
- 对于应用自身产生的新文件应该存放在自己的私有目录下,访问这两个目录无需申请权限,其它应用也无法直接访问,遵循官方的指导意见,让每个文件出现在最适合它的地方
- 对于应用自身的旧文件,建议尽快完成迁移,媒体文件统一迁移到 meida 集合目录中,非媒体文件迁移至私有目录中
总结
以上就是对分区存储的全部介绍,本文中所有结论性观点都在 Google Pixel 设备上进行过验证,由于 Android 是一个开源的操作系统,可能会因设备型号的不同而有所差异,但这些差异与文章内容并不矛盾,官方适配方案适用于所有搭载了 Android 系统并启用了分区存储的智能设备。
为了方便大家理解本文内容,文中涉及的 Android 存储空间、存储机制、版本差异、适配方案等核心概念都已汇总在下图中。希望大家通过这张图可以更好地掌握 Android 分区存储的相关知识。
分区存储带来了更灵活的存储方式和更安全的数据管理,未来将会提供更加便捷的跨设备文件共享功能。例如,通过云存储和文件同步技术,实现不同设备之间的文件自动备份和共享,因此分区存储带来的细节问题也比较多,但无论什么场景下,我们只需要掌握其基本原理,就可以轻松的理解其使用方式,进而在各种场景下灵活应用。
还有一件事
雪球研发 2024 年届校园招聘已经正式启动,现公开招募成都校招岗位,详情见下图:
前辈带教:专人带教,加速融入,工作生活"万事通";
培训分享:线上线下全技术栈覆盖,海量资源供随时查阅;
即时激励:季度、项目和专利等即时激励政策,为每一个进步喝彩;
人文关怀:专属工程师的「雪球爱码士节」,关注身心,给工作加糖。
欢迎自荐&推荐,简历直达,承诺 24 小时反馈!让雪球研发部成为你的人生助力,期待您的加入~
投递链接:app.mokahr.com/campus_appl...
参考文档
developer.android.google.cn/about/versi...
developer.android.google.cn/training/da...
developer.android.google.cn/training/da...