一文读懂 Android 分区存储

背景

为了更好地管理和保护用户数据,Android 系统在 10.0 版本引入了分区存储机制。分区存储对应用访问外部存储的方式进行了限制和规范,同时也带来了一些适配问题。本文从分区存储的基本概念与核心原理出发,总结了适配过程中可能遇到的问题及相应的解决方案,如果读者也在进行分区存储适配工作,希望这篇文章能提供一些帮助,确保适配过程更为顺畅。

什么是分区存储

我们先来看一下官方的定义:为了让用户更好的控制自己的文件并减少混乱,Android 10 针对移动应用推出了一种新的存储范例,称为分区存储(Scoped Storage)。新的存储模式改变了应用在移动设备上外部存储空间的访问方式,在分区存储模式下,文件被分为两类,媒体文件和非媒体文件,且两类文件访问方式不同。

上述官方定义,总结来看有三个重点:

  1. 目的是让用户自行控制文件,减少混乱
  2. 分区存储针对的是外部存储,内部存储空间不受影响
  3. 媒体文件和非媒体文件访问方式不同

这三点内容可以辅助理解后面一些复杂的变更。

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);
        }
    }
}

分区存储适配方案

分区存储带来了哪些变化?

  1. 分区存储将外部存储分成了应用专属目录和公共目录两部分,公共目录不允许直接使用路径访问,可以使用 MediaStore 或 SAF 访问
  2. 分区存储将文件分为媒体文件和非媒体文件,且访问方式不同

下面针对以上两点变化介绍一下详细的适配方案。

处理媒体文件

在分区存储模式下,媒体文件允许存储到应用私有存储空间或共享存储空间中,共享存储空间指的是系统定义好的媒体目录,包括 DCIM、Pictures、Movies、Music、Download 等,应用中访问媒体文件也是比较常见的场景,例如拍照保存图片、访问图库等。但无论场景,分区存储模式下媒体文件的适配方案都是一致的,主要分为以下两步:

  1. 使用媒体目录存放媒体文件
  2. 使用 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 框架,因此非媒体文件的适配有如下两个方案:

  1. 将公共存储目录改成应用私有目录
  2. 使用 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,下表总结了文件类型与目录的存储关系。

注意事项

  1. DCIM、Pictures、Movies、Music 这几个目录使用路径访问的情况下,只能存放媒体类型的文件,不能存放其它类型的文件,例如将 TXT 文件存放到 DCIM 目录中,系统会抛异常。媒体目录中的文件,应用卸载后存储的文件不会被自动删除
  2. MediaStore 中的 Download 是 Android 10 新增目录,仅在 Android 10 及以上系统才有,这个目录可以存放任意类型的文件,应用卸载后文件不会被自动删除

应用兼容模式

当然我们也可以不适配分区存储,让我们的应用继续以兼容模式运行,但是不推荐这么做,因为 Android 致力于提高用户隐私和安全,系统版本会一直持续更新,如果应用以兼容模式运行会出现无法上架应用市场的风险。

分区存储兼容模式有以下两种实现方法:

  1. targetSdkVersion 永远小于等于 28(Android 9)
  2. 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 权限?

应用可通过执行以下操作向用户请求"所有文件访问权":

  1. 在清单中声明 MANAGE_EXTERNAL_STORAGE 权限
  2. 使用 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 权限。

适配的两点建议

  1. 对于应用自身产生的新文件应该存放在自己的私有目录下,访问这两个目录无需申请权限,其它应用也无法直接访问,遵循官方的指导意见,让每个文件出现在最适合它的地方
  2. 对于应用自身的旧文件,建议尽快完成迁移,媒体文件统一迁移到 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...

developer.android.google.cn/training/da...

developer.android.google.cn/training/da...

相关推荐
╰つ゛木槿3 分钟前
深入了解 React:从入门到高级应用
前端·react.js·前端框架
m0_7482561419 分钟前
Android 关于Tencent vConsole 添加入webView 总结
android
m0_7482412337 分钟前
ElasticPDF-新国产 PDF 编辑器开发框架(基于 pdf.js Web PDF批注开发,实现高亮多边形橡皮擦历史记录保存注释文字)
前端·pdf·编辑器
开发者阿伟1 小时前
Android Jetpack DataBinding源码解析与实践
android·android jetpack
huapiaoy1 小时前
JavaSE---String(含一些源码)
java·linux·前端
rkmhr_sef1 小时前
frp内网穿透云服务器。云服务器映射多个家庭局域网内网端口。家庭Windows主机内网运行多个web程序
服务器·前端·windows
带多刺的玫瑰1 小时前
Leecode刷题C语言之考场就座
c语言·前端·javascript
麦子爱种地1 小时前
前端学习DAY26(华为平板页面)
服务器·前端·javascript
m0_748248021 小时前
HTML5前端实现毛玻璃效果的可拖拽登录框
前端·html·html5