文章目录

  • 分区存储概念
  • 适配分区存储
  • 为什么要适配
  • 怎么适配
  • 新数据的存储
  • 老数据的迁移
  • 数据迁移
  • 理清头绪
  • 实战
  • requestLegacyExternalStorage和preserveLegacyExternalStorage的理解
  • 分区存储模型下,访问SD卡公共区域错误举例
  • File的api
  • FileOutputStream|FileInputStream
  • RecoverableSecurityException
  • 参考


分区存储概念

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

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

  • 特定于应用的目录中的文件(使用 getExternalFilesDir() 访问)。
  • 应用创建的照片、视频和音频片段(通过媒体库访问)。

意思是说,我们的app在外部存储设备(即SD卡)上存文件的时候,需要先想明白需要存的数据是属于app私有的还是需要分享的,如果是app私有的,存在getExternalFilesDir()返回的文件夹下,也就是Android/data/包名/files/文件夹;如果是需要分享的,需要采用媒体库(MediaStore)的方式来存取,后面会讲怎么存取。需要指出的是在分区存储模型下存取共享媒体文件是不需要存储权限的,而旧的存储模型是需要存储权限的。

下表总结了分区存储如何影响文件访问:

文件位置

所需权限

访问方法 (*)

卸载应用时是否移除文件?

特定于应用的目录


getExternalFilesDir()


媒体集合(照片、视频、音频)

READ_EXTERNAL_STORAGE(仅当访问其他应用的文件时)

MediaStore


下载内容(文档和电子书籍)


存储访问框架(加载系统的文件选择器)


适配分区存储

为什么要适配

在分区存储模型下,外部存储设备的公共区域是不让访问的,如果强行访问,会在创建或读写文件的api上报错,具体看分区存储模型下,访问SD卡公共区域错误举例。
那么有没有办法关闭分区存储模型呢?有两种办法,第一种是app的targetSdkVersion永远低于29,这个是不现实的;第二种办法是targetSdkVersion 29时覆盖安装和新安装能关闭,targetSdkVersion 30时覆盖安装能关闭,新安装是没有办法关闭的,具体看requestLegacyExternalStorage和preserveLegacyExternalStorage的理解。而且说不定,Android 12出来后,以Android 12为目标平台的app都是强制执行分区存储模型的。
所以分区存储是一定需要适配的,而且越早适配越好。

怎么适配

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

新数据的存储

把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都能扫描的到。
共享图片、音频和共享视频思路一样,大家自行编写。

老数据的迁移

迁移老数据是为了在分区存储模型下,老数据依旧可以访问,如果不迁移这些散落在SD卡公共区域的数据,一旦开始执行分区存储模型,这些数据app就访问不到了。也就是说,在app还是旧存储模型的时候,需要把数据迁移到能够兼容分区存储要求的文件夹下。这块具体看数据迁移。

数据迁移

理清头绪

在数据迁移的时候,有个很重要的前提是,app能够访问旧存储模型。
我们看看什么情况能访问旧存储模型,得分几种情况讨论:

  1. targetSdkVersion 28的app安装在Android 9(28)的手机上,手机系统升级到Android10或11,app正常访问旧存储模型。这种情况和把targetSDKVersion 28的app安装到Android10或11系统手机上一样的情况。
  2. target 28在Android 9上,app target升级到30,覆盖安装,旧存储模型访问正常;
    target 28在Android10上,app target升级到30,覆盖安装,旧存储模型访问正常。requestLegacyExternalStorage设置成true,在Android 10上新安装的target 30 app,也可以正常访问旧存储模型。
    target28在Android11上,app target升级到30,覆盖安装,旧存储模型不能访问了,需要preserveLegacyExternalStorage设置成true。

怎么进行数据迁移最好呢?
targetSDKVersion 28的时候,先大规模的升级一次,此app就包含数据迁移功能,同时共享媒体的方式也按照分区存储模型的规范来,这样不论什么版本系统的用户,都能完成数据迁移,同时进行共享媒体的方式也正确。
但是,有部分用户就是不升级我们的app,可是我们app以后也得发版,而且target也得升级,假如有一部分用户没升级,等升级的时候,我们的app的target已经是30了,这些用户的系统如果是小于29的,可以正常迁移,如果这些用户的系统版本是29或者30,那也得给这些用户迁移数据呀,target30的app在29的系统上正常迁移,target30的app在30系统上,preserveLegacyExternalStorage设置成true,正常迁移。
所以我们的数据迁移方案就是,做好数据迁移功能和共享媒体功能,requestLegacyExternalStorage和preserveLegacyExternalStorage都设置成true,target升级不升级都没问题。不过前提是compileSdkVersion得是30

实战

在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在任何系统上都是执行旧存储模型。

我们的实际情况:

  • 共享数据迁移
    把之前保存的需要分享的视频从app自建的目录迁移到分区存储模型下app也能访问到Movies目录,这样做的目的是在分区存储模型下,自己和别的app还是访问到这些视频。
从/storage/emulated/0/shvdownload/video/VideoGallery 迁移到 /storage/emulated/0/Movies/SHVideo

VideoGallery目录中有文件,SHVideo目录不存在,move可以成功。
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;
    }

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能继续使用旧存储模型的。

分区存储模型下,访问SD卡公共区域错误举例

File的api

  • createNewFile
    targetSdkVersion 28的app在Android 10的系统上,运行旧存储模型,targetSdkVersion升级到30后,覆盖安装在Android10系统上,也是运行旧存储模型的。
    targetSdkVersion 30的app首次安装到Android10系统上,是开启分区存储模型的(没有配置requestLegacyExternalStorage),在SD卡的公共目录上调用File的createNewFile()方法会报java.io.IOException: No such file or directory
    在旧存储模型下,没有开启读写权限的时候,在SD卡的公共目录上调用File的createNewFile()方法也会报java.io.IOException: No such file or directory
java.io.IOException: No such file or directory
    at java.io.UnixFileSystem.createFileExclusively0(Native Method)
    at java.io.UnixFileSystem.createFileExclusively(UnixFileSystem.java:317)
    at java.io.File.createNewFile(File.java:1008)
    at com.xxx.sdk.android.storage.SohuStorageManager.tryGetGalleryPathState(SohuStorageManager.java:748)
    ...
  • listFiles
    分区存储模式下,SD卡的公共目录调用File的listFiles会返回null,即便此文件夹下有文件。

FileOutputStream|FileInputStream

在分区存储模型下,SD卡的公共目录是不让访问的,除了共享媒体的那几个文件夹。所以,用一个公共目录的路径实例化FileOutputStream或者FileInputStream会报FileNotFoundException异常

java.io.FileNotFoundException: /storage/emulated/0/xxx/SharePic/1603277403193.jpg: open failed: ENOENT (No such fileor directory)
    at libcore.io.IoBridge.open(IoBridge.java:496)
    at java.io.FileOutputStream.<init>(FileOutputStream.java:235)
    at com.xxx.ui.QrCodeActivity.askSDCardSaveImgPermission(QrCodeActivity.java:242)
    ...
java.io.FileNotFoundException: /storage/emulated/0/xxx/data/testusf: open failed: EACCES (Permission denied)
 	at libcore.io.IoBridge.open(IoBridge.java:496)
 	at java.io.FileInputStream.<init>(FileInputStream.java:159)
 	at java.io.FileReader.<init>(FileReader.java:72)
 	at com.android.xxx.sdk.common.toolbox.FileUtils.readSingleLineStringFromFile(FileUtils.java:747)

RecoverableSecurityException

Android 11新特性,Scoped Storage又有了新花样