分区存储介绍
在Android10以前,只要程序获得了READ_EXTERNAL_STORAGE权限,就可以随意读取外部的存储公有目录。只要程序获得了WRITE_EXTERNAL_STORAGE权限,就可以随意在写入外部存储的公有目录上新建文件或文件夹
于是Google在Android10中提出了分区存储,意在限制程序对外部存储中公有目录的使用。
分区存储对内部存储私有目录和外部存储私有目录都没有影响
简单来说就是,在Android10中,
- 对于私有目录的读写没有变化,仍然可以使用File那一套,且不需要任何权限。
- 对于公有目录的读写,则必须使用MediaStore提供的API或是SAF(存储访问框架)
在后续的Android11中,没有了Android10中的兼容模式,不能使用File I/O来读取App外置存储的目录
使用分区存储的应用对自己创建的文件始终拥有读/写权限,无论文件是否位于应用的私有目录内,所以,如果应用仅保存和访问自己创建的文件,则无需请求获得READ_EXTERNAL_STORAGE或WRITE_EXTERNAL_STORAGE权限
如果要访问其他应用创建的文件,则需要READ_EXTERNAL_STORAGE权限。并且仍然只能使用MediaStore提供的API或是SAF访问。
这里需要注意的是,MediaStore提供的API只能访问图片、视频、音频,如果需要访问其它任意格式的文件,需要使用SAF,它会调用系统内置的文件浏览器供用户自主选择文件
SAF是Android在 4.4中引入的一套存储访问框架(Storage Access Framework),借助 SAF,用户可轻松在其所有首选文档存储提供程序中浏览并打开文档、图像及其他文件。用户可通过易用的标准界面,以统一方式在所有应用和提供程序中浏览文件,以及访问最近使用的文件。
Android Q规定了App有两种存储空间模式视图:Legacy View、Filtered View
- Legacy View(兼容模式) 跟以前Android Q一样,App访问Sdcard一样
- Filtered View(沙箱模式)
App只能直接访问App-specific目录文件,没有权限访问App-specific外的文件。访问其他目录,只能通过MediaStore、SAF、或者其他App提供ContentProvider访问
Scoped Storage的存储空间
- 公共目录:Downloads、Documents、Pictures、DCIM、Movies、Music、Ringtones
- 公共目录的文件在App卸载后,不会删除
- 可以通过SAF、MediaStore接口访问
- App-specific目录
- 对于Filtered View App,App-specific目录只能自己直接访问
- App卸载,数据会清除
运行视图
App运行视图
系统通过下列方式确定App的运行模式:
- App的TargetSDK>=Q,默认为Filtered View
- App的TargetSDK<Q,声明了READ_EXTERNAL_STORAGE或者WRITE_EXTERNAL_STORAGE权限,默认Legacy View
- 应用通过AndroidManifest.xml设置 requestLegacyExternalStorage
- true:表示兼容模式Legacy View
- false:表示沙箱模式 Filtered View
判断当前App的运行模式
判断当前App运行的是什么模式,可以通过Environment提供的API进行判断
Environment.isExternalStorageLegacy()
MediaStore的Uri定义
MediaStore提供了下列几种类型的访问Uri,通过查找对应Uri数据,达到访问的目的。
- Audio
- Internal:MediaStore.Audio.Media.INTERNAL_CONTENT_URI
- content://media/internal/audio/media
- External:MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
- content://media/external/audio/media
- 可移动存储:MediaStore.Audio.Media.getContentUri
- content://media/<volumeName>/audio/media
- Video
- Internal:MediaStore.Video.Media.INTERNAL_CONTENT_URI
- content://media/internal/video/media
- External:MediaStore.Video.Media.EXTERNAL_CONTENT_URI
- content://media/external/video/media
- 可移动存储:MediaStore.Video.Media.getContentUri
- content://media/<volumeName>/video/media
- Image
- Internal: MediaStore.Images.Media.INTERNAL_CONTENT_URI
- content://media/internal/images/media。
- External: MediaStore.Images.Media.EXTERNAL_CONTENT_URI
- content://media/external/images/media。
- 可移动存储: MediaStore.Images.Media.getContentUri
- content://media/<volumeName>/images/media。
- File
- MediaStore. Files.Media.getContentUri
- content://media/<volumeName>/file。
- Downloads
- Internal: MediaStore.Downloads.INTERNAL_CONTENT_URI
- content://media/internal/downloads。
- External: MediaStore.Downloads.EXTERNAL_CONTENT_URI
- content://media/external/downloads。
- 可移动存储: MediaStore.Downloads.getContentUri
- content://media/<volumeName>/downloads。
获取所有的Volume
我们还可以使用getContentUri获取所有<volumeName>
Uri跟公共目录关系
MediaProvider对于App存放到公共目录文件,通过ContentResolver insert方法中Uri来确定
权限
MediaStroe通过不同Uri,为用户提供了增、删、改方法,权限对应如下
- 由上表可以看出没在操作共享存储空间时,获取的权限不同可以对应不同的操作共享存储空间的方式
- WRITE_EXTERNAL_STORAGE:获取这个权限,可以修改所有的app新建的文件,但是都需要授予权限
- READ_EXTERNAL_STORAGE:获取这个权限,可以读取所有的app新建的文件,不能修改其他App新建的文件
- 什么权限都不获取的话,只能读取、修改自己app新建的文件
操作共享存储空间,读写公共目录
通过Media定义的URI
新建文件(通过ContentResolver的insert接口,使用不同的Uri选择存储到不同的目录)
/**
* 通过SAF创建文件文件夹
*
* @param view
*/
public void createSAF(View view) {
Uri uri = MediaStore.Files.getContentUri("external");
ContentResolver contentResolver = this.getContentResolver();
//定义path
String path = Environment.DIRECTORY_DOWNLOADS + "/wx";
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Downloads.RELATIVE_PATH, path);
contentValues.put(MediaStore.Downloads.DISPLAY_NAME, "wxPic");
contentValues.put(MediaStore.Downloads.TITLE, "it's title");
Uri resultUri = contentResolver.insert(uri, contentValues);
if (resultUri != null) {
Toast.makeText(this, "创建文件夹成功", Toast.LENGTH_SHORT).show();
}
}
新建一张图片
/**
* 插入一张图片
*
* @param view
*/
public void insertImage(View view) {
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.moto);
String disPlayPicName = System.currentTimeMillis() + "123.jpg";
String mimeType = "image/jpeg";
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, disPlayPicName);
contentValues.put(MediaStore.Images.ImageColumns.MIME_TYPE, mimeType);
contentValues.put(MediaStore.Images.ImageColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + File.separator + "wx" + File.separator);
imageUri = getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues);
OutputStream outputStream = null;
try {
outputStream = getContentResolver().openOutputStream(imageUri);
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
outputStream.close();
FileDescriptor fileDescriptor = getContentResolver().openFileDescriptor(imageUri, "r").getFileDescriptor();
} catch (Exception e) {
e.printStackTrace();
}
Toast.makeText(this, "添加图片成功", Toast.LENGTH_SHORT).show();
}
修改照片
/**
* 修改数据
*
* @param view
*/
public void updateImage(View view) {
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, "test.jpg");
int update = getContentResolver().update(imageUri, contentValues, null, null);
if (update > 0) {
Toast.makeText(this, "修改成功", Toast.LENGTH_SHORT).show();
}
}
查询数据
/**
* 查询数据
*
* @param view
*/
public void query(View view) {
//获取URI
Uri external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
//创建selection
String selection = MediaStore.Images.Media.DISPLAY_NAME + "=?";
String[] arg = new String[]{"test.jpg"};
Cursor cursor = getContentResolver().query(external, null, selection, arg, null);
if (cursor != null && cursor.moveToFirst()) {
Uri idUri = ContentUris.withAppendedId(external, cursor.getLong(0));
Toast.makeText(this, "获取成功" + idUri, Toast.LENGTH_SHORT).show();
cursor.close();
}
}
删除数据
/**
* 删除图片
*
* @param view
*/
public void deleteImage(View view) {
int delete = getContentResolver().delete(imageUri, null);
if (delete > 0) {
Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show();
}
}
关于RecoverableSecurityException异常
当我们删除其他应用创建的资源时会报出RecoverableSecurityException异常,我们可以捕获这个异常然后提示给与uri修改或删除的权限
private fun deleteImage(imageUri: Uri, adapterPosition: Int) {
var row = 0
try {
// Android 10+中,如果删除的是其它应用的Uri,则需要用户授权
// 会抛出RecoverableSecurityException异常
row = contentResolver.delete(imageUri, null, null)
} catch (securityException: SecurityException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val recoverableSecurityException =
securityException as? RecoverableSecurityException
?: throw securityException
pendingDeleteImageUri = imageUri
pendingDeletePosition = adapterPosition
// 我们可以使用IntentSender向用户发起授权
requestRemovePermission(recoverableSecurityException.userAction.actionIntent.intentSender)
} else {
throw securityException
}
}
if (row > 0) {
Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show()
pictureAdapter.deletePosition(adapterPosition)
}
}
private fun requestRemovePermission(intentSender: IntentSender) {
startIntentSenderForResult(intentSender, REQUEST_DELETE_PERMISSION,
null, 0, 0, 0, null)
}
private fun deletePendingImageUri(){
pendingDeleteImageUri?.let {
pendingDeleteImageUri = null
deleteImage(it,pendingDeletePosition)
pendingDeletePosition = -1
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK &&
requestCode == REQUEST_DELETE_PERMISSION
) {
// 执行之前的删除逻辑
deletePendingImageUri()
}
}
如果想获取Download文件夹下的某个非媒体文件怎么办
例如PDF,PDF为非媒体类文件,因此我们不能通过MediaStore来获取,对于这种其他类型的文件,一般使用SAF来让用户选择
private fun selectPdfUseSAF() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "application/pdf"
// 我们需要使用ContentResolver.openFileDescriptor读取数据
addCategory(Intent.CATEGORY_OPENABLE)
}
startActivityForResult(intent, REQUEST_OPEN_PDF)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
REQUEST_OPEN_PDF -> {
if (resultCode == Activity.RESULT_OK) {
data?.data?.also { documentUri ->
val fileDescriptor =
contentResolver.openFileDescriptor(documentUri, "r") ?: return
// 现在,我们可以使用PdfRenderer等类通过fileDescriptor读取pdf内容
Toast.makeText(this, "pdf读取成功", Toast.LENGTH_SHORT).show()
}
}
}
}
}
如何创建任意类型的文件
我们也推荐使用SAF让用户自己去创建,IntentAction为:ACTION_CREATE_DOCUMENT
访问App-specific目录
访问app-specific分为两种情况,一种是访问App自身App-specific目录,第二是访问其他App目录文件
App访问自身App-specific目录
Android Q,App如果启动了Filtered View,那么只能直接访问自己目录的文件:
Environment.getExternalStorageDirectory、getExternalStoragePublicDirectory这些接口在Android Q上废弃,App是Filtered View,无法直接访问这个目录。
通过File(“/sdcard/”)访问App是Filtered View,无法直接访问这个目录。
-
获取App-specific目录
- 获取Media接口:getExternalMediaDirs
- 获取Cache接口:getExternalCacheDirs
- 获取Obb接口:getObbDirs
- 获取Data接口:getExternalFilesDirs
App访问App-sepecific目录内部的多媒体文件
- App自身访问,和App访问自身的App-soecific目录一样
- 其他App访问
- 默认情况下,Media Scanner不会扫描App-specific里的多媒体文件,如果需要扫描通过MediaScannerConnection.scanFile添加到MediaProvider数据库中,访问方式和访问共享存储空间方式一样
- App通过创建ContentProvider共享出去
App访问其他App目录文件
App是FilteredView,其他App无法直接访问当前App私有目录,需要通过以下方法:
通过SAF文件
- App自定义DocumentsProvider
- 访问App通过ACTION_OPEN_DOCUMENT启动SAF浏览