Android 11 分區(qū)存儲

1.分區(qū)存儲概念

為了讓用戶更好地控制自己的文件并減少混亂,Android10針對應(yīng)用推出了一個(gè)新的存儲規(guī)范,新的存儲模型會讓以 Android 10(API 級別 29)及更高版本為目標(biāo)平臺的應(yīng)用在默認(rèn)情況下被賦予了對外部存儲設(shè)備的分區(qū)訪問權(quán)限,即分區(qū)存儲(scoped storage)。分區(qū)存儲改變了應(yīng)用在設(shè)備的外部存儲設(shè)備中存儲和訪問文件的方式。

從另一個(gè)角度來說,分區(qū)存儲的推出更好的保護(hù)用戶的隱私。默認(rèn)情況下,對于以 Android 10 及更高版本為目標(biāo)平臺的應(yīng)用,其訪問權(quán)限范圍限定為外部存儲,即分區(qū)存儲。此類應(yīng)用可以查看外部存儲設(shè)備內(nèi)以下類型的文件,無需請求任何與存儲相關(guān)的用戶權(quán)限:

  • 特定于應(yīng)用的目錄中的文件(使用 getExternalFilesDir() 訪問)。
  • 應(yīng)用創(chuàng)建的照片、視頻和音頻片段(通過媒體庫訪問)。
    意思是說,我們的app在外部存儲設(shè)備(即SD卡)上存文件的時(shí)候,需要先想明白需要存的數(shù)據(jù)是屬于app私有的還是需要分享的,如果是app私有的,存在getExternalFilesDir()返回的文件夾下,也就是Android/data/包名/files/文件夾;如果是需要分享的,需要采用媒體庫(MediaStore)的方式來存取,后面會講怎么存取。需要指出的是在分區(qū)存儲模型下存取共享媒體文件是不需要存儲權(quán)限的,而舊的存儲模型是需要存儲權(quán)限的。

2.怎么適配

適配分為兩部分,新數(shù)據(jù)的存儲和老數(shù)據(jù)的遷移,我們先說新數(shù)據(jù)的存儲。

2.1新數(shù)據(jù)的存儲

把a(bǔ)pp所有需要存的數(shù)據(jù)梳理一遍,對于私有數(shù)據(jù)我們存到SD卡app私有目錄下,對于需要共享的媒體數(shù)據(jù)我們通過MediaStore的方式。數(shù)據(jù)放到私有目錄很簡單我們不講,主要講怎么共享媒體數(shù)據(jù),以視頻為例,看下面的代碼:

/**
     * 保存共享媒體資源,必須使用先在MediaStore創(chuàng)建表示視頻保存信息的Uri,然后通過Uri寫入視頻數(shù)據(jù)的方式。
     * 在"分區(qū)存儲"模型中,這是官方推薦的,因?yàn)樵贏ndroid 10禁止通過File的方式訪問媒體資源,Android 11又允許了
     * 從Android 10開始默認(rèn)是分區(qū)存儲模型
     *
     *
     * 說明:
     * 此方法中MediaStore默認(rèn)的保存目錄是/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;
    }

需要保存視頻的時(shí)候,其實(shí)就是先在MediaStore的Video表插入一條記錄,獲取一個(gè)Uri,然后把視頻寫入這Uri就行了。具體保存位置,我們不用操心,它其實(shí)是保存到了Sd卡的Movies文件夾下了,在Android 10以上系統(tǒng)提供RELATIVE_PATH字段用于創(chuàng)建子目錄。

我們會問,高版本可以這樣共享視頻,那么低版本可以嗎?如果可以的話,低版本的也用這種方式,一套方案解決。理論上是可以的,畢竟MediaStore從Android誕生就存在??蓪?shí)際操作發(fā)現(xiàn)了問題,具體看下面代碼注釋

/**
     * 此接口用于獲取保存共享視頻的輸出流,推薦?。?!
     *
     * 在低于29的系統(tǒng)上采用getSaveToGalleryVideoUri的方式保存共享視頻,會有文件名不能定制、視頻保存類型是.3gp、視頻保存在video文件夾等問題
     * 所以在低版本上采用文件路徑的方式寫入數(shù)據(jù)。在低于29的系統(tǒng)上采用文件路徑的方式是沒有問題的,因?yàn)樵谶@些系統(tǒng)上沒有分區(qū)存儲的概念
     * 以及,getExternalStoragePublicDirectory函數(shù)可用
     *
     * @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());
            }
            //通過顯示路徑方式共享媒體的時(shí)候,是需要指定文件后綴,要不然下載文件會沒有后綴名
            if (!TextUtils.isEmpty(mineType)) {
                String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mineType);
                if (videoName.contains(".")) {
                    videoName = videoName.substring(0, videoName.indexOf(".")) + "." + extension;
                } else {
                    videoName += "." + extension;
                }
            }

            /**
             * 直接路徑的方式,組合出的文件路徑,路徑中的文件夾一定要存在,否則轉(zhuǎn)成FileOutputStream的時(shí)候會報(bào)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;
    }

解決辦法,進(jìn)行了版本區(qū)分,對外暴露OutputStream接口,低版本我們采用直接路徑的方式,直接把視頻保存到Movies目錄下,而且還可以有子目錄,為了讓相冊或者別的app能看到保存的視頻,我們通過DATA把保存路徑注冊給了MediaStore,這個(gè)在低版本上是可行的,這種方式絕大多數(shù)開發(fā)者之前都是這么做的,但是,DATA從Android 10開始標(biāo)記為棄用。

我們這里會問,我們可不可以在Android 10及以上也用直接路徑保存視頻到Movies目錄下呢?可以,但是會有問題,首先Android 10的分區(qū)存儲模型下不能使用直接路徑,因?yàn)槭褂肍ile api報(bào)錯(cuò),不過我們可以通過requestLegacyExternalStorage禁用分區(qū)存儲模型;最大的問題是獲取Movies目錄的接口getExternalStoragePublicDirectory從Android 10開始標(biāo)記為棄用。而且google還提示了使用直接路徑操作媒體文件的性能問題。==當(dāng)您使用直接文件路徑依序讀取媒體文件時(shí),其性能與 MediaStore API 相當(dāng)。但是,當(dāng)您使用直接文件路徑隨機(jī)讀取和寫入媒體文件時(shí),進(jìn)程的速度可能最多會慢一倍。在此類情況下,我們建議您改為使用 MediaStore API。==

這套適配方案無論是在舊存儲模型還是分區(qū)存儲模型下都能完美運(yùn)行,把共享視頻保存到Medias的指定文件夾下,而且相冊和別的app都能掃描的到。共享圖片、音頻和共享視頻思路一樣,大家自行編寫。

3.數(shù)據(jù)遷移

在8.0及以上的系統(tǒng),采用Files.move進(jìn)行數(shù)據(jù)遷移,8.0以下的系統(tǒng)采用File.rename進(jìn)行數(shù)據(jù)遷移。Files的move方法既可以作用于文件也可以作用于文件夾。我們項(xiàng)目中需要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系統(tǒng)(linux源自UNIX)上同一個(gè)partition上,即便被move的文件夾中有內(nèi)容,也是可以move的,實(shí)際就是重命名了一下。

我們的需求:在分區(qū)存儲模型下,SD卡的公共區(qū)域是禁止app使用的,為了保證我們app之前下載到SD的視頻在分區(qū)存儲模型下還能被app識別,所以,在app還是采用舊存儲模型的時(shí)候,我們需要把這些視頻遷移到app在SD卡的私有目錄下。這兩個(gè)目錄都在SD卡上,屬于同一個(gè)partition。說明一下,targetSDKVersion 29或30的app在Android 10和Android 11上,也是有辦法讓app采用舊存儲模型的;targetSDKVersion 29以下的app在任何系統(tǒng)上都是執(zhí)行舊存儲模型。

  • 私有數(shù)據(jù)遷移
從/storage/emulated/0/xxx/data 遷移到 /storage/emulated/0/Android/data/包名/files/data

xxx/data目錄中有文件,files/data目錄不存在,==在Android 10及以下的系統(tǒng)上,可以move成功;在Android 11的系統(tǒng)上 ,move失敗了,報(bào)DirectoryNotEmptyException。== 猜測可能是Android 11對Android/data目錄有了限制吧!如果,在Android 11上還需要進(jìn)行這種遷移的話,可以采用遍歷文件夾輸入輸出流拷貝的方式。

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 文件夾的時(shí)候,如果目標(biāo)文件夾存在,那么會報(bào)java.nio.file.FileAlreadyExistsException異常

private boolean moveData(File source, File target) {
        long start = System.currentTimeMillis();
        // 只有目標(biāo)文件夾不存在的時(shí)候,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會報(bào)AtomicMoveNotSupportedException異常
                    //在Android11上,move REPLACE_EXISTING會報(bào)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 引入的。

如果你已經(jīng)適配Android 10,如果應(yīng)用通過升級安裝,那么還會使用以前的儲存模式(Legacy View),只有通過首次安裝或是卸載重新安裝才能啟用新模式(Filtered View)。經(jīng)過測試,確實(shí)是這樣,我們在Android10的手機(jī)上安裝了一個(gè)targetSDKVersion是27的app,舊的存儲模型是可以正常使用的,然后覆蓋安裝了target是29的新包,舊存儲模型也是可以訪問的,但是,卸載重新安裝舊存儲模型就不能訪問了。requestLegacyExternalStorage讓targetSDKVersion是29(適配了Android 10)的app新安裝在Android 10系統(tǒng)上也繼續(xù)訪問舊的存儲模型。

==如果某個(gè)應(yīng)用在安裝時(shí)啟用了傳統(tǒng)外部存儲,則該應(yīng)用會保持此模式,直到卸載為止。無論設(shè)備后續(xù)是否升級為搭載 Android 10 或更高版本,或者應(yīng)用后續(xù)是否更新為以 Android 10 或更高版本為目標(biāo)平臺,此兼容性行為均適用。==

這句話是有些問題的,估計(jì)當(dāng)時(shí)說這話的時(shí)候,是Android10的時(shí)候。在Android11中引入了preserveLegacyExternalStorage,看下面的解釋

按照文檔說targetSDKVersion<29時(shí),requestLegacyExternalStorage默認(rèn)是true的,也就是說這些app是采用舊的存儲模型運(yùn)行的,targetSDKVersion升級到29后,requestLegacyExternalStorage默認(rèn)是false的,但是覆蓋安裝的,還是采用舊的存儲模式運(yùn)行。重新安裝的,由于requestLegacyExternalStorage是false,就采用分區(qū)存儲模式運(yùn)行了,除非requestLegacyExternalStorage顯示設(shè)置成true。

也就是說requestLegacyExternalStorage給了app,在Android 10的系統(tǒng)上,無論是覆蓋安裝還是重新安裝都能使用舊存儲模式的機(jī)會。

targetSDKVersion升級到30后,在Android 11設(shè)備上,requestLegacyExternalStorage會被忽略掉,在Android 10的系統(tǒng)上requestLegacyExternalStorage依舊有效。preserveLegacyExternalStorage只是讓覆蓋安裝的app能繼續(xù)使用舊的存儲模型,如果之前是舊的存儲模型的話。如果您使用 preserveLegacyExternalStorage,舊版存儲模型只在用戶卸載您的應(yīng)用之前保持有效。如果用戶在搭載 Android 11 的設(shè)備上安裝或重新安裝您的應(yīng)用,那么無論 preserveLegacyExternalStorage 的值是什么,您的應(yīng)用都無法停用分區(qū)存儲模型。

app targetSDKVersion適配到30,在Android 11的系統(tǒng)上首次安裝,是沒有任何機(jī)會,讓app能繼續(xù)使用舊存儲模型的。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容