一、存儲基本知識
先來看看存儲區(qū)域劃分:
其中,以下目錄無需存儲權限即可訪問:
1、App自身的內部存儲
2、App自身的自帶外部存儲-私有目錄
剩下的都需要申請存儲權限,Android 10.0前后對于存儲作用域訪問的區(qū)別就體現(xiàn)在如何訪問剩余這些目錄內的文件。
重點在自帶外部存儲之共享存儲空間和其它目錄
2、Android 10.0 之前訪問方式
繼續(xù)細分為Android 6.0 之前和之后。
Android 6.0 之前訪問方式
Android 6.0 之前是無需申請動態(tài)權限的,在AndroidManifest.xml 里聲明存儲權限:
<?uses-permissionandroid:name=?"android.permission.WRITE_EXTERNAL_STORAGE"/>?
<?uses-permissionandroid:name=?"android.permission.READ_EXTERNAL_STORAGE"/>
就可以訪問共享存儲空間、其它目錄下的文件了。
Android 6.0 之后的訪問方式動態(tài)申請權限
Android 6.0 后需要動態(tài)申請權限,除了在AndroidManifest.xml 里聲明存儲權限外,還需要在代碼里動態(tài)申請。
//檢查權限,并返回需要申請的權限列表
?private List?checkPermission(?Context context, String[] checkList)?{?
? ? ?List list =?new ArrayList();
? ? ?for(?inti =?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(?intrequestCode, @NonNull String[] permissions, @NonNull?int[] grantResults?)?{?
if(requestCode ==?100) {?
? ? ? for(?inti =?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 =?newString[]{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(?newString[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
? ? ? ? ? longid = 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:存儲訪問框架。相當于系統(tǒng)內置了文件選擇器,通過它可以拿到想要訪問的文件信息。
同樣的以獲取圖片為例:
private void startSAF{?
? ? ? ?Intent intent =?newIntent(Intent.ACTION_OPEN_DOCUMENT);?
? ? ? ?intent.addCategory(Intent.CATEGORY_OPENABLE);
? ? ? ?//選擇圖片intent.setType(?"image/jpeg");?
? ? ? ?startActivityForResult(intent,?100);?
}
@Override
protected void? onActivityResult(?intrequestCode,?intresultCode, @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/目錄下直接創(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();
? ? ? }
}
可以看出,/sdcard/myDir/目錄創(chuàng)建成功。
二、通過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、動態(tài)申請存儲權限
3、通過路徑或者Uri訪問文件
3、Android 10.0 訪問方式變更
為什么要變更
你可能已經(jīng)發(fā)現(xiàn)了上面訪問方式的弊端,比如我們能夠直接在/sdcard/目錄下創(chuàng)建目錄/文件。事實上,很多App就是這么干的,看圖說話:
可以看出/sdcard/目錄下,如淘寶、qq、qq瀏覽器、微博、支付寶等都自己建了目錄。
這么看來,導致目錄結構很亂,而且App卸載后,對應的目錄并沒有刪除,于是就是遺留了很多"垃圾"文件,久而久之不處理,用戶的存儲空間越來越小。
總結弊端如下:
1、在設置里"Clear storage"或者"Clear cache"并不能刪除該目錄下的文件
2、卸載App也不能刪除該目錄下的文件
3、App可以隨意修改其它目錄下的文件,如修改別的App創(chuàng)建的文件等,不安全
你也許會問,為什么要在/sdcard/目錄下新建自己的目錄呢?
大體有以下兩個原因:
1、此處新建的目錄不會被設置里的App存儲用量統(tǒng)計,讓用戶"看起來"自己的App占用的存儲空間很小
2、方便操作文件
如何變更
面對眾多App不講"碼德"隨意新建目錄/文件的現(xiàn)象,Google在Android 10.0上重拳出擊了。
引入Scoped Storage
翻譯成中文有好幾個版本:作用域存儲、分區(qū)存儲、沙盒存儲。
具體中文翻譯不重要,下面以分區(qū)存儲指代。
分區(qū)存儲原理:
1、App訪問自身內部存儲空間、訪問外部存儲空間-App私有目錄不需要任何權限(這個與Android 10.0之前一致)
2、外部存儲空間-共享存儲空間、外部存儲空間-其它目錄 App無法通過路徑直接訪問,不能新建、刪除、修改目錄/文件等
3、外部存儲空間-共享存儲空間、外部存儲空間-其它目錄 需要通過Uri訪問
分區(qū)存儲的變更在于第二點、第三點。
為什么Uri能夠訪問
先來看為什么通過路徑無法直接訪問。
我們知道訪問文件最終是通過構造InputStream/OutputStream來實現(xiàn)的,以InputStream為例,看看其構造方法:
#FileInputStream.java
//文件描述符?
private final FileDeor fd;?
public FileInputStream(?File file)? throws? FileNotFoundException?{?
? ? ? String? name? =? file !=?null ? file.getPath() :?null;
? ? ? ? ...
? ? ? //傳入name,構造FileDeor
? ? ? //沒有權限訪問,則此處拋出異常
? ? ? ?fd = IoBridge.open(name, O_RDONLY);
? ? ? ?...
}
可以看出,要想FileInputStream 能讀入文件,核心是需要構造FileDeor,而對于Android 10.0,直接通過路徑構造FileDeor 會拋出異常。
那么我們自然會想到,有沒有通過構造好的FileDeor 來生成FileInputStream對象,進而使用read(xx)方法讀取數(shù)據(jù)。
還真有,請看:通過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是被允許的
? ? ? ? ? ? ?AssetFileDeor fd = openAssetFileDeor(uri,?"r",?null);
? ? ? ? ? ? ?try{
? ? ? ? ? ? ?//反過來創(chuàng)建 InputStream?
? ? ? ? ? ? ?return fd !=?null? fd.createInputStream() :?null;?
? ? ? ? ? ? }?catch(IOException e) {?
? ? ? ? ? ? throw? newFileNotFoundException(?"Unable to create stream");?
? ? ? ? ? ?}
? ? ? }
}
AssetFileDeor 持有ParcelFileDeor 引用,而ParcelFileDeor 持有FileDeor 引用。
同理也適用于FileOutputStream。因此,通過Uri能夠訪問文件。
四、如何適配Android 10.0
從以上分析可知,適配Android 10.0 有點麻煩,問題來了有沒有簡單的方法繞過檢測。
第一種方法
1、Android 10.0 及其以后才會有分區(qū)存儲功能,只要Android 設備不升級系統(tǒng)到Android 10.0以后,就不會有問題。
2、可能覺得這是句廢話,其實不然,有些定制的設備系統(tǒng)一般都不會升級的。
如果不能使用第一種方法,還可以采用第二種方法。
第二種方法
1、Android 一般升級功能的時候都會配合targetSdkVersion使用。只要targetSdkVersion<=28,分區(qū)存儲功能就不會開啟。
有關targetSdkVersion 作用請移步:targetSdkVersion、compileSdkVersion、minSdkVersion作用與區(qū)別
如果第二種方法也不能使用,則還有第三種方法。
第三種方法
在AndroidManifest.xml 里application標簽下添加:
android:requestLegacyExternalStorage="true" 可禁用分區(qū)存儲
從長遠的角度看,以上三個方法都不是一勞永逸的方法,其中第二種、第三種方法是Google 留給App開發(fā)者適配的緩沖時間。
對于第二種方法:
Google 在App上架App Store 時候可能會強制要求升級targetSdkVersion,因此該方法不保險。
對于第三種方法:
在Android 11會忽略該字段,強制開啟分區(qū)存儲,該字段也不怎么靠譜。
因此,最終還是需要老老實實按照Google 的要求適配Android 10.0,下篇將重點分析Android 10.0/11 該如何來適配。
本文基于Android 10.0。
五、MediaStore 基本知識
上篇已經(jīng)分析得出結論,Android 10.0 存儲訪問方式變更地方在于:
自帶外部存儲-共享存儲空間和自帶外部存儲-其它目錄
以上兩個地方不能通過路徑直接訪問文件,而是需要通過Uri訪問。
共享存儲空間
共享存儲空間存放的是圖片、視頻、音頻等文件,這些資源是公用的,所有App都能夠訪問它們。
系統(tǒng)里有external.db數(shù)據(jù)庫,該數(shù)據(jù)庫里有files表,該表里存放著共享文件的諸多信息,如圖片有寬高,經(jīng)緯度、存放路徑等,視頻寬高、時長、存放路徑等。而文件真正存放的地方在于共享存儲空間。
1、保存圖片到相冊
當App1保存圖片到相冊時,簡單流程如下:
1、將路徑信息寫入數(shù)據(jù)庫里,并獲取Uri
2、通過Uri構造輸出流
3、將該圖片保存在/sdcard/Pictures/目錄下
2、從相冊獲取圖片
當App2從相冊獲取圖片時,簡單流程如下:
1、先查詢數(shù)據(jù)庫,找到對應的圖片Cursor
2、從Cursor里構造Uri
3、從Uri構造輸入流讀取圖片
以上以圖片為例簡單分析了共享存儲空間文件的寫入與讀取,實際上對于視頻、音頻步驟亦是如此。
MediaStore作用
共享存儲空間里存放著圖片、視頻、音頻、下載的文件,App獲取或者插入文件的時候怎么區(qū)分這些類型呢?
這個時候就需要MediaStore,來看看MediaStore.java
可以看出其內部有Audio、Images等內部類,這些內部類里記錄著files表的各個字段名,通過構造這些參數(shù)就可以插入相應的字段值以及獲取對應的字段值。
MediaStore 實際上就是相當于給各個字段起了別名,我們編碼的時候更容易記住與使用:
//列舉一些字段:
//圖片類型? MediaStore.Images.Media.MIME_TYPE
//音頻時長? MediaStore.Audio.Media.DURATION
//視頻時長? MediaStore.Video.Media.DURATION
//等等,還有很多
MediaStore和Uri聯(lián)系
比如想要查詢共享存儲空間里的圖片文件:
Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,?null,?null,?null,?null);
MediaStore.Images.Media.EXTERNAL_CONTENT_URI 意思是指定查詢文件的類型是圖片,并構造成Uri對象,Uri實現(xiàn)了Parcelable,能夠在進程間傳遞。
接收方(另一個進程收到后),匹配Uri,解析出對應的字段,進行具體的操作。
當然,MediaStore是系統(tǒng)提供的方便操作共享存儲空間的類,若是自己寫ContentProvider,則也可以自定義類似MediaStore的類用來標記自己的數(shù)據(jù)庫表的字段。
六、通過Uri讀取和寫入文件
既然不能通過路徑直接訪問文件,那么來看看如何通過Uri訪問文件。在上篇文章里提到過:?Uri可以通過MediaStore或者SAF獲取。(此處需要注意的是:雖然也可以通過文件路徑直接構造Uri,但是此種方式構造的Uri是沒有權限訪問文件的)
先來看看通過SAF獲取Uri。
從Uri讀取文件
現(xiàn)在/sdcard/目錄下存在一個文件名為:mytest.txt。
該文件內容是:
傳統(tǒng)的直接讀取mytest.txt方法:
//從文件讀取
private? void? readFile(?String filePath)?{?
? ? ?if( TextUtils.isEmpty( filePath) ) return;
? ? try{?
? ? ? ? ? ?File file =?newFile(filePath);?
? ? ? ? ? ?FileInputStream fileInputStream =?newFileInputStream(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 =?newString(readContent);?
? ? ? ? ? ? ? ? ? ?Log.d(?"test",?"read content:"+ content.substring(?0, readLen));?
? ? ? ? ? ? ? }
? ? ? ? ? }
? ? ? ? ? ?fileInputStream.close;
? ? ? }? catch(Exception e) {
? ? ? }
}
開啟分區(qū)存儲功能后,這種方法是不可取的,會報權限錯誤。
而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(?intrequestCode,?intresultCode, @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 =?newbyte[?1024];?
? ? ?int len =?0;?
? ? ? ? ? do{?
? ? ? ? ? ?//讀文件
? ? ? ? ? ?len = inputStream.read(readContent);
? ? ? ? ? ? if(len !=?-1) {
? ? ? ? ? ? Log.d(?"test",?"read content:"+?newString(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寫入文件
繼續(xù)來看看寫的過程,現(xiàn)在需要往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,發(fā)現(xiàn)正好是之前寫入的內容,說明寫入成功。
七、通過Uri 獲取圖片和插入相冊
上面列舉出了其它目錄下文件的讀寫,方法是通過SAF拿到Uri。
SAF好處是:
系統(tǒng)提供了文件選擇器,調用者只需要指定想要讀寫的文件類型,比如文本類型、圖片類型、視頻類型等,選擇器就會過濾出相應文件以供選擇。接入方便,選擇簡單。
想想另一種場景:
想要自己實現(xiàn)相冊選擇器,那么就需要獲得共享存儲空間下的文件信息。此種場景下使用SAF是無法做到的。
因此問題的關鍵是:?如何批量獲得共享存儲空間下圖片/視頻的信息?
答案是:ContentResolver+ContentProvider+MediaStore(ContentProvider對于調用者是透明的)。
以圖片為例,分析插入與查詢方式。
插入相冊
來看看圖片的插入過程:
//fileName為需要保存到相冊的圖片名
private void? insert2Album(InputStream inputStream,?StringfileName) {
? ? ? 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{?
? ? ? ? ? ? ? Stringd? stPath = Environment.getExternalStorageDirectory + File.separator + Environment.DIRECTORY_PICTURES?+ File.separator + fileName;
? ? ? ? ? ?? ???//DATA字段在Android 10.0 之后已經(jīng)廢棄
? ? ? ? ? ? ? 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)
以上兩步僅僅只是往數(shù)據(jù)庫里增加一條記錄,該記錄指向的新文件是空的,需要將圖片寫入到新文件。
而新文件位于/sdcard/Pictures/目錄下,該目錄是不能直接通過路徑訪問的,因此需要通過第二步返回的Uri進行訪問。
//? uri 關聯(lián)著待寫入的文件
//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{?
? ? ? ? ? ?//從輸入流里讀取數(shù)據(jù)
? ? ? ? ? ?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);?
? ? ?}
}
可以看出,目標文件關聯(lián)的Uri有了,還需要原始的輸入文件。
測試上述的插入方法:
private? void? testInsert(?)?{
? ? ? String picName =?"mypic.jpg";?
? ? ? try{
? ? ? ? ? ?File? externalFilesDir = getExternalFilesDir(?null);?
? ? ? ? ? ?File? file =?newFile(externalFilesDir, picName);?
? ? ? ? ? ?FileInputStream fis =?newFileInputStream(file);?
? ? ? ? ? ?insert2Album(fis, picName);
? ? ? ? }?catch( Exception e) {?
? ? ? Log.d(?"test", e.getLocalizedMessage);?
? ? ? ?}
}
其中,原始文件(圖片)存放于自帶外部存儲-App私有目錄,如下:
需要注意的是:
1、讀取原始文件需要權限,上述例子里的原始文件存放在自帶外部存儲-App私有目錄,因此本App可以使用路徑直接讀取
2、對于其他目錄則依然需要構造Uri讀取,如通過SAF獲取Uri
獲取圖片
同樣的,想要從系統(tǒng)相冊中獲取圖片,也需要通過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) 的參數(shù)取值還可以更豐富,但不是本篇重點,因此忽略了,實際使用過程中具體情況具體分析。
八、Android 11.0 權限申請
通過Uri訪問文件似乎已經(jīng)滿足了Android 10.0適配要求,但是仔細想想還是有不足之處:
1、共享存儲空間只能通過MediaStore訪問,以前流行的訪問方式是直接通過路徑訪問。比如自己做的相冊管理器,先遍歷相冊拿到圖片/視頻的路徑,然后再解析成Bitmap展示,現(xiàn)在需要先拿到Uri,再解析成Bitmap,多少有些不方便。此外,也許你依賴的第三方庫是直接通過路徑訪問文件的,而三方庫又沒有及時更新適配分區(qū)存儲,可能就會導致用不了相應的功能。
2、SAF雖然能夠訪問其它目錄的文件,但是每次都需要跳轉到新的頁面去選擇,當想要批量展示文件的時候,比如自己做的文件管理器,就需要列出當前目錄下有哪些目錄/文件,這個時候需要有權限遍歷/sdcard/目錄。顯然,SAF并不能勝任此工作。
Android 11.0考慮到上面的問題,因此做了新的優(yōu)化。
共享存儲空間-媒體文件訪問變更
媒體文件可以通過路徑直接訪問:
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開啟了分區(qū)存儲功能,當App運行在Android 10.0的設備上時,是沒法遍歷/sdcard/目錄的。而在Android 11.0上運行時是可以遍歷的,需要進行如下幾個步驟。
1、聲明管理權限
在AndroidManifest.xml添加權限聲明
<?uses-permissionandroid:name=?"android.permission.MANAGE_EXTERNAL_STORAGE"/>
2、動態(tài)申請所有文件訪問權限
private? void? testAllFiles{?
? ? ? ? ? ?//運行設備>=Android 11.0
? ? ? ? ? if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {?
? ? ? ? ? ? ? //檢查是否已經(jīng)有權限
? ? ? ? ? ? ? ? ? ? ?if(!Environment.isExternalStorageManager) {?
? ? ? ? ? ? ? ? ? ? ?//跳轉新頁面申請權限? ? ? ? ? ? ?
? ? ? ? ? ? ? ? ? ? ?startActivityForResult(?new? Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION),?101);?
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ?}
}
@Override
protected void? onActivityResult(?intrequestCode,?intresultCode, @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(?inti =?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 =?newFile(rootFile,?"mytest.txt");?
? ? ? ? ? ? ? ? FileOutputStream fos =?newFileOutputStream(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 這個權限的名字看起來很唬人,感覺就像是能夠操作所有文件的樣子,這不就是打破了分區(qū)存儲的規(guī)則了嗎?其實不然:
即使擁有了該權限,依然不能訪問內部存儲和外部存儲-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以上。
九、Android 10/11 存儲適配建議
好了,通過分析Android 10/11存儲適配方式,了解到了不同的系統(tǒng)需要如何進行適配,此時就需要一個統(tǒng)一的適配方案了。
適配核心
分區(qū)存儲是核心,App自身產(chǎn)生的文件應該存放在自己的目錄下:
/sdcard/Android/data/packagename/ 和/data/data/packagename/
這兩個目錄本App無需申請訪問權限即可申請,其它App無法訪問本App的目錄。
適配共享存儲
共享存儲空間里的文件需要通過Uri構造輸入輸出流訪問,Uri獲取方式有兩種:MediaStore和SAF。
適配其它目錄
在Android 11上需要申請訪問所有文件的權限。
具體做法
第一步
在AndroidManifest.xml里添加如下字段:
權限聲明:
?<?uses-permissionandroid:name=?"android.permission.WRITE_EXTERNAL_STORAGE"/>?
?<?uses-permissionandroid:name=?"android.permission.READ_EXTERNAL_STORAGE"/>?
?<?uses-permissionandroid:name=?"android.permission.MANAGE_EXTERNAL_STORAGE"/>
在<application/>標簽下添加如下字段:
android:requestLegacyExternalStorage=?"true"
第二步
如果需要訪問共享存儲空間,則判斷運行設備版本是否大于等于Android6.0,若是則需要申請WRITE_EXTERNAL_STORAGE 權限。拿到權限后,通過Uri訪問共享存儲空間里的文件。
如果需要訪問其它目錄,則通過SAF訪問
第三步
如果想要做文件管理器、病毒掃描管理器等功能。則判斷運行設備版本是否大于等于Android 6.0,若是先需要申請普通的存儲權。若運行設備版本為Android 10.0,則可以直接通過路徑訪問/sdcard/目錄下文件(因為禁用了分區(qū)存儲);若運行設備版本為Android 11.0,則需要申請MANAGE_EXTERNAL_STORAGE 權限。
以上是Android 存儲權限適配的全部內容。