1 背景
在Android 10之前,绝大部分的应用只需要动态申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限即可在SD卡上新建属于自己的专属文件夹。应用即使被卸载,该文件夹下的文件也不会被清除。
从用户角度来说,如果每个App都这么干,那么整个文件系统就非常的混乱,同时可用空间也越来越小(作为开发者,我也觉得不友好~)。
因此,从Android 10开始,引入了分区存储概念:Scoped Storage。
手机的外部存储被分为了两个部分: 私有目录App-specific和公共目录。这样做的目的是规范开发者对外部存储的使用,解决上述痛点。
第二篇更多是在项目中的实践: Android 10 和Android 11 适配采坑 实践篇
2 分区存储的概念
2.1 到底什么是分区存储?
顾名思义,就是把外部存储空间分为两个区域:
- App-specific
- 公共目录
2.1.1 App-specific
也称为App外部私有目录。特点是随着卸载会被清除。 但是可以通过在应用的application标签中加入属性:
android:hasFragileUserData="true"
true: 表示弹出对话框
默认为false,不弹出。
在用户卸载的时候,会弹出会话框,询问用户是否保留该应用的数据?这样把是否清除数据的选择权交给了用户!
与外部私有目录对应的是内部私有目录。 内部私有目录只能应用自己访问,且随着卸载被清除。路径如下:
访问方式:
- app无需申请存储权限可以直接访问
- 通过SAF或者FileProvider的方式分享给第三方引用
2.1.2 公共目录
包含:Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones等。 应用卸载,这些目录下的文件不会被清除。
访问方式:
- 媒体类型文件:音频、视频、图片。通过MediaStore/SAF
- 非媒体类文件:pdf/documents 。通过SAF
SAF: Storage Access Framework。 一般是文件管理类应用使用。
2.1.3 总结
说了这么多,一开始可能不好理解,那么来张图肯定更清晰一些。 图片来自网络,侵删:
说明: 分为左右两个部分:不可移除的存储和可以移除的存储。右边一般是指可移除的外置存储卡,如TF卡 。一般情况下,我们都是面对左边的情况。 左边又分为三个部分:
Internal Storage:
内部私有目录。应用的私有目录,如通过getCacheDir()
和getFileDir()
获得。Primary External Storage:
主要外部存储目录。分为两个部分:
App-specific
外部app私有目录。app可直接访问,其他app不能直接访问。只能通过FileProvider
或者SAF
分享给外部public directory:
外部公共目录。对应Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones
等。可通过mediaStore
或ASF
来访问。
Rmovable External Storage
可移除的外部存储 外置存储卡,如 tf/sd 卡。管理策略跟第二点一样。
注意:公共目录的资源在Android10版本上不能直接通过File格式访问,Google建议通过fd,但是在Android11后可以通过File 直接访问。Google觉得影响太大了。。。
- 使用直接文件路径和原生库访问文件
- 为了帮助您的应用更顺畅地使用第三方媒体库,Android 11 允许您使用除 MediaStore API 之外的 API 通过直接文件路径访问共享存储空间中的媒体文件。其中包括:File API
3 为什么我们必须要适配
- 当你的
targetSdkVersion
设置为 <=28:
当你的App运行在<=28 版本上,还是按照原来的存储方式运行。 当你的App运行在>=29 版本上,系统会启动兼容性行为,以确保还是按照你所期望的方式运行。
尽管Android10开始分区存储。但是系统会根据你设定的targetSdkVersion版本以一种兼容的方式来运行你的App。
- 当你的
targetSdkVersion
设置为 >=29:
当你的App运行在<=28 版本上,还是按照原来的存储方式运行。
当你的App运行在>=29 版本上,系统默认采用分区存储方式运行。但是我们可以在application标签中设置 :
android:requestLegacyExternalStorage="true"
true: 表示以原来的存储方式
false:表示采用分区存储方式
注意: 该标志仅仅是过渡时期的临时方案。 会在Android R(11) 版本失效。
同时Android R(11) 又新增了一个标志: preserveLegacyExternalStorage。
true: 覆盖安装的情况下,还是一旧的存储方式。卸载安装则以分区方式进行。
总结:
-
targetSdkVersion保持低于29 你可以一直不升级
targetSdkVersion
,让它保持在<=28,但是Google是会强制你升级。不仅仅是分区存储,Androidx升级等都需要你去升级。 -
采用临时方案 但这也是临时的啊。。 饮鸩止渴了属于是。
-
老老实实的分区存储吧 那么怎么去适配,往下看。
4 怎么去适配
4.1 适配影响到的场景:
1,无法在SD卡新建文件夹 原因: 除了App-specific外部私有目录,其他的文件目录访问会失败。 解决方案: 在外部私有目录创建 或者通过MediaStore在公共目录创建。
2,无法直接访问公共目录的文件 原因:除了App-specific外部私有目录,其他的文件目录访问会失败。
问题: 使用MediaStore接口访问公共目录中的多媒体文件,或者使用 SAF访问公共目录中的任意文件。
注意:从MediaStore接口中查询到的DATA字段将在Android Q开始废弃,不应该利用它来访问文件或者判断文件是否存在;
从 MediaStore接口或者SAF获取到文件Uri后,请利用Uri打开FD 或者输入输出流,而不要转换成文件路径去访问。
问题原因2:使用MediaStore接口访问非多媒体文件。
问题分析2:在Android Q上,使用MediaStore接口只能访问公共目录中的多媒体文件。
3,无法正确分享
如微信、qq的分享在Android 11上失败问题。 原因: 分享App-specific目录下的文件,是以File:// 开头的格式的Uri。而该目录对其他的App是无法访问的。 解决方案:因此,只能通过FileProvide的方式,以content:// 格式分享给其他App。
4.1 存储适配
适配思路:
- 发布一个兼容版本,在应用启动的时候,如果大于29且没有开启分区存储的时候,就把之前文件夹中的旧数据拷贝到沙盒中(可选,可不做)。
- 根据版本>=29&&Environment.isExternalStorageLegacy=false判断是否启用了分区存储。
- 如果不启用,则继续原来的兼容方式
- 如果启动,则返回沙盒目录。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !Environment.isExternalStorageLegacy()) {
sdCardDir = CoreValue.gContext.getExternalFilesDir(null);
} else {
sdCardDir = Environment.getExternalStorageDirectory();
}
// 一般以sdCardDir作为一级目录,让后在分建不同子目录来存储。
MediaStore & SAF的用法参考:
4.2 分享适配
4.2.1 分享适配原理
本质上是Android系统为了保护你的目录不被其他应用随意访问。因此,如果App本身的私有目录下的文件(如:图片)需要分享给第三方的应用(如:qq、微信等) ,那么需要通过FileProvider的方式来实现。
FileProvider
继承自ContentProvder
类。它的作用是把一个file://
类型的Uri转换为content://
类型的Uri,这样就确保了安全分享。
为什么呢?
原来content://
类型的Uri,只是赋予第三方app一个临时的使用权限。我们一般是通过Intent
去唤起第三方app的分享页面,当页面销毁后那么第三方的app就没有权限去访问我们的私有目录啦。
如果是唤起service
,那么service
也只有处于running
的状态下才会有临时权限。
而file://
类型的Uri,基本上是永久的赋予了第三方app访问我们私有目录的权限,这是非常不安全的!
所以,我们需要去适配:
- 在清单文件中添加provider节点信息:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
- 添加res/xml/file_paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- root-path:/ 表示的是整个设备的根目录。当你没有sd卡的时候,如果不配置则会报错的哦-->
<root-path name="adcard_root" path="." />
<!-- external-path:Environment.getExternalStorageDirectory() 这个是sd卡的根目录,-->
<!-- external-path:/storage/emulated/0/test/或者/sdcard/test/-->
<external-path name="files5" path="test/"/>
<!-- external-files-path : /storage/emulated/0/Android/data/com.demo.test/files/-->
<!-- . 表示当前目录下的所以目录。-->
<external-files-path name="files1" path="." />
<!-- external-files-path : /storage/emulated/0/Android/data/com.demo.test/files/test/-->
<external-files-path name="files2" path="test/" />
<!-- external-files-path : /storage/emulated/0/Android/data/com.demo.test/cache/test/-->
<external-cache-path name="files3" path="test/"/>
<!-- context.getExternalMediaDirs() 这个路径没测试。。-->
<external-media-path name="files4" path="test/"/>
<!--/data/user/0/com.demo.test/cache/test/ 或者 /data/data/com.demo.test/cache/test/-->
<cache-path name="files6" path="test/"/>
<!--/data/user/0/com.demo.test/files 或者 /data/data/com.demo.test/files/test/-->
<files-path name="files7" path="test/"/>
</paths>
必须要解释下上面的配置信息。其中需要注意:
- root-path:表示整个设备的根目录,因此,一般你配了这个其他就可以不用管了,包括了sd卡和没有sd卡的情况。
- external-path:表示sd卡的根目录。
如果为了省事可以直接配置以上两个即可覆盖全部情况了。不过为了安全起见,还是建议针对每个目录单独配置。
name、path和path="."的含义
- name是path的别名,所以取个能看懂的名字即可,没什么卵用。
- path是子目录。也就是当前目录下你要分享的子目录。
- path="." .表示该目录的所以子目录
举个例子: 假设你的图片存储在:/storage/emulated/0/Android/data/com.demo.test/files/test/aa.jpg
,那么你就需要配置
<external-files-path name="files1" path="." />
或者<external-files-path name="files1" path="test/" />
都是可以的。
但是如果配置:<external-files-path name="files1" path="fake/" />
,那么就会分享失败。
file://Uri
转content:// Uri
public static String convertFileUriToContentUri(File fileUri, Context context) {
if (fileUri == null || !fileUri.exists()) {
return "";
}
Uri contentUri = FileProvider.getUriForFile(context, context.getPackageName() +
".fileprovider",
fileUri);
context.grantUriPermission("com.tencent.mobileqq", contentUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION);
return contentUri.toString();
}
// 判断Android版本是否7.0及以上
public static boolean checkAndroidNotBelowN() {
return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N;
}
好了,大功告成~
4.2.2 微信分享适配
微信5.5.8版本支持接收content格式的Uri,所以,需要你在外部去调用convertFileUriToContentUri
转换后,传入即可成功。
4.2.3 qq分享适配
- 创建方式的改变
//mTencent = Tencent.createInstance(QQAppId, activity);
mTencent = Tencent.createInstance(QQAppId, activity,
activity.getApplicationContext().getPackageName() + ".fileprovider");
- 无需外部转换
qq 这边自己在内部已经做了
content 格式的
转换,因此,你不需要自己在外部去转换了,直接file的路径传入即可。相反,如果你转换了反而报错:非法uri格式!
qq和微信两个的处理是不一样的,记得注意区分!
可以参考微信、QQ的适配方案:
5 总结
适配的总体思路:
1,根据sdk版本来进行处理。29以下保持原来方式。 29及以上采取分区处理。
2,尽量使用自身的App-specific目录。 媒体文件如 图片、视频、音频等可以公开的文件可以通过mediaStore方式访问公共目录。
3,文件类管理应用,则需要使用SAF方式访问指定的目录和文件。
以上是近期适配的一些心得,如有错误之处,还望指正~
参考: