背景
以前,Android 开发者习惯在根目录建一个自己应用的文件夹,用于存放应用的数据。这样会导致用户卸载后,应用数据不会随之删除。导致手机文件特别混乱,长期占用空间,而且容易泄露用户隐私。
其实 Android 早就提供了 getCacheDir()、getFilesDir()、getExternalFilesDir()、getExternalCacheDir() 等 API 供开发者使用,但是开发者为了方便,没有去用。
为了解决这个问题,从 Android 10 开始,Google 添加了一个新特性 Scoped Storage,我们称之为分区存储,也可以称为沙盒。
在 Android 10 上,仍然可以通过以下两种手段避开分区存储:
- targetSdkVersion 设成 29 以下
- 在 manifest 中设置 android:requestLegacyExternalStorage="true"
在 Android 11 上,requestLegacyExternalStorage 会失效,没有效果。但是又增加了 preserveLegacyExternalStorage 属性,对于覆盖安装的应用还能继续用,但是新应用不能用。
至于 targetSdkVersion,上传到 Google Play 的应用,Google 要求必须设成 30 及以上。
分区存储目录
-
沙盒目录
通过 getExternalFilesDir() 等获取到的目录,随着 App 卸载会被删除。
不过可以在 manifest 中设置 android:hasFragileUserData="true" 让用户选择是否删除。 -
公共目录
DCIM、Photos、Images、Videos、Audio、Downloads 等目录, App 卸载后会保留。
访问公共目录
重点说下公共目录,沙盒目录就不详细介绍了,沙盒目录可以通过系统提供的接口直接获取,可以直接通过路径读写,也不需要定义任何读写权限,很简单。
访问公共目录需要通过 MediaStore 或者 Storage Access Framework(以下简称 SAF)。媒体文件(图片,音频,视频)能通过 MediaStore 和 SAF 两种方式访问,非媒体文件只能通过 SAF 访问。
MediaStore
关于 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE 读写权限,MediaStore 访问应用自身存放到公共目录下的文件不需要申请权限(但是如果应用卸载后重装,之前保存的文件将不属于本应用创建的文件),而如果要访问其他应用保存到公共目录下的文件则需要申请权限。
MediaStore 通过 Uri 操作文件。
各个目录的 Uri 如下:
| 类型 | Uri | Uri 常量 | 默认路径 |
|---|---|---|---|
| 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 下创建子目录,需要用到 RELATIVE_PATH(Android 版本 >= 10)。
修改上面的例子,把子目录添加进 ContentValues:
ContentValues values = new ContentValues(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // 指定子目录,否则保存到对应媒体类型文件夹根目录 values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES +"/test"); }
还可以向 ContentValues 中添加其他信息,如:文件名,MIME 等
继续修改上面的例子:
ContentValues values = new ContentValues(); // 获取保存文件的 Uri values.put(MediaStore.Images.Media.MIME_TYPE, "image/png"); // 指定保存的文件名,如果不设置,则系统会取当前的时间戳作为文件名 values.put(MediaStore.Images.Media.DISPLAY_NAME, "test_" + System.currentTimeMillis() + ".png"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // 指定子目录,否则保存到对应媒体类型文件夹根目录 values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/test"); }
删除自己应用创建的文件
获取到对应的 Uri 之后 contentResolver.delete(uri,null,null) 即可。
查询自己应用创建的文件
// 查询 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 获取具体内容并显示到界面上 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(); } } }
查询其他应用创建的文件
如上文所诉,访问自己应用创建的文件不需要 READ_EXTERNAL_STORAGE 权限。以上代码获取到的 filesUris 只包含本应用之前创建的文件。
如果需要连其他应用的文件一起获取,则申请下 READ_EXTERNAL_STORAGE 权限即可。
修改其他应用创建的文件
同理,需要申请 WRITE_EXTERNAL_STORAGE 权限。
但是,即便申请了 WRITE_EXTERNAL_STORAGE 权限之后,还是会报如下异常:
android.app.RecoverableSecurityException: xxx has no access to content://media/external/images/media/100
这是因为还需要向用户申请修改的权限。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { try { delete(); } catch (RecoverableSecurityException e) { e.printStackTrace(); // 弹出对话框,向用户申请修改其他应用文件的权限 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 通过系统提供的标准化 UI 浏览和修改手机中的文件,如下图
ACTION_CREATE_DOCUMENT 创建文件
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()); } }
运行之后,会启动标准文件管理器 UI 保存文件。
写文件不需要申请写权限。
ACTION_OPEN_DOCUMENT 读文件
因为有可能读取其他应用创建的文件,所以需要申请读权限。
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); } // 选取文件夹然后在文件夹中创建子文件夹和文件 @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(); } }