Android 10、11 儲存完全適配

語言: CN / TW / HK

通過本篇文章,你將瞭解到:

1、儲存基本知識 2、Android 10.0 之前訪問方式 3、Android 10.0 訪問方式變更 4、如何不適配Android 10.0
5、MediaStore 基本知識 6、通過Uri讀取和寫入檔案 7、通過Uri 獲取圖片和插入相簿 8、Android 11.0 許可權申請 9、Android 10/11 儲存適配建議

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()]));
}
}

申請許可權後,提示使用者作出選擇:

訪問檔案

許可權申請成功後,即可對自帶外部儲存之共享儲存空間和其它目錄進行訪問。

分別以共享儲存空間和其它目錄為例,闡述訪問方式:

訪問共享儲存空間

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

訪問媒體檔案

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

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();
}
}

可以看出,/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就是這麼幹的,看圖說話:

可以看出/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。

5、MediaStore 基本知識

上篇已經分析得出結論,Android 10.0 儲存訪問方式變更地方在於:

自帶外部儲存-共享儲存空間和自帶外部儲存-其它目錄

以上兩個地方不能通過路徑直接訪問檔案,而是需要通過Uri訪問。

共享儲存空間

共享儲存空間存放的是圖片、視訊、音訊等檔案,這些資源是公用的,所有App都能夠訪問它們。

系統裡有external.db資料庫,該資料庫裡有files表,該表裡存放著共享檔案的諸多資訊,如圖片有寬高,經緯度、存放路徑等,視訊寬高、時長、存放路徑等。而檔案真正存放的地方在於共享儲存空間。

1、儲存圖片到相簿

當App1儲存圖片到相簿時,簡單流程如下:

1、將路徑資訊寫入資料庫裡,並獲取Uri

2、通過Uri構造輸出流

3、將該圖片儲存在/sdcard/Pictures/目錄下

2、從相簿獲取圖片

當App2從相簿獲取圖片時,簡單流程如下:

1、先查詢資料庫,找到對應的圖片Cursor

2、從Cursor裡構造Uri

3、從Uri構造輸入流讀取圖片

以上以圖片為例簡單分析了共享儲存空間檔案的寫入與讀取,實際上對於視訊、音訊步驟亦是如此。

MediaStore作用

共享儲存空間裡存放著圖片、視訊、音訊、下載的檔案,App獲取或者插入檔案的時候怎麼區分這些型別呢?

這個時候就需要MediaStore,來看看MediaStore.java

可以看出其內部有Audio、Images等內部類,這些內部類裡記錄著files表的各個欄位名,通過構造這些引數就可以插入相應的欄位值以及獲取對應的欄位值。

MediaStore 實際上就是相當於給各個欄位起了別名,我們編碼的時候更容易記住與使用:

//列舉一些欄位:
//圖片型別
MediaStore.Images.Media.MIME_TYPE
//音訊時長
MediaStore.Audio.Media.DURATION
//視訊時長
MediaStore.Video.Media.DURATION
//等等,還有很多

MediaStore和Uri聯絡

比如想要查詢共享儲存空間裡的圖片檔案:

Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null);

MediaStore.Images.Media.EXTERNAL_CONTENT_URI 意思是指定查詢檔案的型別是圖片,並構造成Uri物件,Uri實現了Parcelable,能夠在程序間傳遞。

接收方(另一個程序收到後),匹配Uri,解析出對應的欄位,進行具體的操作。

當然,MediaStore是系統提供的方便操作共享儲存空間的類,若是自己寫ContentProvider,則也可以自定義類似MediaStore的類用來標記自己的資料庫表的欄位。

6、通過Uri讀取和寫入檔案

既然不能通過路徑直接訪問檔案,那麼來看看如何通過Uri訪問檔案。在上篇文章裡提到過: Uri可以通過MediaStore或者SAF獲取。 (此處需要注意的是:雖然也可以通過檔案路徑直接構造Uri,但是此種方式構造的Uri是沒有許可權訪問檔案的)

先來看看通過SAF獲取Uri。

從Uri讀取檔案

現在/sdcard/目錄下存在一個檔名為:mytest.txt。

該檔案內容是:

傳統的直接讀取mytest.txt方法:

//從檔案讀取
private void readFile(String filePath) {
if (TextUtils.isEmpty(filePath))
return;


try {
File file = new File(filePath);
FileInputStream fileInputStream = new FileInputStream(file);
BufferedInputStream bis = new BufferedInputStream(fileInputStream);
byte[] readContent = new byte[1024];
int readLen = 0;
while (readLen != -1) {
readLen = bis.read(readContent, 0, readContent.length);
if (readLen > 0) {
String content = new String(readContent);
Log.d("test", "read content:" + content.substring(0, readLen));
}
}
fileInputStream.close();
} catch (Exception e) {


}
}

開啟分割槽儲存功能後,這種方法是不可取的,會報許可權錯誤。

而mytest.txt不屬於共享儲存空間的檔案,是屬於其它目錄的,因此不能通過MediaStore獲取,只能通過SAF獲取,如下:

private void startSAF() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
//指定選擇文字型別的檔案
intent.setType("text/plain");
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();
openUriForRead(uri);
}
}

拿到Uri後,用來構造輸入流讀取檔案。

private void openUriForRead(Uri uri) {
if (uri == null)
return;


try {
//獲取輸入流
InputStream inputStream = getContentResolver().openInputStream(uri);
byte[] readContent = new byte[1024];
int len = 0;
do {
//讀檔案
len = inputStream.read(readContent);
if (len != -1) {
Log.d("test", "read content:" + new String(readContent).substring(0, len));
}
} while (len != -1);
inputStream.close();
} catch (Exception e) {
Log.d("test", e.getLocalizedMessage());
}
}

最終輸出:

由此可以看出,mytest.txt屬於"其它目錄"下的檔案,因此需要通過SAF訪問,SAF返回Uri,通過Uri構造InputStream即可讀取檔案。

從Uri寫入檔案

繼續來看看寫的過程,現在需要往mytest.txt寫入內容。

同樣的,還是需要通過SAF拿到Uri,拿到Uri後構造輸出流:

private void openUriForWrite(Uri uri) {
if (uri == null) {
return;
}


try {
//從uri構造輸出流
OutputStream outputStream = getContentResolver().openOutputStream(uri);
//待寫入的內容
String content = "hello world I'm from SAF\n";
//寫入檔案
outputStream.write(content.getBytes());
outputStream.flush();
outputStream.close();
} catch (Exception e) {
Log.d("test", e.getLocalizedMessage());
}
}

最後來看看檔案是否寫入成功,通過SAF再次讀取mytest.txt,發現正好是之前寫入的內容,說明寫入成功。

7、通過Uri 獲取圖片和插入相簿

上面列舉出了其它目錄下檔案的讀寫,方法是通過SAF拿到Uri。

SAF好處是:

系統提供了檔案選擇器,呼叫者只需要指定想要讀寫的檔案型別,比如文字型別、圖片型別、視訊型別等,選擇器就會過濾出相應檔案以供選擇。接入方便,選擇簡單。

想想另一種場景:

想要自己實現相簿選擇器,那麼就需要獲得共享儲存空間下的檔案資訊。此種場景下使用SAF是無法做到的。

因此問題的關鍵是: 如何批量獲得共享儲存空間下圖片/視訊的資訊?

答案是:ContentResolver+ContentProvider+MediaStore(ContentProvider對於呼叫者是透明的)。

以圖片為例,分析插入與查詢方式。

插入相簿

來看看圖片的插入過程:

//fileName為需要儲存到相簿的圖片名
private void insert2Album(InputStream inputStream, String fileName) {
if (inputStream == null)
return;


ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, fileName);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//RELATIVE_PATH 欄位表示相對路徑-------->(1)
contentValues.put(MediaStore.Images.ImageColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES);
} else {
String dstPath = Environment.getExternalStorageDirectory() + File.separator + Environment.DIRECTORY_PICTURES
+ File.separator + fileName;
//DATA欄位在Android 10.0 之後已經廢棄
contentValues.put(MediaStore.Images.ImageColumns.DATA, dstPath);
}


//插入相簿------->(2)
Uri uri = getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues);


//寫入檔案------->(3)
write2File(uri, inputStream);
}

重點說明三個點:

(1)

Android 10.0之前,MediaStore.Images.ImageColumns.DATA 欄位記錄的是圖片的絕對路徑,而Android 10.0(含)之後,DATA 被廢棄,取而代之的是使用MediaStore.Images.ImageColumns.RELATIVE_PATH,表示相對路徑。比如指定RELATIVE_PATH為Environment.DIRECTORY_PICTURES,表示之後的圖片將會放到Environment.DIRECTORY_PICTURES目錄下。

(2)

呼叫ContentResolver裡的方法插入相簿。

MediaStore.Images.Media.EXTERNAL_CONTENT_URI 指的是插入圖片表。

ContentValues 以Map的形式記錄了待寫入的欄位值。

插入後返回Uri。

(3)

以上兩步僅僅只是往資料庫裡增加一條記錄,該記錄指向的新檔案是空的,需要將圖片寫入到新檔案。

而新檔案位於/sdcard/Pictures/目錄下,該目錄是不能直接通過路徑訪問的,因此需要通過第二步返回的Uri進行訪問。

//uri 關聯著待寫入的檔案
//inputStream 表示原始的檔案流
private void write2File(Uri uri, InputStream inputStream) {
if (uri == null || inputStream == null)
return;


try {
//從Uri構造輸出流
OutputStream outputStream = getContentResolver().openOutputStream(uri);


byte[] in = new byte[1024];
int len = 0;


do {
//從輸入流裡讀取資料
len = inputStream.read(in);
if (len != -1) {
outputStream.write(in, 0, len);
outputStream.flush();
}
} while (len != -1);


inputStream.close();
outputStream.close();


} catch (Exception e) {
Log.d("test", e.getLocalizedMessage());
}
}

可以看出,目標檔案關聯的Uri有了,還需要原始的輸入檔案。

測試上述的插入方法:

 private void testInsert() {


String picName = "mypic.jpg";
try {
File externalFilesDir = getExternalFilesDir(null);
File file = new File(externalFilesDir, picName);
FileInputStream fis = new FileInputStream(file);
insert2Album(fis, picName);
} catch (Exception e) {
Log.d("test", e.getLocalizedMessage());
}
}

其中,原始檔案(圖片)存放於自帶外部儲存-App私有目錄,如下:

需要注意的是:

1、讀取原始檔案需要許可權,上述例子裡的原始檔案存放在自帶外部儲存-App私有目錄,因此本App可以使用路徑直接讀取

2、對於其他目錄則依然需要構造Uri讀取,如通過SAF獲取Uri

獲取圖片

同樣的,想要從系統相簿中獲取圖片,也需要通過Uri訪問。

 private void queryImageFromAlbum() {
Cursor cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null,
null, null, null);


if (cursor != 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);
//解析uri
decodeUriForBitmap(uri);
}
}
}


private void decodeUriForBitmap(Uri uri) {
if (uri == null)
return;


try {
//構造輸入流
InputStream inputStream = getContentResolver().openInputStream(uri);
//解析Bitmap
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
if (bitmap != null)
Log.d("test", "bitmap width-width:" + bitmap.getWidth() + "-" + bitmap.getHeight());
} catch (Exception e) {
Log.d("test", e.getLocalizedMessage());
}
}

與插入相簿過程類似,同樣需要拿到Uri,再構造輸入流,從輸入流讀取檔案(圖片內容)。

以上,通過Uri 獲取圖片和插入相簿分析完畢,共享儲存空間的其他檔案型別如視訊、音訊、下載檔案也是同樣的流程。

需要說明的是上述的ContentResolver .insert(xx)/ContentResolver.query(xx) 的引數取值還可以更豐富,但不是本篇重點,因此忽略了,實際使用過程中具體情況具體分析。

8、Android 11.0 許可權申請

通過Uri訪問檔案似乎已經滿足了Android 10.0適配要求,但是仔細想想還是有不足之處:

1、共享儲存空間只能通過MediaStore訪問,以前流行的訪問方式是直接通過路徑訪問。比如自己做的相簿管理器,先遍歷相簿拿到圖片/視訊的路徑,然後再解析成Bitmap展示,現在需要先拿到Uri,再解析成Bitmap,多少有些不方便。此外,也許你依賴的第三方庫是直接通過路徑訪問檔案的,而三方庫又沒有及時更新適配分割槽儲存,可能就會導致用不了相應的功能。

2、SAF雖然能夠訪問其它目錄的檔案,但是每次都需要跳轉到新的頁面去選擇,當想要批量展示檔案的時候,比如自己做的檔案管理器,就需要列出當前目錄下有哪些目錄/檔案,這個時候需要有許可權遍歷/sdcard/目錄。顯然,SAF並不能勝任此工作。

Android 11.0考慮到上面的問題,因此做了新的優化。

共享儲存空間-媒體檔案訪問變更

媒體檔案可以通過路徑直接訪問:

 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()) {


try {
//取出路徑
String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA));
Bitmap bitmap = BitmapFactory.decodeFile(path);
} catch (Exception e) {
Log.d("test", e.getLocalizedMessage());
}
break;
}
}

可以看出,之前在Android 10.0上被禁用的訪問方式,在Android 11.0上又被允許了,這就解決了上面的第一個問題。

需要注意的是:此種方式只允許讀檔案,寫檔案依然不行

Google 官方指導意見是:

雖然可以通過路徑直接訪問媒體檔案,但是這些操作最終是被重定向到MediaStore API的,重定向過程可能會損耗一些效能,並且直接通過路徑訪問不一定比MediaStore API 訪問快。

總之建議非必要的話不要直接使用路徑訪問。

訪問所有檔案

假若App開啟了分割槽儲存功能,當App執行在Android 10.0的裝置上時,是沒法遍歷/sdcard/目錄的。而在Android 11.0上執行時是可以遍歷的,需要進行如下幾個步驟。

1、宣告管理許可權

在AndroidManifest.xml新增許可權宣告

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

2、動態申請所有檔案訪問許可權

 private void testAllFiles() {
//執行裝置>=Android 11.0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
//檢查是否已經有許可權
if (!Environment.isExternalStorageManager()) {
//跳轉新頁面申請許可權
startActivityForResult(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION), 101);
}
}
}


@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
//申請許可權結果
if (requestCode == 101) {
if (Environment.isExternalStorageManager()) {
Toast.makeText(MainActivity.this, "訪問所有檔案許可權申請成功", Toast.LENGTH_SHORT).show();


//遍歷目錄
showAllFiles();
}
}
}

此處申請許可權不是以對話方塊的形式提示使用者,而是跳轉到新的頁面,說明該許可權的管理更嚴格。

3、遍歷目錄、讀寫檔案

擁有許可權後,就可以進行相應的操作了。

private void showAllFiles() {
File file = Environment.getExternalStorageDirectory();
File[] list = file.listFiles();
for (int i = 0; i < list.length; i++) {
String name = list[i].getName();
Log.d("test", "fileName:" + name);
}
}

檔案管理器效果圖類似如下:

當然讀寫檔案也不在話下了,比如往/sdcard/目錄下寫入檔案:

 private void testPublicFile() {
File rootFile = Environment.getExternalStorageDirectory();
try {
File file = new File(rootFile, "mytest.txt");
FileOutputStream fos = new FileOutputStream(file);
String content = "hello world\n";
fos.write(content.getBytes());
fos.flush();
fos.close();
} catch (Exception e) {
Log.d("test", e.getLocalizedMessage());
}
}


ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION 這個許可權的名字看起來很唬人,感覺就像是能夠操作所有檔案的樣子,這不就是打破了分割槽儲存的規則了嗎?其實不然:

即使擁有了該許可權,依然不能訪問內部儲存和外部儲存-App私有目錄

需要說明的是:

1、Environment.isExternalStorageManager()、Build.VERSION_CODES.R 等需要編譯版本>=30才能編譯通過。

2、Google 提示當使用MANAGE_EXTERNAL_STORAGE 申請許可權時,並且targetSdkVersion>=30,此種情況下App被禁止上架Google Play的,限制時間最早到2021年。因此,在此時間之前若是申請了MANAGE_EXTERNAL_STORAGE許可權,最好不要升級targetSdkVersion到30以上。

9、Android 10/11 儲存適配建議

好了,通過分析Android 10/11儲存適配方式,瞭解到了不同的系統需要如何進行適配,此時就需要一個統一的適配方案了。

適配核心

分割槽儲存是核心,App自身產生的檔案應該存放在自己的目錄下:

/sdcard/Android/data/packagename/ 和/data/data/packagename/

這兩個目錄本App無需申請訪問許可權即可申請,其它App無法訪問本App的目錄。

適配共享儲存

共享儲存空間裡的檔案需要通過Uri構造輸入輸出流訪問,Uri獲取方式有兩種:MediaStore和SAF。

適配其它目錄

在Android 11上需要申請訪問所有檔案的許可權。

具體做法

第一步

在AndroidManifest.xml裡新增如下欄位:

許可權宣告:

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

在<application/>標籤下新增如下欄位:

android:requestLegacyExternalStorage="true"

第二步

如果需要訪問共享儲存空間,則判斷執行裝置版本是否大於等於Android6.0,若是則需要申請WRITE_EXTERNAL_STORAGE 許可權。拿到許可權後,通過Uri訪問共享儲存空間裡的檔案。

如果需要訪問其它目錄,則通過SAF訪問

第三步

如果想要做檔案管理器、病毒掃描管理器等功能。則判斷執行裝置版本是否大於等於Android 6.0,若是先需要申請普通的儲存權。若執行裝置版本為Android 10.0,則可以直接通過路徑訪問/sdcard/目錄下檔案(因為禁用了分割槽儲存);若執行裝置版本為Android 11.0,則需要申請MANAGE_EXTERNAL_STORAGE 許可權。

以上是Android 儲存許可權適配的全部內容。

本篇基於Android 10.0 11.0 。 Android 10.0真機、Android 11.0模擬器

測試程式碼(https://github.com/fishforest/AndroidDemo/tree/main/app/src/main/java/com/example/androiddemo/storagepermission)

作者:fishforest

連結:https://www.jianshu.com/p/d5573e312bb8

關注我獲取更多知識或者投稿