背景
以前涡尘,Android 開發(fā)者習(xí)慣在根目錄建一個(gè)自己應(yīng)用的文件夾,用于存放應(yīng)用的數(shù)據(jù)响迂。這樣會(huì)導(dǎo)致用戶卸載后考抄,應(yīng)用數(shù)據(jù)不會(huì)隨之刪除莱预。導(dǎo)致手機(jī)文件特別混亂苍日,長期占用空間,而且容易泄露用戶隱私。
其實(shí) Android 早就提供了 getCacheDir()皇拣、getFilesDir()苦始、getExternalFilesDir()擂达、getExternalCacheDir() 等 API 供開發(fā)者使用蹋肮,但是開發(fā)者為了方便,沒有去用丢早。
為了解決這個(gè)問題姨裸,從 Android 10 開始,Google 添加了一個(gè)新特性 Scoped Storage怨酝,我們稱之為分區(qū)存儲(chǔ)傀缩,也可以稱為沙盒。
在 Android 10 上凫碌,仍然可以通過以下兩種手段避開分區(qū)存儲(chǔ):
- targetSdkVersion 設(shè)成 29 以下
- 在 manifest 中設(shè)置 android:requestLegacyExternalStorage="true"
在 Android 11 上扑毡,requestLegacyExternalStorage 會(huì)失效,沒有效果盛险。但是又增加了 preserveLegacyExternalStorage 屬性瞄摊,對于覆蓋安裝的應(yīng)用還能繼續(xù)用,但是新應(yīng)用不能用苦掘。
至于 targetSdkVersion换帜,上傳到 Google Play 的應(yīng)用,Google 要求必須設(shè)成 30 及以上鹤啡。
分區(qū)存儲(chǔ)目錄
沙盒目錄
通過 getExternalFilesDir() 等獲取到的目錄惯驼,隨著 App 卸載會(huì)被刪除。
不過可以在 manifest 中設(shè)置 android:hasFragileUserData="true" 讓用戶選擇是否刪除递瑰。公共目錄
DCIM祟牲、Photos、Images抖部、Videos说贝、Audio、Downloads 等目錄慎颗, App 卸載后會(huì)保留乡恕。
訪問公共目錄
重點(diǎn)說下公共目錄,沙盒目錄就不詳細(xì)介紹了俯萎,沙盒目錄可以通過系統(tǒng)提供的接口直接獲取傲宜,可以直接通過路徑讀寫,也不需要定義任何讀寫權(quán)限夫啊,很簡單函卒。
訪問公共目錄需要通過 MediaStore 或者 Storage Access Framework(以下簡稱 SAF)。媒體文件(圖片撇眯,音頻报嵌,視頻)能通過 MediaStore 和 SAF 兩種方式訪問躁愿,非媒體文件只能通過 SAF 訪問。
MediaStore
關(guān)于 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE 讀寫權(quán)限沪蓬,MediaStore 訪問應(yīng)用自身存放到公共目錄下的文件不需要申請權(quán)限(但是如果應(yīng)用卸載后重裝,之前保存的文件將不屬于本應(yīng)用創(chuàng)建的文件)来候,而如果要訪問其他應(yīng)用保存到公共目錄下的文件則需要申請權(quán)限跷叉。
MediaStore 通過 Uri 操作文件。
各個(gè)目錄的 Uri 如下:
類型 | Uri | Uri 常量 | 默認(rèn)路徑 |
---|---|---|---|
Image | content://media/external/images/media | MediaStore.Images.Media.EXTERNAL_CONTENT_URI | Pictures |
Video | content://media/external/video/media | MediaStore.Video.Media.EXTERNAL_CONTENT_URI | Movies |
Audio | content://media/external/audio/media | MediaStore.Audio.Media.EXTERNAL_CONTENT_URI | Music |
Download | content://media/external/downloads | MediaStore.Downloads.EXTERNAL_CONTENT_URI | Download |
File | content://media/external/ | MediaStore.Files.getContentUri(“external”) | Documents |
寫文件
// 從 Assets 讀取 Bitmap
Bitmap bitmap = null;
try {
bitmap = BitmapFactory.decodeStream(getAssets().open("test.jpg"));
} catch (IOException e) {
e.printStackTrace();
}
if (bitmap == null) return;
// 獲取保存文件的 Uri
ContentResolver contentResolver = getContentResolver();
ContentValues values = new ContentValues();
Uri insertUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
// 保存圖片到 Pictures 目錄下
if (insertUri != null) {
OutputStream os = null;
try {
os = contentResolver.openOutputStream(insertUri);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os);
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (os != null) {
os.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
上面的例子直接把圖片保存到 Pictures 根目錄营搅,如果要在 Pictures 下創(chuàng)建子目錄云挟,需要用到 RELATIVE_PATH(Android 版本 >= 10)。
修改上面的例子转质,把子目錄添加進(jìn) ContentValues:
ContentValues values = new ContentValues();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 指定子目錄园欣,否則保存到對應(yīng)媒體類型文件夾根目錄
values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES +"/test");
}
還可以向 ContentValues 中添加其他信息,如:文件名休蟹,MIME 等
繼續(xù)修改上面的例子:
ContentValues values = new ContentValues();
// 獲取保存文件的 Uri
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
// 指定保存的文件名沸枯,如果不設(shè)置,則系統(tǒng)會(huì)取當(dāng)前的時(shí)間戳作為文件名
values.put(MediaStore.Images.Media.DISPLAY_NAME, "test_" + System.currentTimeMillis() + ".png");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 指定子目錄赂弓,否則保存到對應(yīng)媒體類型文件夾根目錄
values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/test");
}
刪除自己應(yīng)用創(chuàng)建的文件
獲取到對應(yīng)的 Uri 之后 contentResolver.delete(uri,null,null) 即可绑榴。
查詢自己應(yīng)用創(chuàng)建的文件
// 查詢
ContentResolver contentResolver = getContentResolver();
Cursor cursor = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
new String[]{
MediaStore.Images.Media._ID,
MediaStore.Images.Media.WIDTH,
MediaStore.Images.Media.HEIGHT
},
MediaStore.Images.Media._ID + " > ? ", new String[]{"100"},
MediaStore.Images.Media._ID + " DESC"
);
// 得到所有的 Uri
List<Uri> filesUris = new ArrayList<>();
while (cursor.moveToNext()) {
int index = cursor.getColumnIndex(MediaStore.Images.Media._ID);
Uri uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cursor.getLong(index)
);
filesUris.add(uri);
}
cursor.close();
// 通過 Uri 獲取具體內(nèi)容并顯示到界面上
ParcelFileDescriptor pfd = null;
try {
pfd = contentResolver.openFileDescriptor(filesUris.get(0), "r");
if (pfd != null) {
Bitmap bitmap = BitmapFactory.decodeFileDescriptor(pfd.getFileDescriptor());
((ImageView) findViewById(R.id.image)).setImageBitmap(bitmap);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (pfd != null) {
try {
pfd.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
查詢其他應(yīng)用創(chuàng)建的文件
如上文所訴,訪問自己應(yīng)用創(chuàng)建的文件不需要 READ_EXTERNAL_STORAGE 權(quán)限盈魁。以上代碼獲取到的 filesUris 只包含本應(yīng)用之前創(chuàng)建的文件翔怎。
如果需要連其他應(yīng)用的文件一起獲取,則申請下 READ_EXTERNAL_STORAGE 權(quán)限即可杨耙。
修改其他應(yīng)用創(chuàng)建的文件
同理赤套,需要申請 WRITE_EXTERNAL_STORAGE 權(quán)限。
但是珊膜,即便申請了 WRITE_EXTERNAL_STORAGE 權(quán)限之后容握,還是會(huì)報(bào)如下異常:
android.app.RecoverableSecurityException: xxx has no access to content://media/external/images/media/100
這是因?yàn)檫€需要向用戶申請修改的權(quán)限。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
delete();
} catch (RecoverableSecurityException e) {
e.printStackTrace();
// 彈出對話框辅搬,向用戶申請修改其他應(yīng)用文件的權(quán)限
requestConfirmDialog(e);
}
}
private void delete() {
Uri uri = Uri.parse("content://media/external/images/media/100");
getContentResolver().delete(uri, null, null);
}
@RequiresApi(api = Build.VERSION_CODES.Q)
private void requestConfirmDialog(RecoverableSecurityException e) {
try {
startIntentSenderForResult(
e.getUserAction().getActionIntent().getIntentSender()
, 0, null, 0, 0, 0, null);
} catch (IntentSender.SendIntentException ex) {
ex.printStackTrace();
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == RESULT_OK){
delete();
}
}
將文件下載到 Download 目錄
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
private void downloadApkAndInstall(String downloadUrl, String apkName) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// 使用原始方式
} else {
new Thread(() -> {
BufferedInputStream bis = null;
BufferedOutputStream bos = null;
try {
URL url = new URL(downloadUrl);
URLConnection urlConnection = url.openConnection();
InputStream is = urlConnection.getInputStream();
bis = new BufferedInputStream(is);
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DISPLAY_NAME, apkName);
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
ContentResolver contentResolver = getContentResolver();
Uri uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
OutputStream os = contentResolver.openOutputStream(uri);
bos = new BufferedOutputStream(os);
byte[] buffer = new byte[1024];
int bytes = bis.read(buffer);
while (bytes >= 0) {
bos.write(buffer, 0, bytes);
bos.flush();
bytes = bis.read(buffer);
}
runOnUiThread(() -> installAPK(uri));
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (bis != null) bis.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (bos != null) bos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
private void installAPK(Uri uri) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(uri, "application/vnd.android.package-archive");
startActivity(intent);
}
SAF
SAF 在 Android 4.4 就支持了唯沮。
SAF 通過系統(tǒng)提供的標(biāo)準(zhǔn)化 UI 瀏覽和修改手機(jī)中的文件,如下圖
ACTION_CREATE_DOCUMENT 創(chuàng)建文件
private void createFile() {
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
intent.putExtra(Intent.EXTRA_TITLE, "test_create.png");
startActivityForResult(intent, WRITE_REQUEST_CODE);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (data == null || resultCode != RESULT_OK) return;
if (requestCode == WRITE_REQUEST_CODE) {
Log.d("tianjf", "write uri : " + data.getData());
}
}
運(yùn)行之后堪遂,會(huì)啟動(dòng)標(biāo)準(zhǔn)文件管理器 UI 保存文件介蛉。
寫文件不需要申請寫權(quán)限。
ACTION_OPEN_DOCUMENT 讀文件
因?yàn)橛锌赡茏x取其他應(yīng)用創(chuàng)建的文件溶褪,所以需要申請讀權(quán)限币旧。
protected void readFiles() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
startActivityForResult(intent, READ_REQUEST_CODE);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (data == null || resultCode != RESULT_OK) return;
if (requestCode == READ_REQUEST_CODE) {
Log.d("tianjf", "read uri : " + data.getData());
process(data.getData());
}
}
private void process(Uri uri) {
String[] selectionArgs = new String[]{DocumentsContract.getDocumentId(uri).split(":")[1]};
Cursor cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
null, MediaStore.Images.Media._ID + "=?",
selectionArgs, null);
if (null != cursor) {
if (cursor.moveToFirst()) {
int index = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
if (index > -1) {
String path = cursor.getString(index);
Log.d("tianjf", "onActivityResult path=" + path + ";id=" + selectionArgs[0]);
}
}
cursor.close();
}
}
ACTION_OPEN_DOCUMENT_TREE 讀取文件夾
protected void readFolder() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, READ_FOLDER_REQUEST_CODE);
}
// 選取文件夾然后在文件夾中創(chuàng)建子文件夾和文件
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (data == null || resultCode != RESULT_OK) return;
if (requestCode == READ_FOLDER_REQUEST_CODE) {
Log.d("tianjf", "read folder uri : " + data.getData());
DocumentFile selectedFolder = DocumentFile.fromTreeUri(this, data.getData());
DocumentFile newFolder = selectedFolder.createDirectory("newFolder");
DocumentFile newFile = newFolder.createFile("text/plain", "test.txt");
try {
getContentResolver().openOutputStream(newFile.getUri()).write("Hello".getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}