Android-10、11-存儲完全適配(上)

前言

存儲適配系列文章:

Android-存儲基礎
Android-10、11-存儲完全適配(上)
Android-10、11-存儲完全適配(下)
Android-FileProvider-輕松掌握

上篇文章分析了Android 存儲相關的基礎知識,說到了各個目錄下文件的訪問方式。本篇將著重分析Android 系統版本變更對存儲訪問權限的影響及其適配方法。
通過本篇文章,你將了解到:

1、存儲基本知識
2、Android 10.0 之前訪問方式
3、Android 10.0 訪問方式變更
4、如何不適配Android 10.0

1、存儲基本知識

先來看看存儲區域劃分:


image.png

其中,以下目錄無需存儲權限即可訪問:

1、App自身的內部存儲
2、App自身的自帶外部存儲-私有目錄

剩下的都需要申請存儲權限,Android 10.0前后對于存儲作用域訪問的區別就體現在如何訪問剩余這些目錄內的文件。

重點在自帶外部存儲之共享存儲空間和其它目錄

2、Android 10.0 之前訪問方式

繼續細分為Android 6.0 之前和之后。

Android 6.0 之前訪問方式

Android 6.0 之前是無需申請動態權限的,在AndroidManifest.xml 里聲明存儲權限:

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

就可以訪問共享存儲空間、其它目錄下的文件了。

Android 6.0 之后的訪問方式

動態申請權限

Android 6.0 后需要動態申請權限,除了在AndroidManifest.xml 里聲明存儲權限外,還需要在代碼里動態申請。

    //檢查權限,并返回需要申請的權限列表
    private List<String> checkPermission(Context context, String[] checkList) {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < checkList.length; i++) {
            if (PackageManager.PERMISSION_GRANTED != ActivityCompat.checkSelfPermission(context, checkList[i])) {
                list.add(checkList[i]);
            }
        }
        return list;
    }

    //申請權限
    private void requestPermission(Activity activity, String requestPermissionList[]) {
        ActivityCompat.requestPermissions(activity, requestPermissionList, 100);
    }

    //用戶作出選擇后,返回申請的結果
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        if (requestCode == 100) {
            for (int i = 0; i < permissions.length; i++) {
                if (permissions[i].equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                    if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
                        Toast.makeText(MainActivity.this, "存儲權限申請成功", Toast.LENGTH_SHORT).show();
                    } else {
                        Toast.makeText(MainActivity.this, "存儲權限申請失敗", Toast.LENGTH_SHORT).show();
                    }
                }
            }
        }
    }

    //測試申請存儲權限
    private void testPermission(Activity activity) {
        String[] checkList = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE};
        List<String> needRequestList = checkPermission(activity, checkList);
        if (needRequestList.isEmpty()) {
            Toast.makeText(MainActivity.this, "無需申請權限", Toast.LENGTH_SHORT).show();
        } else {
            requestPermission(activity, needRequestList.toArray(new String[needRequestList.size()]));
        }
    }

申請權限后,提示用戶作出選擇:


image.png

訪問文件

權限申請成功后,即可對自帶外部存儲之共享存儲空間和其它目錄進行訪問。
分別以共享存儲空間和其它目錄為例,闡述訪問方式:

訪問共享存儲空間

共享存儲空間分為兩類文件:媒體文件和文檔/其它文件。

訪問媒體文件

目的是拿到媒體文件的路徑,有兩種方式獲取路徑:

1、直接構造路徑
以圖片為例,假設圖片存儲在/sdcard/Pictures/目錄下。

    private void testShareMedia() {
        //獲取目錄:/storage/emulated/0/
        File rootFile = Environment.getExternalStorageDirectory();
        String imagePath = rootFile.getAbsolutePath() + File.separator + Environment.DIRECTORY_PICTURES + File.separator + "myPic.png";
        Bitmap bitmap = BitmapFactory.decodeFile(imagePath);
    }

如上,myPic.png的路徑:/storage/emulated/0/Pictures/myPic.png,拿到路徑后就可以解析并獲取Bitmap。

2、通過MediaStore獲取路徑
沿用上篇的demo:

private void getImagePath(Context context) {
        ContentResolver contentResolver = context.getContentResolver();
        Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null);
        while(cursor.moveToNext()) {
            String imagePath = cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA));
            Bitmap bitmap = BitmapFactory.decodeFile(imagePath);
            break;
        }
    }

同樣的,也是拿到圖片路徑后獲取Bitmap。

還有一種不直接通過路徑訪問的方法:

3、通過MediaStore獲取Uri

    private void getImagePath(Context context) {
        ContentResolver contentResolver = context.getContentResolver();
        Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null);
        while(cursor.moveToNext()) {
            //獲取唯一的id
            long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
            //通過id構造Uri
            Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
            openUri(uri);
            break;
        }
    }

與直接拿到路徑不同的是,此處拿到的是Uri。圖片的信息封裝在Uri里,通過Uri構造出InputStream,再進行圖片解碼拿到Bitmap

訪問文檔和其它文件

1、直接構造路徑
與媒體文件一樣,可以直接構造路徑訪問。

2、通過SAF訪問
Storage Access Framework 簡稱SAF:存儲訪問框架。相當于系統內置了文件選擇器,通過它可以拿到想要訪問的文件信息。
同樣的以獲取圖片為例:

    private void startSAF() {
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        //選擇圖片
        intent.setType("image/jpeg");
        startActivityForResult(intent, 100);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (requestCode == 100) {
            //選中返回的圖片封裝在uri里
            Uri uri = data.getData();
            openUri(uri);
        }
    }

    private void openUri(Uri uri) {
        try {
            //從uri構造輸入流
            InputStream fis = getContentResolver().openInputStream(uri);
            Bitmap bitmap = BitmapFactory.decodeStream(fis);
        } catch (Exception e) {

        }
    }

可以看出,通過SAF并不能直接拿到圖片的路徑,圖片的信息封裝在Uri里,通過Uri構造出InputStream,再進行圖片解碼拿到Bitmap。

訪問其它目錄

有兩種方式:
1、直接構造路徑
在/sdcard/目錄下直接創建目錄:

    private void testPublicFile() {
        File rootFile = Environment.getExternalStorageDirectory();
        String imagePath = rootFile.getAbsolutePath() + File.separator + "myDir";
        File myDir = new File(imagePath);
        if (!myDir.exists()) {
            myDir.mkdir();
        }
    }
image.png

可以看出,/sdcard/myDir/目錄創建成功。

2、通過SAF訪問
與共享存儲空間SAF訪問方式一致。

Android 10.0 之前訪問方式總結

由上面分析的共享存儲空間/其它目錄訪問方式可知,訪問目錄/文件可通過如下兩個方法:

1、通過路徑訪問。路徑可以直接構造也可以通過MediaStore獲取。
2、通過Uri訪問。Uri可以通過MediaStore或者SAF獲取。

Android 6.0 以下訪問共享存儲空間/其它目錄步驟:

1、AndroidManifest.xml里聲明存儲權限
2、通過路徑或者Uri訪問文件

Android 6.0(含)~Android 10.0(不含)訪問共享存儲空間/其它目錄步驟:

1、AndroidManifest.xml里聲明存儲權限
2、動態申請存儲權限
3、通過路徑或者Uri訪問文件

3、Android 10.0 訪問方式變更

為什么要變更

你可能已經發現了上面訪問方式的弊端,比如我們能夠直接在/sdcard/目錄下創建目錄/文件。事實上,很多App就是這么干的,看圖說話:


image.png
image.png

可以看出/sdcard/目錄下,如淘寶、qq、qq瀏覽器、微博、支付寶等都自己建了目錄。
這么看來,導致目錄結構很亂,而且App卸載后,對應的目錄并沒有刪除,于是就是遺留了很多"垃圾"文件,久而久之不處理,用戶的存儲空間越來越小。
總結弊端如下:

1、在設置里"Clear storage"或者"Clear cache"并不能刪除該目錄下的文件
2、卸載App也不能刪除該目錄下的文件
3、App可以隨意修改其它目錄下的文件,如修改別的App創建的文件等,不安全

你也許會問,為什么要在/sdcard/目錄下新建自己的目錄呢?
大體有以下兩個原因:

1、此處新建的目錄不會被設置里的App存儲用量統計,讓用戶"看起來"自己的App占用的存儲空間很小
2、方便操作文件

如何變更

面對眾多App不講"碼德"隨意新建目錄/文件的現象,Google在Android 10.0上重拳出擊了。

引入Scoped Storage

翻譯成中文有好幾個版本:作用域存儲、分區存儲、沙盒存儲。
具體中文翻譯不重要,下面以分區存儲指代。
分區存儲原理:

1、App訪問自身內部存儲空間、訪問外部存儲空間-App私有目錄不需要任何權限(這個與Android 10.0之前一致)
2、外部存儲空間-共享存儲空間、外部存儲空間-其它目錄 App無法通過路徑直接訪問,不能新建、刪除、修改目錄/文件等
3、外部存儲空間-共享存儲空間、外部存儲空間-其它目錄 需要通過Uri訪問

分區存儲的變更在于第二點、第三點。

為什么Uri能夠訪問

先來看為什么通過路徑無法直接訪問。
我們知道訪問文件最終是通過構造InputStream/OutputStream來實現的,以InputStream為例,看看其構造方法:

#FileInputStream.java
    //文件描述符
    private final FileDescriptor fd;
    public FileInputStream(File file) throws FileNotFoundException {
        String name = (file != null ? file.getPath() : null);
        ...
        //傳入name,構造FileDescriptor
        //沒有權限訪問,則此處拋出異常
        fd = IoBridge.open(name, O_RDONLY);
        ...
    }

可以看出,要想FileInputStream 能讀入文件,核心是需要構造FileDescriptor,而對于Android 10.0,直接通過路徑構造FileDescriptor 會拋出異常。
那么我們自然會想到,有沒有通過構造好的FileDescriptor 來生成FileInputStream對象,進而使用read(xx)方法讀取數據。
還真有,請看:通過Uri構造InputStream。

InputStream fis = getContentResolver().openInputStream(uri);

進入看其源碼:

#ContentResolver.java
    public final @Nullable
    InputStream openInputStream(@NonNull Uri uri)
            throws FileNotFoundException {
        ...
        if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
            ...
        } else if (SCHEME_FILE.equals(scheme)) {
            ...
        } else {
            //通過Uri構造fd是被允許的
            AssetFileDescriptor fd = openAssetFileDescriptor(uri, "r", null);
            try {
                //反過來創建InputStream
                return fd != null ? fd.createInputStream() : null;
            } catch (IOException e) {
                throw new FileNotFoundException("Unable to create stream");
            }
        }
    }

AssetFileDescriptor 持有ParcelFileDescriptor 引用,而ParcelFileDescriptor 持有FileDescriptor 引用。
同理也適用于FileOutputStream。因此,通過Uri能夠訪問文件。

4、如何不適配Android 10.0

從以上分析可知,適配Android 10.0 有點麻煩,問題來了有沒有簡單的方法繞過檢測。
第一種方法

1、Android 10.0 及其以后才會有分區存儲功能,只要Android 設備不升級系統到Android 10.0以后,就不會有問題。
2、可能覺得這是句廢話,其實不然,有些定制的設備系統一般都不會升級的。

如果不能使用第一種方法,還可以采用第二種方法。
第二種方法

1、Android 一般升級功能的時候都會配合targetSdkVersion使用。只要targetSdkVersion<=28,分區存儲功能就不會開啟。

有關targetSdkVersion 作用請移步:targetSdkVersion、compileSdkVersion、minSdkVersion作用與區別

如果第二種方法也不能使用,則還有第三種方法。
第三種方法

在AndroidManifest.xml 里application標簽下添加:
android:requestLegacyExternalStorage="true" 可禁用分區存儲

從長遠的角度看,以上三個方法都不是一勞永逸的方法,其中第二種、第三種方法是Google 留給App開發者適配的緩沖時間。
對于第二種方法:

Google 在App上架App Store 時候可能會強制要求升級targetSdkVersion,因此該方法不保險。

對于第三種方法:

在Android 11會忽略該字段,強制開啟分區存儲,該字段也不怎么靠譜。

因此,最終還是需要老老實實按照Google 的要求適配Android 10.0,下篇將重點分析Android 10.0/11 該如何來適配。

本文基于Android 10.0。

您若喜歡,請點贊、關注,您的鼓勵是我前進的動力

持續更新中,和我一起步步為營系統、深入學習Android

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,362評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,013評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,346評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,421評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,146評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,534評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,585評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,767評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,318評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,074評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,258評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,828評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,486評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,916評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,156評論 1 290
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,993評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,234評論 2 375

推薦閱讀更多精彩內容