Android 11 分区存储

1.分区存储概念

为了让用户更好地控制自己的文件并减少混乱,Android10针对应用推出了一个新的存储规范,新的存储模型会让以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被赋予了对外部存储设备的分区访问权限,即分区存储(scoped storage)。分区存储改变了应用在设备的外部存储设备中存储和访问文件的方式。

从另一个角度来说,分区存储的推出更好的保护用户的隐私。默认情况下,对于以 Android 10 及更高版本为目标平台的应用,其访问权限范围限定为外部存储,即分区存储。此类应用可以查看外部存储设备内以下类型的文件,无需请求任何与存储相关的用户权限:

  • 特定于应用的目录中的文件(使用 getExternalFilesDir() 访问)。
  • 应用创建的照片、视频和音频片段(通过媒体库访问)。
    意思是说,我们的app在外部存储设备(即SD卡)上存文件的时候,需要先想明白需要存的数据是属于app私有的还是需要分享的,如果是app私有的,存在getExternalFilesDir()返回的文件夹下,也就是Android/data/包名/files/文件夹;如果是需要分享的,需要采用媒体库(MediaStore)的方式来存取,后面会讲怎么存取。需要指出的是在分区存储模型下存取共享媒体文件是不需要存储权限的,而旧的存储模型是需要存储权限的。

2.怎么适配

适配分为两部分,新数据的存储和老数据的迁移,我们先说新数据的存储。

2.1新数据的存储

把app所有需要存的数据梳理一遍,对于私有数据我们存到SD卡app私有目录下,对于需要共享的媒体数据我们通过MediaStore的方式。数据放到私有目录很简单我们不讲,主要讲怎么共享媒体数据,以视频为例,看下面的代码:

/**
     * 保存共享媒体资源,必须使用先在MediaStore创建表示视频保存信息的Uri,然后通过Uri写入视频数据的方式。
     * 在"分区存储"模型中,这是官方推荐的,因为在Android 10禁止通过File的方式访问媒体资源,Android 11又允许了
     * 从Android 10开始默认是分区存储模型
     *
     *
     * 说明:
     * 此方法中MediaStore默认的保存目录是/storage/emulated/0/video
     * 而Environment.DIRECTORY_MOVIES的目录是/storage/emulated/0/Movies
     * @param context
     * @return
     */
    static Uri getSaveToGalleryVideoUri(Context context, String videoName, String mineType, String subDir) {
        ContentValues values = new ContentValues();
        values.put(MediaStore.Video.Media.DISPLAY_NAME,  videoName);
        values.put(MediaStore.Video.Media.MIME_TYPE, mineType);
        values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + subDir);
        }

        Uri uri = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
        printMediaInfo(context, uri);
        return uri;
    }

需要保存视频的时候,其实就是先在MediaStore的Video表插入一条记录,获取一个Uri,然后把视频写入这Uri就行了。具体保存位置,我们不用操心,它其实是保存到了Sd卡的Movies文件夹下了,在Android 10以上系统提供RELATIVE_PATH字段用于创建子目录。

我们会问,高版本可以这样共享视频,那么低版本可以吗?如果可以的话,低版本的也用这种方式,一套方案解决。理论上是可以的,毕竟MediaStore从Android诞生就存在。可实际操作发现了问题,具体看下面代码注释

/**
     * 此接口用于获取保存共享视频的输出流,推荐!!!
     *
     * 在低于29的系统上采用getSaveToGalleryVideoUri的方式保存共享视频,会有文件名不能定制、视频保存类型是.3gp、视频保存在video文件夹等问题
     * 所以在低版本上采用文件路径的方式写入数据。在低于29的系统上采用文件路径的方式是没有问题的,因为在这些系统上没有分区存储的概念
     * 以及,getExternalStoragePublicDirectory函数可用
     *
     * @param context
     * @param videoName
     * @param mineType
     * @return
     * @throws FileNotFoundException
     */
    public static FileOutputStream getSaveToGalleryVideoOutputStream(@NonNull Context context, @NonNull String videoName, @NonNull String mineType) throws FileNotFoundException {
        //先在MediaStore中查询,有的话直接返回
        Uri uri = SHScopedStorageManager.querySpecialVideoUri(context, videoName);
        if (uri != null) {
            ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "w");
            FileOutputStream outputStream = new FileOutputStream(fileDescriptor.getFileDescriptor());
            return outputStream;
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            uri = getSaveToGalleryVideoUri(context, videoName, mineType);
            if (uri == null)
                return null;
            ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "w");
            FileOutputStream outputStream = new FileOutputStream(fileDescriptor.getFileDescriptor());
            return outputStream;
        } else {
            if (TextUtils.isEmpty(videoName)) {
                videoName = String.valueOf(System.currentTimeMillis());
            }
            //通过显示路径方式共享媒体的时候,是需要指定文件后缀,要不然下载文件会没有后缀名
            if (!TextUtils.isEmpty(mineType)) {
                String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mineType);
                if (videoName.contains(".")) {
                    videoName = videoName.substring(0, videoName.indexOf(".")) + "." + extension;
                } else {
                    videoName += "." + extension;
                }
            }

            /**
             * 直接路径的方式,组合出的文件路径,路径中的文件夹一定要存在,否则转成FileOutputStream的时候会报FileNotFoundException
             * 即便是通过DATA注册到MediaStore中,也是如此
             */
            String rootPath = getSaveToGalleryVideoPath();
            String videoPath = null;
            if (rootPath.endsWith(File.separator)) {
                videoPath = rootPath + videoName;
            } else {
                videoPath = rootPath + File.separator + videoName;
            }

            //通过DATA字段在MediaStore中注册一下
            ContentValues values = new ContentValues();
            values.put(MediaStore.Video.Media.DISPLAY_NAME, videoName);
            values.put(MediaStore.Video.Media.MIME_TYPE, mineType);
            values.put(MediaStore.Video.Media.DATA, videoPath);
            values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
            uri = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);

            if (uri == null)
                return null;

            SHScopedStorageManager.printMediaInfo(context, uri);
            ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "w");
            FileOutputStream outputStream = new FileOutputStream(fileDescriptor.getFileDescriptor());

            return outputStream;
        }
    }

    public static String getSaveToGalleryVideoPath() {
        File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
        if (!path.exists()) {
            path.mkdirs();
        }
        String pathStr = path.getAbsolutePath() + VIDEO_DIR;
        File file = new File(pathStr);
        if (!file.exists()) {
            file.mkdirs();
        }
        return pathStr;
    }

解决办法,进行了版本区分,对外暴露OutputStream接口,低版本我们采用直接路径的方式,直接把视频保存到Movies目录下,而且还可以有子目录,为了让相册或者别的app能看到保存的视频,我们通过DATA把保存路径注册给了MediaStore,这个在低版本上是可行的,这种方式绝大多数开发者之前都是这么做的,但是,DATA从Android 10开始标记为弃用。

我们这里会问,我们可不可以在Android 10及以上也用直接路径保存视频到Movies目录下呢?可以,但是会有问题,首先Android 10的分区存储模型下不能使用直接路径,因为使用File api报错,不过我们可以通过requestLegacyExternalStorage禁用分区存储模型;最大的问题是获取Movies目录的接口getExternalStoragePublicDirectory从Android 10开始标记为弃用。而且google还提示了使用直接路径操作媒体文件的性能问题。==当您使用直接文件路径依序读取媒体文件时,其性能与 MediaStore API 相当。但是,当您使用直接文件路径随机读取和写入媒体文件时,进程的速度可能最多会慢一倍。在此类情况下,我们建议您改为使用 MediaStore API。==

这套适配方案无论是在旧存储模型还是分区存储模型下都能完美运行,把共享视频保存到Medias的指定文件夹下,而且相册和别的app都能扫描的到。共享图片、音频和共享视频思路一样,大家自行编写。

3.数据迁移

在8.0及以上的系统,采用Files.move进行数据迁移,8.0以下的系统采用File.rename进行数据迁移。Files的move方法既可以作用于文件也可以作用于文件夹。我们项目中需要move的是文件夹,首先看看对move文件夹的定义:Empty directories can be moved. If the directory is not empty, the move is allowed when the directory can be moved without moving the contents of that directory. On UNIX systems, moving a directory within the same partition generally consists of renaming the directory. In that situation, this method works even when the directory contains files. 从定义中,我们知道在UNIX系统(linux源自UNIX)上同一个partition上,即便被move的文件夹中有内容,也是可以move的,实际就是重命名了一下。

我们的需求:在分区存储模型下,SD卡的公共区域是禁止app使用的,为了保证我们app之前下载到SD的视频在分区存储模型下还能被app识别,所以,在app还是采用旧存储模型的时候,我们需要把这些视频迁移到app在SD卡的私有目录下。这两个目录都在SD卡上,属于同一个partition。说明一下,targetSDKVersion 29或30的app在Android 10和Android 11上,也是有办法让app采用旧存储模型的;targetSDKVersion 29以下的app在任何系统上都是执行旧存储模型。

  • 私有数据迁移
从/storage/emulated/0/xxx/data 迁移到 /storage/emulated/0/Android/data/包名/files/data

xxx/data目录中有文件,files/data目录不存在,==在Android 10及以下的系统上,可以move成功;在Android 11的系统上 ,move失败了,报DirectoryNotEmptyException。== 猜测可能是Android 11对Android/data目录有了限制吧!如果,在Android 11上还需要进行这种迁移的话,可以采用遍历文件夹输入输出流拷贝的方式。

java.nio.file.DirectoryNotEmptyException: /storage/emulated/0/xxx/data
 at sun.nio.fs.UnixCopyFile.move(UnixCopyFile.java:498)
 at sun.nio.fs.UnixFileSystemProvider.move(UnixFileSystemProvider.java:262)
 at java.nio.file.Files.move(Files.java:1395)
 at com.xxx.sdk.android.storage.SHDataMigrateUtil.moveData(SHDataMigrateUtil.java:257)
    ...

File.move 文件夹的时候,如果目标文件夹存在,那么会报java.nio.file.FileAlreadyExistsException异常

private boolean moveData(File source, File target) {
        long start = System.currentTimeMillis();
        // 只有目标文件夹不存在的时候,move文件夹才能成功
        if (target.exists() && target.isDirectory() && (target.list() == null || target.list().length == 0)) {
            target.delete();
        }
        boolean isSuccess;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            Path sourceP = source.toPath();
            Path targetP = target.toPath();

            if (target.exists()) {
                isSuccess = copyDir(source, target);
                LogUtils.i(TAG, "moveData copyDir");
            } else {
                try {
                    Files.move(sourceP, targetP);
                    isSuccess = true;
                    LogUtils.i(TAG, "moveData Files.move");
                } catch (IOException e) {
                    e.printStackTrace();
                    LogUtils.i(TAG, Log.getStackTraceString(e));
                    //在Android11上,move ATOMIC_MOVE会报AtomicMoveNotSupportedException异常
                    //在Android11上,move REPLACE_EXISTING会报DirectoryNotEmptyException异常
                    isSuccess = copyDir(source, target);
                    LogUtils.i(TAG, "moveData move fail, use copyDir");
                }
            }
        } else {
            if (target.exists()) {
                isSuccess = copyDir(source, target);
                LogUtils.i(TAG, "moveData copyDir");
            } else {
                isSuccess = source.renameTo(target);
                LogUtils.i(TAG, "moveData renameTo result " + isSuccess);
            }
        }
        long end = System.currentTimeMillis();
        long val = end - start;
        LogUtils.i(TAG, "moveData migrate data take time " + val +" from " + source.getAbsolutePath() + " to " + target.getAbsolutePath());

        return isSuccess;
    } 

4.requestLegacyExternalStorage和preserveLegacyExternalStorage的理解

requestLegacyExternalStorage是Android10引入的,preserveLegacyExternalStorage 是 Android11 引入的。

如果你已经适配Android 10,如果应用通过升级安装,那么还会使用以前的储存模式(Legacy View),只有通过首次安装或是卸载重新安装才能启用新模式(Filtered View)。经过测试,确实是这样,我们在Android10的手机上安装了一个targetSDKVersion是27的app,旧的存储模型是可以正常使用的,然后覆盖安装了target是29的新包,旧存储模型也是可以访问的,但是,卸载重新安装旧存储模型就不能访问了。requestLegacyExternalStorage让targetSDKVersion是29(适配了Android 10)的app新安装在Android 10系统上也继续访问旧的存储模型。

==如果某个应用在安装时启用了传统外部存储,则该应用会保持此模式,直到卸载为止。无论设备后续是否升级为搭载 Android 10 或更高版本,或者应用后续是否更新为以 Android 10 或更高版本为目标平台,此兼容性行为均适用。==

这句话是有些问题的,估计当时说这话的时候,是Android10的时候。在Android11中引入了preserveLegacyExternalStorage,看下面的解释

按照文档说targetSDKVersion<29时,requestLegacyExternalStorage默认是true的,也就是说这些app是采用旧的存储模型运行的,targetSDKVersion升级到29后,requestLegacyExternalStorage默认是false的,但是覆盖安装的,还是采用旧的存储模式运行。重新安装的,由于requestLegacyExternalStorage是false,就采用分区存储模式运行了,除非requestLegacyExternalStorage显示设置成true。

也就是说requestLegacyExternalStorage给了app,在Android 10的系统上,无论是覆盖安装还是重新安装都能使用旧存储模式的机会。

targetSDKVersion升级到30后,在Android 11设备上,requestLegacyExternalStorage会被忽略掉,在Android 10的系统上requestLegacyExternalStorage依旧有效。preserveLegacyExternalStorage只是让覆盖安装的app能继续使用旧的存储模型,如果之前是旧的存储模型的话。如果您使用 preserveLegacyExternalStorage,旧版存储模型只在用户卸载您的应用之前保持有效。如果用户在搭载 Android 11 的设备上安装或重新安装您的应用,那么无论 preserveLegacyExternalStorage 的值是什么,您的应用都无法停用分区存储模型。

app targetSDKVersion适配到30,在Android 11的系统上首次安装,是没有任何机会,让app能继续使用旧存储模型的。

你可能感兴趣的:(Android 11 分区存储)