Android 10 分區(qū)存儲(chǔ)

背景

以前涡尘,Android 開發(fā)者習(xí)慣在根目錄建一個(gè)自己應(yīng)用的文件夾,用于存放應(yīng)用的數(shù)據(jù)响迂。這樣會(huì)導(dǎo)致用戶卸載后考抄,應(yīng)用數(shù)據(jù)不會(huì)隨之刪除莱预。導(dǎo)致手機(jī)文件特別混亂苍日,長期占用空間,而且容易泄露用戶隱私。

其實(shí) Android 早就提供了 getCacheDir()皇拣、getFilesDir()苦始、getExternalFilesDir()擂达、getExternalCacheDir() 等 API 供開發(fā)者使用蹋肮,但是開發(fā)者為了方便,沒有去用丢早。

為了解決這個(gè)問題姨裸,從 Android 10 開始,Google 添加了一個(gè)新特性 Scoped Storage怨酝,我們稱之為分區(qū)存儲(chǔ)傀缩,也可以稱為沙盒。

在 Android 10 上凫碌,仍然可以通過以下兩種手段避開分區(qū)存儲(chǔ):

  1. targetSdkVersion 設(shè)成 29 以下
  2. 在 manifest 中設(shè)置 android:requestLegacyExternalStorage="true"

在 Android 11 上扑毡,requestLegacyExternalStorage 會(huì)失效,沒有效果盛险。但是又增加了 preserveLegacyExternalStorage 屬性瞄摊,對于覆蓋安裝的應(yīng)用還能繼續(xù)用,但是新應(yīng)用不能用苦掘。

至于 targetSdkVersion换帜,上傳到 Google Play 的應(yīng)用,Google 要求必須設(shè)成 30 及以上鹤啡。

分區(qū)存儲(chǔ)目錄

  1. 沙盒目錄
    通過 getExternalFilesDir() 等獲取到的目錄惯驼,隨著 App 卸載會(huì)被刪除。
    不過可以在 manifest 中設(shè)置 android:hasFragileUserData="true" 讓用戶選擇是否刪除递瑰。

  2. 公共目錄
    DCIM祟牲、Photos、Images抖部、Videos说贝、Audio、Downloads 等目錄慎颗, App 卸載后會(huì)保留乡恕。

訪問公共目錄

重點(diǎn)說下公共目錄,沙盒目錄就不詳細(xì)介紹了俯萎,沙盒目錄可以通過系統(tǒng)提供的接口直接獲取傲宜,可以直接通過路徑讀寫,也不需要定義任何讀寫權(quán)限夫啊,很簡單函卒。

訪問公共目錄需要通過 MediaStore 或者 Storage Access Framework(以下簡稱 SAF)。媒體文件(圖片撇眯,音頻报嵌,視頻)能通過 MediaStore 和 SAF 兩種方式訪問躁愿,非媒體文件只能通過 SAF 訪問。

MediaStore

關(guān)于 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE 讀寫權(quán)限沪蓬,MediaStore 訪問應(yīng)用自身存放到公共目錄下的文件不需要申請權(quán)限(但是如果應(yīng)用卸載后重裝,之前保存的文件將不屬于本應(yīng)用創(chuàng)建的文件)来候,而如果要訪問其他應(yīng)用保存到公共目錄下的文件則需要申請權(quán)限跷叉。

MediaStore 通過 Uri 操作文件。
各個(gè)目錄的 Uri 如下:

類型 Uri Uri 常量 默認(rèn)路徑
Image content://media/external/images/media MediaStore.Images.Media.EXTERNAL_CONTENT_URI Pictures
Video content://media/external/video/media MediaStore.Video.Media.EXTERNAL_CONTENT_URI Movies
Audio content://media/external/audio/media MediaStore.Audio.Media.EXTERNAL_CONTENT_URI Music
Download content://media/external/downloads MediaStore.Downloads.EXTERNAL_CONTENT_URI Download
File content://media/external/ MediaStore.Files.getContentUri(“external”) Documents

寫文件

// 從 Assets 讀取 Bitmap
Bitmap bitmap = null;
try {
    bitmap = BitmapFactory.decodeStream(getAssets().open("test.jpg"));
} catch (IOException e) {
    e.printStackTrace();
}

if (bitmap == null) return;

// 獲取保存文件的 Uri
ContentResolver contentResolver = getContentResolver();
ContentValues values = new ContentValues();
Uri insertUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);

// 保存圖片到 Pictures 目錄下
if (insertUri != null) {
    OutputStream os = null;
    try {
        os = contentResolver.openOutputStream(insertUri);
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, os);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } finally {
        try {
            if (os != null) {
                os.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

上面的例子直接把圖片保存到 Pictures 根目錄营搅,如果要在 Pictures 下創(chuàng)建子目錄云挟,需要用到 RELATIVE_PATH(Android 版本 >= 10)。

修改上面的例子转质,把子目錄添加進(jìn) ContentValues:

ContentValues values = new ContentValues();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    // 指定子目錄园欣,否則保存到對應(yīng)媒體類型文件夾根目錄
    values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES +"/test");
}

還可以向 ContentValues 中添加其他信息,如:文件名休蟹,MIME 等
繼續(xù)修改上面的例子:

ContentValues values = new ContentValues();
// 獲取保存文件的 Uri
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
// 指定保存的文件名沸枯,如果不設(shè)置,則系統(tǒng)會(huì)取當(dāng)前的時(shí)間戳作為文件名
values.put(MediaStore.Images.Media.DISPLAY_NAME, "test_" + System.currentTimeMillis() + ".png");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    // 指定子目錄赂弓,否則保存到對應(yīng)媒體類型文件夾根目錄
    values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/test");
}

刪除自己應(yīng)用創(chuàng)建的文件

獲取到對應(yīng)的 Uri 之后 contentResolver.delete(uri,null,null) 即可绑榴。

查詢自己應(yīng)用創(chuàng)建的文件

// 查詢
ContentResolver contentResolver = getContentResolver();
Cursor cursor = contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        new String[]{
                MediaStore.Images.Media._ID,
                MediaStore.Images.Media.WIDTH,
                MediaStore.Images.Media.HEIGHT
        },
        MediaStore.Images.Media._ID + " > ? ", new String[]{"100"},
        MediaStore.Images.Media._ID + " DESC"
);

// 得到所有的 Uri
List<Uri> filesUris = new ArrayList<>();
while (cursor.moveToNext()) {
    int index = cursor.getColumnIndex(MediaStore.Images.Media._ID);
    Uri uri = ContentUris.withAppendedId(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cursor.getLong(index)
    );
    filesUris.add(uri);
}
cursor.close();

// 通過 Uri 獲取具體內(nèi)容并顯示到界面上
ParcelFileDescriptor pfd = null;
try {
    pfd = contentResolver.openFileDescriptor(filesUris.get(0), "r");
    if (pfd != null) {
        Bitmap bitmap = BitmapFactory.decodeFileDescriptor(pfd.getFileDescriptor());
        ((ImageView) findViewById(R.id.image)).setImageBitmap(bitmap);
    }
} catch (FileNotFoundException e) {
    e.printStackTrace();
} finally {
    if (pfd != null) {
        try {
            pfd.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

查詢其他應(yīng)用創(chuàng)建的文件

如上文所訴,訪問自己應(yīng)用創(chuàng)建的文件不需要 READ_EXTERNAL_STORAGE 權(quán)限盈魁。以上代碼獲取到的 filesUris 只包含本應(yīng)用之前創(chuàng)建的文件翔怎。
如果需要連其他應(yīng)用的文件一起獲取,則申請下 READ_EXTERNAL_STORAGE 權(quán)限即可杨耙。

修改其他應(yīng)用創(chuàng)建的文件

同理赤套,需要申請 WRITE_EXTERNAL_STORAGE 權(quán)限。
但是珊膜,即便申請了 WRITE_EXTERNAL_STORAGE 權(quán)限之后容握,還是會(huì)報(bào)如下異常:

android.app.RecoverableSecurityException: xxx has no access to content://media/external/images/media/100

這是因?yàn)檫€需要向用戶申請修改的權(quán)限。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    try {
        delete();
    } catch (RecoverableSecurityException e) {
        e.printStackTrace();
        // 彈出對話框辅搬,向用戶申請修改其他應(yīng)用文件的權(quán)限
        requestConfirmDialog(e);
    }
}

private void delete() {
    Uri uri = Uri.parse("content://media/external/images/media/100");
    getContentResolver().delete(uri, null, null);
}

@RequiresApi(api = Build.VERSION_CODES.Q)
private void requestConfirmDialog(RecoverableSecurityException e) {
    try {
        startIntentSenderForResult(
                e.getUserAction().getActionIntent().getIntentSender()
                , 0, null, 0, 0, 0, null);
    } catch (IntentSender.SendIntentException ex) {
        ex.printStackTrace();
    }
}

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (resultCode == RESULT_OK){
        delete();
    }
}

將文件下載到 Download 目錄

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
    private void downloadApkAndInstall(String downloadUrl, String apkName) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            // 使用原始方式
        } else {
            new Thread(() -> {
                BufferedInputStream bis = null;
                BufferedOutputStream bos = null;
                try {
                    URL url = new URL(downloadUrl);
                    URLConnection urlConnection = url.openConnection();
                    InputStream is = urlConnection.getInputStream();
                    bis = new BufferedInputStream(is);
                    ContentValues values = new ContentValues();
                    values.put(MediaStore.MediaColumns.DISPLAY_NAME, apkName);
                    values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
                    ContentResolver contentResolver = getContentResolver();
                    Uri uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
                    OutputStream os = contentResolver.openOutputStream(uri);
                    bos = new BufferedOutputStream(os);
                    byte[] buffer = new byte[1024];
                    int bytes = bis.read(buffer);
                    while (bytes >= 0) {
                        bos.write(buffer, 0, bytes);
                        bos.flush();
                        bytes = bis.read(buffer);
                    }
                    runOnUiThread(() -> installAPK(uri));
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    try {
                        if (bis != null) bis.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    try {
                        if (bos != null) bos.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }

    private void installAPK(Uri uri) {
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.setDataAndType(uri, "application/vnd.android.package-archive");
        startActivity(intent);
    }

SAF

SAF 在 Android 4.4 就支持了唯沮。
SAF 通過系統(tǒng)提供的標(biāo)準(zhǔn)化 UI 瀏覽和修改手機(jī)中的文件,如下圖


ACTION_CREATE_DOCUMENT 創(chuàng)建文件

    private void createFile() {
        Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        intent.setType("image/*");
        intent.putExtra(Intent.EXTRA_TITLE, "test_create.png");
        startActivityForResult(intent, WRITE_REQUEST_CODE);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (data == null || resultCode != RESULT_OK) return;
        if (requestCode == WRITE_REQUEST_CODE) {
            Log.d("tianjf", "write uri : " + data.getData());
        }
    }

運(yùn)行之后堪遂,會(huì)啟動(dòng)標(biāo)準(zhǔn)文件管理器 UI 保存文件介蛉。
寫文件不需要申請寫權(quán)限。

ACTION_OPEN_DOCUMENT 讀文件

因?yàn)橛锌赡茏x取其他應(yīng)用創(chuàng)建的文件溶褪,所以需要申請讀權(quán)限币旧。

    protected void readFiles() {
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        intent.setType("image/*");
        startActivityForResult(intent, READ_REQUEST_CODE);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (data == null || resultCode != RESULT_OK) return;
        if (requestCode == READ_REQUEST_CODE) {
            Log.d("tianjf", "read uri : " + data.getData());
            process(data.getData());
        }
    }

    private void process(Uri uri) {
        String[] selectionArgs = new String[]{DocumentsContract.getDocumentId(uri).split(":")[1]};
        Cursor cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                null, MediaStore.Images.Media._ID + "=?",
                selectionArgs, null);
        if (null != cursor) {
            if (cursor.moveToFirst()) {
                int index = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
                if (index > -1) {
                    String path = cursor.getString(index);
                    Log.d("tianjf", "onActivityResult path=" + path + ";id=" + selectionArgs[0]);
                }
            }
            cursor.close();
        }
    }

ACTION_OPEN_DOCUMENT_TREE 讀取文件夾

    protected void readFolder() {
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
        startActivityForResult(intent, READ_FOLDER_REQUEST_CODE);
    }

    // 選取文件夾然后在文件夾中創(chuàng)建子文件夾和文件
    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (data == null || resultCode != RESULT_OK) return;
        if (requestCode == READ_FOLDER_REQUEST_CODE) {
            Log.d("tianjf", "read folder uri : " + data.getData());
            DocumentFile selectedFolder = DocumentFile.fromTreeUri(this, data.getData());
            DocumentFile newFolder = selectedFolder.createDirectory("newFolder");
            DocumentFile newFile = newFolder.createFile("text/plain", "test.txt");
            try {
                getContentResolver().openOutputStream(newFile.getUri()).write("Hello".getBytes());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市猿妈,隨后出現(xiàn)的幾起案子吹菱,更是在濱河造成了極大的恐慌巍虫,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,454評論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件鳍刷,死亡現(xiàn)場離奇詭異占遥,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)输瓜,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,553評論 3 385
  • 文/潘曉璐 我一進(jìn)店門瓦胎,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人尤揣,你說我怎么就攤上這事搔啊。” “怎么了北戏?”我有些...
    開封第一講書人閱讀 157,921評論 0 348
  • 文/不壞的土叔 我叫張陵负芋,是天一觀的道長。 經(jīng)常有香客問我嗜愈,道長旧蛾,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,648評論 1 284
  • 正文 為了忘掉前任蠕嫁,我火速辦了婚禮蚜点,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘拌阴。我一直安慰自己绍绘,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,770評論 6 386
  • 文/花漫 我一把揭開白布迟赃。 她就那樣靜靜地躺著陪拘,像睡著了一般。 火紅的嫁衣襯著肌膚如雪纤壁。 梳的紋絲不亂的頭發(fā)上左刽,一...
    開封第一講書人閱讀 49,950評論 1 291
  • 那天,我揣著相機(jī)與錄音酌媒,去河邊找鬼欠痴。 笑死,一個(gè)胖子當(dāng)著我的面吹牛秒咨,可吹牛的內(nèi)容都是我干的喇辽。 我是一名探鬼主播,決...
    沈念sama閱讀 39,090評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼雨席,長吁一口氣:“原來是場噩夢啊……” “哼菩咨!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,817評論 0 268
  • 序言:老撾萬榮一對情侶失蹤抽米,失蹤者是張志新(化名)和其女友劉穎特占,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體云茸,經(jīng)...
    沈念sama閱讀 44,275評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡是目,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,592評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了标捺。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片胖笛。...
    茶點(diǎn)故事閱讀 38,724評論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖宜岛,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情功舀,我是刑警寧澤萍倡,帶...
    沈念sama閱讀 34,409評論 4 333
  • 正文 年R本政府宣布,位于F島的核電站辟汰,受9級特大地震影響列敲,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜帖汞,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,052評論 3 316
  • 文/蒙蒙 一戴而、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧翩蘸,春花似錦所意、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,815評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至郎任,卻和暖如春秧耗,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背舶治。 一陣腳步聲響...
    開封第一講書人閱讀 32,043評論 1 266
  • 我被黑心中介騙來泰國打工分井, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人霉猛。 一個(gè)月前我還...
    沈念sama閱讀 46,503評論 2 361
  • 正文 我出身青樓尺锚,卻偏偏與公主長得像,于是被迫代替她去往敵國和親惜浅。 傳聞我的和親對象是個(gè)殘疾皇子缩麸,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,627評論 2 350

推薦閱讀更多精彩內(nèi)容