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

前言

存儲適配系列文章:

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

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

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

1、存儲基本知識

先來看看存儲區(qū)域劃分:


image.png

其中,以下目錄無需存儲權(quán)限即可訪問:

1、App自身的內(nèi)部存儲
2、App自身的自帶外部存儲-私有目錄

剩下的都需要申請存儲權(quán)限,Android 10.0前后對于存儲作用域訪問的區(qū)別就體現(xiàn)在如何訪問剩余這些目錄內(nèi)的文件。

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

2、Android 10.0 之前訪問方式

繼續(xù)細(xì)分為Android 6.0 之前和之后。

Android 6.0 之前訪問方式

Android 6.0 之前是無需申請動態(tài)權(quán)限的,在AndroidManifest.xml 里聲明存儲權(quán)限:

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

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

Android 6.0 之后的訪問方式

動態(tài)申請權(quán)限

Android 6.0 后需要動態(tài)申請權(quán)限,除了在AndroidManifest.xml 里聲明存儲權(quán)限外,還需要在代碼里動態(tài)申請。

    //檢查權(quán)限,并返回需要申請的權(quán)限列表
    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;
    }

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

    //用戶作出選擇后,返回申請的結(jié)果
    @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, "存儲權(quán)限申請成功", Toast.LENGTH_SHORT).show();
                    } else {
                        Toast.makeText(MainActivity.this, "存儲權(quán)限申請失敗", Toast.LENGTH_SHORT).show();
                    }
                }
            }
        }
    }

    //測試申請存儲權(quán)限
    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, "無需申請權(quán)限", Toast.LENGTH_SHORT).show();
        } else {
            requestPermission(activity, needRequestList.toArray(new String[needRequestList.size()]));
        }
    }

申請權(quán)限后,提示用戶作出選擇:


image.png

訪問文件

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

訪問共享存儲空間

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

訪問媒體文件

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

1、直接構(gòu)造路徑
以圖片為例,假設(shè)圖片存儲在/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構(gòu)造Uri
            Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
            openUri(uri);
            break;
        }
    }

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

訪問文檔和其它文件

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

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

    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構(gòu)造輸入流
            InputStream fis = getContentResolver().openInputStream(uri);
            Bitmap bitmap = BitmapFactory.decodeStream(fis);
        } catch (Exception e) {

        }
    }

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

訪問其它目錄

有兩種方式:
1、直接構(gòu)造路徑
在/sdcard/目錄下直接創(chuàng)建目錄:

    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/目錄創(chuàng)建成功。

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

Android 10.0 之前訪問方式總結(jié)

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

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

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

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

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

1、AndroidManifest.xml里聲明存儲權(quán)限
2、動態(tài)申請存儲權(quán)限
3、通過路徑或者Uri訪問文件

3、Android 10.0 訪問方式變更

為什么要變更

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


image.png
image.png

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

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

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

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

如何變更

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

引入Scoped Storage

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

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

分區(qū)存儲的變更在于第二點、第三點。

為什么Uri能夠訪問

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

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

可以看出,要想FileInputStream 能讀入文件,核心是需要構(gòu)造FileDescriptor,而對于Android 10.0,直接通過路徑構(gòu)造FileDescriptor 會拋出異常。
那么我們自然會想到,有沒有通過構(gòu)造好的FileDescriptor 來生成FileInputStream對象,進而使用read(xx)方法讀取數(shù)據(jù)。
還真有,請看:通過Uri構(gòu)造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構(gòu)造fd是被允許的
            AssetFileDescriptor fd = openAssetFileDescriptor(uri, "r", null);
            try {
                //反過來創(chuàng)建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 及其以后才會有分區(qū)存儲功能,只要Android 設(shè)備不升級系統(tǒng)到Android 10.0以后,就不會有問題。
2、可能覺得這是句廢話,其實不然,有些定制的設(shè)備系統(tǒng)一般都不會升級的。

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

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

有關(guān)targetSdkVersion 作用請移步:targetSdkVersion、compileSdkVersion、minSdkVersion作用與區(qū)別

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

在AndroidManifest.xml 里application標(biāo)簽下添加:
android:requestLegacyExternalStorage="true" 可禁用分區(qū)存儲

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

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

對于第三種方法:

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

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

本文基于Android 10.0。

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

持續(xù)更新中,和我一起步步為營系統(tǒng)、深入學(xué)習(xí)Android

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

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

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