前言
存儲適配系列文章:
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、存儲基本知識
先來看看存儲區域劃分:
其中,以下目錄無需存儲權限即可訪問:
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。