一 分区存储概述
Android 10之前,Android的文件存储现象就像个垃圾桶,但凡app取得了存储空间的读写权限
WRITE_EXTERNAL_STORAGE
,就可以肆意创建文件,难以管理。用户体验也特别差,打开文件管理器,会发现,想找个具体的文件根本无从下手。
1.1 分区存储原则
为了更好地管理自己的文件并减少混乱,加强隐私保护,Android Q开始引入了分区存储机制。外部存储空间被重新设计,按应用私有和公用共享划分。应用只能访问到自己私有空间,或者通过MediaStore API 和Storage Access Framework去访问共享的资源目录。
分区存储主要遵循了三大原则对文件存储进行重新设计:
-
记录文件来源:系统会记录文件由哪个应用创建,应用不需要权限就可以对自己创建的文件进行读写;
MediaStore数据库增加
owner_package_name
字段记录文件属于哪个应用, 应用卸载后owner_package_name
字段会置空,也就是说,卸载重装后,之前创建的文件,已不属于应用创建的了,需要相关存储权限才能再次读写 -
应用数据的保护:对外部存储空间进行了访问限制,应用只能访问自身的私有空间或共享空间,即使获得了读写权限,也是无法访问其他应用的私有空间的;
-
用户数据保护:当用户下载了一些文件,比如带有敏感信息的邮件附件,这些文件应该对其他应用不可见。添加了pdf、office、doc等文件的访问限制,用户即使申请了存储权限也不能通过MediaStore访问其他应用创建的pdf、office、doc等文件,需要通过Storage Access Framework 框架,由用户参与选择,才能获得访问权限
Android 系统的版本越新,就越依赖于文件的用途而不是位置来确定应用对文件的访问能力
1.2 关于存储方式的兼容和判断
- 当targetSdk <= 28时,应用使用传统存储方式;
- 当targetSdk <= 29时,可以通过在应用清单的application标签中添加
android:requestLegacyExternalStorage="true"
,从而关闭分区存储功能,继续使用传统访问方式。 - 当targetSdk>>=30时,Android会强制执行分区存储,无法关闭。
- 可以通过
Environment.isExternalStorageLegacy()
判断应用存储的运行方式,true表示以传统的兼容方式运行,false表示以分区存储运行
注意:当修改了requestLegacyExternalStorage属性的值,必须要卸载掉旧APK,重新安装才会生效
二 存储空间的影响
Android 提供了两类物理存储位置:内部存储空间和外部存储空间。在大多数设备上,内部存储空间小于外部存储空间。不过,所有设备上的内部存储空间都是始终可用的,因此在存储应用所依赖的数据时更为可靠。
可移除卷(例如 SD 卡)在文件系统中属于外部存储空间。空间较大,现在的智能机基本都配有,但为了兼容性,也可在使用相关api时检查该空间是否处于可用状态。Environment.getExternalStorageState()
// 是否可读写
fun isExternalStorageWritable(): Boolean {
return Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
}
// 是否可读
fun isExternalStorageReadable(): Boolean {
return Environment.getExternalStorageState() in
setOf(Environment.MEDIA_MOUNTED, Environment.MEDIA_MOUNTED_READ_ONLY)
}
在写入存储之前,需要知道设备有多少空间可用,不够的话会抛出异常。不过现在的智能设备内存也是比较大的,这部分可以参考Google 查询可用空间
2.1 内部存储空间
打开Android studio的 Device File Explorer时,可以看到应用的内部空间目录:/data/data/包名/
内部存储空间本身便是保护应用隐私设计的。这部分是没有变化的。应用不需要任何系统权限即可读取和写入这些目录中的文件。其他应用无法访问存储在内部存储空间中的文件。
内部存储空间为应用提供目录。一个目录专为应用的持久性文件而设计,而另一个目录包含应用的缓存文件。内部存储空间是应用专属的,是可以正常使用File
相关api的,所以只要取得路径便可自由发挥:
-
持久性文件根目录File:
context.filesDir()
,/data/data/包名/files/ -
缓存性文件根目录File::
context.cacheDir()
,/data/data/包名/cache/
android也提供了一些简便的api创建删除文件:
context.openFileOutput(filename, Context.MODE_PRIVATE)
、context.openFileInput(filename)
、context.fileList()
、context.getDir(dirName, Context.MODE_PRIVATE)
、context.delefteFile(fileName)
注意:卸载app后,系统会自动移除这些目录释放空间!!
2.2 外部存储空间
/storage/emulated/0/Android/data/包名
Android 10的分区存储特性,对Android系统的外部存储空间重新设计,外部存储被分为应用私有目录以及共享目录两个部分:
- 应用私有目录:存储应用私有数据,外部存储应用私有目录对应Android/data/包名
- 共享目录:存储其他应用可访问文件, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms, Music, Notifications,Podcasts, Ringtones、Movies、Download等目录。
2.2.1 应用私有空间
与以往相同的是,访问自身的外部存储下的应用私有空间是不需要任何权限的。与内部一样,也有一个目录专为应用的持久性文件而设计,和另一个目录包含应用的缓存文件。也是可以正常使用File
相关api的,所以只要取得路径便可自由发挥。
需要注意的不同点是:开启了分区存储特性后,应用只能访问自身的私有空间,即使获得了存储权限,也无法访问其他应用的私有空间
另外与内部空间的区别是,外部存储空间可能被移除也可能有多个,所以返回的是一个数组,对于返回数组中的第一个元素被视为主外部存储卷。除非该卷已满或不可用,否则请使用该卷。
- 持久性文件:
getExternalFilesDirs(@NonNull Context context, @Nullable String type)
,type根据文件类型可传系统预定义的子目录常量,如图片Environment.DIRECTORY_PICTURES
,此时返回/storage/emulated/0/Android/data/包名/files/Pictures
。或者传null直接返回/storage/emulated/0/Android/data/包名/files
- 缓存性文件:
ContextCompat.getExternalCacheDirs(context)
,/storage/emulated/0/Android/data/包名/cache
注意:卸载app后,系统会自动移除这些目录释放空间!!
三 共享存储空间的影响
如果用户数据可供或应可供其他应用访问,并且即使在用户卸载应用后也可对其进行保存,请使用共享存储空间。
共享文件类型, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms, Music, Notifications,Podcasts, Ringtones、Movies、Download等目录。Android 分别提供用于获得该类型可共享数据文件Uri的 API:
-
媒体内容:可以使用
MediaStore
API 访问此内容 -
文档和其他文件:系统有一个特殊目录,用于包含其他文件类型,例如 PDF 文档和采用 EPUB 格式的图书。应用可以使用
Storage Access Framework
访问这些文件。
对于共享文件,。以往可以通过data column
获得路径,再使用File API来操作,现在都会返回失败。开启了分区存储特性之后,应用只能通过系统提供的api来向系统请求得到对应文件的Uri
,并通过Uri
生成FileDescriptor
和InputStream
等方式进行文件读写:(简而言之,对于共享文件的增删查改,主要问题在于Uri的获取)
注:android 11 又允许通过路径来访问,系统会自动重定向为Uri。
val resolver = applicationContext.contentResolver
//读
resolver.openFileDescriptor(content-uri, "r")?.use { pfd ->
val inputStream = FileInputStream(pfd.fileDescriptor)
}
resolver.openInputStream(content-uri).use { stream ->
}
//写
resolver.openFileDescriptor(content-uri, "w")?.use { pfd ->
val outputStream = FileOutputStream(pfd.fileDescriptor)
}
resolver.openOutputStream(content-uri).use { stream ->
}
//图片bitmap
BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor)
3.1 MediaStore API
MediaStrore API的增删查改,可参看Google官方指南,主要是通过contentResolver获得对应的uri,这里就不引入了。图片来源
3.1.1 MediaStore 概述
Android系统会自动扫描外部存储空间,将媒体文件按类型添加到系统预定义的Images、Videos、Audio files、Downloaded files集合中。Android Q通过MediaStore.Images、MediaStore.Video、MediaStore.Audio、MediaStore.Downloads 访问相对应共享目录文件资源。预定义集合所对应的目录如下表所示:
媒体类型 | Uri | 默认创建目录 | 允许创建目录 |
---|---|---|---|
Image | content://media/external/images/media | Pictures | DCIM,Pictures |
Audio | content://media/external/audio/media | Music | Alarms,Music,Notifications,Podcasts,Ringtones |
Video | content://media/external/video/media | Movies | DCIM,Movies |
Download | content://media/external/downloads | Download | Download |
注意:MediaStore.Downloads.EXTERNAL_CONTENT_URI
是Android10版本新增API,用于创建、访问非媒体文件
3.1.1 MediaStore 的变化
- MediaStore API在共享目录指定目录下创建文件或者访问应用自己创建文件,不需要申请存储权限;
- MediaStore API访问其他应用在共享目录创建的媒体文件(图片、音频、视频), 需要申请存储权限,未申请存储权限,通过ContentResolver查询不到文件Uri,即使通过其他方式获取到文件Uri,读取或创建文件会抛出异常;
- MediaStore API不能够访问其他应用创建的非媒体文件(pdf、office、doc、txt等), Android 10 里唯一一种访问其他应用创建的非媒体文件的途径是使用存储访问框架 (Storage Access Framework) 提供的文档选择器。
3.1.2 通过api创建的文件存放到哪里?如何自定义位置?
当通过MediaStore API
创建文件时,文件会默认保存到对应的类型目录,比如图片存到Pictures/
目录下,可以往上查看表格的默认目录及允许目录;
可以使用MediaStore.xxx.Media.RELATIVE_PATH
自己指定要存放的目录或者子目录,如:contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/自定义子目录")
,文件就会放在Pictures/自定义子目录/
中;或者使用contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment. DIRECTORY_DCIM)
,将文件放到DCIM/
中
注意:每一种类型都有对应的可允许创建的目录,否则会返回失败。具体可创建目录可以往上查看表格
3.2 Storage Access Framework
SAF框架支持用户与系统选择器互动,从而选择文档提供器以及供您的应用创建、打开或修改的特定文档和其他文件。由于用户参与了文件的选择,因此该机制无需任何系统权限。
应用通过调用 ACTION_CREATE_DOCUMENT
, ACTION_OPEN_DOCUMENT
, 和ACTION_OPEN_DOCUMENT_TREE
Intent获取Document provider提供的文件,并在onActivityResult接口接收返回的被选择文件的Uri。另外,在配置 intent 时,应指定文件的名称和 MIME 类型,并且还可以根据需要使用 EXTRA_INITIAL_URI
intent extra 指定文件选择器在首次加载时应显示的文件或目录的 URI。
这部分也是没变化的,可参考官方指南:从共享存储空间访问文档和其他文件
3.2.1 获取持久权限
对于通过SAF框架获得的uri权限,可以通过申请持久权限,不用每次重启手机都要重新请求。
contentResolver.takePersistableUriPermission(
documentUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
四 存储特性Android版本差异概览
4.1 其他变化:图片位置信息
一些照片在元数据中包含位置信息,以便用户查看照片的拍摄地点。由于此位置信息属于敏感信息,如果应用使用了分区存储,默认情况下 Android 10 会对应用隐藏此信息。
如果应用需要访问照片的位置信息:
- 在应用清单中请求
ACCESS_MEDIA_LOCATION
权限 - 通过调用
setRequireOriginal()
,从MediaStore
对象获取照片的确切字节,并传入照片的 URI
五 更新
5.1 Android 10 的分区存储"bug"
Android 10 通过媒体MediaStore API 删除(delete)一个媒体文件,只是简单移除了MediaStore数据库的索引,并不会真正删除物理存储上的实体文件,而且只要手机重启,则索引又被加上去了。issue
这需求也比较少见,只是刚好测试发现了。网上查了下,的确存在这个问题,Android 11 就可以正常删除了。 要是有什么解决方案,热烈欢迎指出!!
5.2 Android 11 的存储变更
5.2.1 允许继续使用原始文件路径
5.2.2 增加批量操作
在 Android 10 中,应用在对MediaStore的每一个文件请求编辑或删除时都必须一个个地得到用户的确认。而在 Android 11 中,应用可以一次请求修改或者删除多个媒体文件。
主要通过以下新增的批量操作api
方法 | 说明 |
---|---|
MediaStore.createDeleteRequest (resolver, uris) | 批量删除(不放入回收站) |
MediaStore.createFavoriteRequest(resolver, uris) | 批量收藏 |
MediaStore.createTrashRequest (resolver, uris) | 批量移入回收站 |
MediaStore.createWriteRequest(resolver, uris) | 批量获得写入权限 |
val uris = ...
val pi = MediaStore.createWriteRequest(contentResolver,
uris)
startIntentSenderForResult(pi.intentSender, REQUEST_CODE, null, 0, 0, 0)
//相应
override fun onActivityResult(xxx) {
when (requestCode) {
REQUEST_CODE ->
if (resultCode == Activity.RESULT_OK) {
//获得权限,继续操作
} else {
// 用户拒绝了权限授予
}
}
}
推荐学习视频
大家如果还想了解Android 相关的更多知识点可以点进我的【GitHub】项目中,里面记录了许多的Android 知识点。
网友评论