Android Q (10) 分区存储 微信、qq分享 适配

2,912 阅读7分钟

1 背景

在Android 10之前,绝大部分的应用只需要动态申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限即可在SD卡上新建属于自己的专属文件夹。应用即使被卸载,该文件夹下的文件也不会被清除。

从用户角度来说,如果每个App都这么干,那么整个文件系统就非常的混乱,同时可用空间也越来越小(作为开发者,我也觉得不友好~)。

因此,从Android 10开始,引入了分区存储概念:Scoped Storage。

手机的外部存储被分为了两个部分: 私有目录App-specific和公共目录。这样做的目的是规范开发者对外部存储的使用,解决上述痛点。

第二篇更多是在项目中的实践: Android 10 和Android 11 适配采坑 实践篇

2 分区存储的概念

2.1 到底什么是分区存储?

顾名思义,就是把外部存储空间分为两个区域:

  1. App-specific
  2. 公共目录

2.1.1 App-specific

也称为App外部私有目录。特点是随着卸载会被清除。 但是可以通过在应用的application标签中加入属性:

android:hasFragileUserData="true"
true: 表示弹出对话框
默认为false,不弹出。

在用户卸载的时候,会弹出会话框,询问用户是否保留该应用的数据?这样把是否清除数据的选择权交给了用户!

与外部私有目录对应的是内部私有目录。 内部私有目录只能应用自己访问,且随着卸载被清除。路径如下: image.png

访问方式:

  • app无需申请存储权限可以直接访问

image.png

  • 通过SAF或者FileProvider的方式分享给第三方引用

2.1.2 公共目录

包含:Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones等。 应用卸载,这些目录下的文件不会被清除。

访问方式:

  • 媒体类型文件:音频、视频、图片。通过MediaStore/SAF
  • 非媒体类文件:pdf/documents 。通过SAF

SAF: Storage Access Framework。 一般是文件管理类应用使用。

2.1.3 总结

说了这么多,一开始可能不好理解,那么来张图肯定更清晰一些。 图片来自网络,侵删:

image.png

说明: 分为左右两个部分:不可移除的存储和可以移除的存储。右边一般是指可移除的外置存储卡,如TF卡 。一般情况下,我们都是面对左边的情况。 左边又分为三个部分:

  1. Internal Storage:内部私有目录。应用的私有目录,如通过 getCacheDir()getFileDir()获得。
  2. Primary External Storage:主要外部存储目录。分为两个部分:
  • App-specific 外部app私有目录。app可直接访问,其他app不能直接访问。只能通过FileProvider或者SAF分享给外部
  • public directory:外部公共目录。对应Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones等。可通过 mediaStoreASF 来访问。
  1. 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: 覆盖安装的情况下,还是一旧的存储方式。卸载安装则以分区方式进行。 

总结:

  1. targetSdkVersion保持低于29 你可以一直不升级targetSdkVersion,让它保持在<=28,但是Google是会强制你升级。不仅仅是分区存储,Androidx升级等都需要你去升级。

  2. 采用临时方案 但这也是临时的啊。。 饮鸩止渴了属于是。

  3. 老老实实的分区存储吧 那么怎么去适配,往下看。

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 存储适配

适配思路:

  1. 发布一个兼容版本,在应用启动的时候,如果大于29且没有开启分区存储的时候,就把之前文件夹中的旧数据拷贝到沙盒中(可选,可不做)。
  2. 根据版本>=29&&Environment.isExternalStorageLegacy=false判断是否启用了分区存储。
  3. 如果不启用,则继续原来的兼容方式
  4. 如果启动,则返回沙盒目录。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !Environment.isExternalStorageLegacy()) {
    sdCardDir = CoreValue.gContext.getExternalFilesDir(null);
} else {
    sdCardDir = Environment.getExternalStorageDirectory();
}

// 一般以sdCardDir作为一级目录,让后在分建不同子目录来存储。

MediaStore & SAF的用法参考:

OOPO适配

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访问我们私有目录的权限,这是非常不安全的!

所以,我们需要去适配:

  1. 在清单文件中添加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>
  1. 添加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/" />,那么就会分享失败。

  1. file://Uricontent:// 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分享适配

  1. 创建方式的改变
 //mTencent = Tencent.createInstance(QQAppId, activity);
        mTencent = Tencent.createInstance(QQAppId, activity,
                activity.getApplicationContext().getPackageName() + ".fileprovider");
  1. 无需外部转换 qq 这边自己在内部已经做了content 格式的转换,因此,你不需要自己在外部去转换了,直接file的路径传入即可。相反,如果你转换了反而报错:非法uri格式!

qq和微信两个的处理是不一样的,记得注意区分!

可以参考微信、QQ的适配方案:

微信适配Provider方式分享

微信关于Android11 分区存储的适配

qq官方分享适配

5 总结

适配的总体思路:

1,根据sdk版本来进行处理。29以下保持原来方式。 29及以上采取分区处理。

2,尽量使用自身的App-specific目录。 媒体文件如 图片、视频、音频等可以公开的文件可以通过mediaStore方式访问公共目录。

3,文件类管理应用,则需要使用SAF方式访问指定的目录和文件。

以上是近期适配的一些心得,如有错误之处,还望指正~

参考:

AndroidQ分区存储适配,AndroidR适配,以及分区存储的踩坑总结

OOPO适配