11月份开始,上架Google Store的App, 必须把TargetSDKVersion指向30,也就是Android 11。意味着必须适配分区存储了。只上国内市场的不用管,因为TargetSDKVersion只要求28。
分区存储的优缺点:
优点一:解决在根目录下乱建文件夹。根目录下的文件夹在app卸载或清理缓存时,不会自动删除,导致根目录乱七八糟的,浪费用户空间。
优点二:想要默默的删除多媒体文件,必须每次都问用户,似乎更安全了。非多媒体文件通常包含更多的隐私,这类文件无法轻松获取了。
缺点一:在Android/data/和Android/obb/下的垃圾文件,再也无法通过垃圾清理软件删除了。很多app针对log文件,临时文件等,都没有做定期清理,会导致越来越庞大。
缺点二:由于再也无法通过MediaStore获取非多媒体文件,比如doc,ppt,pdf,txt等,只能通过存储访问框架翻来覆去的找,用户能轻松找到这些文件就怪了! QQ中,用户传过来的文件,要传过去的文件,找都找不到。所以微信海外版目前还是没支持分区存储,最近更新在10月份,而这个规则11月启用。
分区存储的影响:
外卡(外部存储卷), 只能访问所谓的共享文件夹,或者说公共目录。而且只能访问这些目录的多媒体文件(图片,视频,音频)。非多媒体文件只能通过系统文件选择器访问,无法像MediaStore一样列举了。
共享文件夹,有如下目录:
- 图片(包括照片和屏幕截图),存储在
DCIM/
和Pictures/
目录中。 - 视频,存储在
DCIM/
、Movies/
和Pictures/
目录中。 - 音频文件,存储在
Alarms/
、Audiobooks/
、Music/
、Notifications/
、Podcasts/
和Ringtones/
目录中,以及位于Music/
或Movies/
目录中的音频播放列表中。 - 下载的文件,存储在
Download/
目录中。Android 10及以上,可以在MediaStore.Downloads 找到。其他低版本是没有的。
注意事项:
- 在Picture/下只能创建图片文件。在Music/下只能创建音乐文件。在Movie/下只能创建视频文件。以上三个文件夹,都能创建文件夹。
- Download文件夹只能搜出多媒体文件,以及文件夹。
- Android 11上,即使TargetSDKVersion没有指向30,也不能访问的两个目录:Android/data/ ,Android/obb/。除非是自己app对应的目录。
-
在文件总表 MediaStore.Files 中。1. 启用了分区存储, 只能搜到照片、视频和音频文件,其他类型文件是没有的。2. 分区存储没启用,才可以搜索到所有类型的文件。
分区存储的适配:
针对Android 9及以前,依旧维持以前的做法。
针对Android 10依旧可以不打开分区存储。在 Manifest 中增加 <application android:requestLegacyExternalStorage = “true”>就可以豁免了。如果要检测是否已豁免,使用 Environment.isExternalStorageLegacy() 函数。
针对Android 11及以后, 在Google store市场上架, 必须适配分区存储。
在Android 10上打开分区存储,只能使用MediaStore API访问文件。从 Android 11 开始,具有 READ_EXTERNAL_STORAGE
权限的应用可以使用直接文件路径和原生库来读取设备的多媒体文件。
缓存非媒体文件时,你可以选择以下两类文件夹,其他app是无法访问到的。
- 小文件或包含敏感信息的文件:请使用 Context#getCacheDir()。在内卡,/data/0/packageName
- 大型文件或不含敏感信息的文件:请使用 Context#getExternalCacheDir()。在外卡,/Android/data/packageName
当应用卸载或者清除数据后,该区域文件会被删除。
分区存储的文件访问:
文件访问的方式有三种:1. MediaStore API,2. 存储访问框架(SAF),3. File API(android 11 共享文件夹,私有缓存文件夹)。
Android系统针对文件类型进行了分类,图片、音频、视频这三类文件将可以通过MediaStore API来进行访问,而其他类型的文件则需要使用系统的文件选择器来进行访问。
- 权限
我们的应用程序向媒体库贡献的图片、音频或视频,将会自动拥有其读写权限,不需要额外申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限。而如果你要读取其他应用程序向媒体库贡献的图片、音频或视频,则必须要申请READ_EXTERNAL_STORAGE权限才行。WRITE_EXTERNAL_STORAGE权限将会在未来的Android版本中废弃,因为删除/修改文件都要询问用户了,所以跳一次的小小权限框还有什么用呢?!
系统会将每个媒体文件归因于一个应用,未请求存储权限时可以访问的文件。每个文件只能归因于一个应用。因此,如果您的应用创建的媒体文件存储在照片、视频或音频文件媒体集合中,应用便可以访问该文件。通常无法更新其他应用存放到媒体库中的媒体文件。但是,如果用户卸载并重新安装您的应用,您必须请求 READ_EXTERNAL_STORAGE 才能访问应用最初创建的文件。此权限请求是必需的,因为系统认为文件归因于以前安装的应用版本,而不是新安装的版本。
- 存储访问框架(SAF)
既可以选取多媒体文件,也可以选取其他类型文件。当你需要访问非多媒体文件时,或者访问根目录下的非共享文件夹时(可能是连接电脑后,在电脑上创建的),那么只能使用SAF。
- 选择文件
使用 ACTION_OPEN_DOCUMENT intent 要求用户使用系统选择器选择要打开的文件。如果您想过滤系统选择器提供给用户选择的文件类型,您可以使用 setType() 或 EXTRA_MIME_TYPES。
用户选择后,会返回选择文件的Uri。通过Uri用MediaStore获取文件信息,读取文件内容。
例如,您可以使用以下代码查找所有 PDF、ODT 和 TXT 文件:
//查找多种具体文件格式的方式:
private ActivityResultLauncher<Object> openFilePicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[] {
"application/pdf", // .pdf
"application/vnd.oasis.opendocument.text", // .odt
"text/plain" // .txt
});
return startActivityForResult(intent, new ActivityResultCallback<Intent>() {
@Override
public void onActivityResult(Intent result) {
Log.d("","");
}
});
}
用下面的代码查找所有图片类型:
//指定一种文件类型。
private ActivityResultLauncher<Object> openFilePicker() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");//"*/*"代表所有文件类型。
return startActivityForResult(intent, new ActivityResultCallback<Intent>() {
@Override
public void onActivityResult(Intent result) {
Log.d("","");
}
}
同时,Android 11对存储访问框架(SAF)添加了以下限制:
使用 ACTION_OPEN_DOCUMENT_TREE 或 ACTION_OPEN_DOCUMENT,无法浏览到Android/data/ 和 Android/obb/ 目录。
使用 ACTION_OPEN_DOCUMENT_TREE无法授权访问存储根目录、Download文件夹。
2. 读取文件
共享文件夹的内容,可以和以前一样依赖全路径,用File类读取。但是需要READ_EXTERNAL_STORAGE 权限。也可以用MediaStore读取类容,代码如下:
文件描述符
// Open a specific media item using ParcelFileDescriptor.
ContentResolver resolver = getApplicationContext()
.getContentResolver();
// "rw" for read-and-write;
// "rwt" for truncating or overwriting existing file contents.
String readOnlyMode = "r";
try (ParcelFileDescriptor pfd =
resolver.openFileDescriptor(content-uri, readOnlyMode)) {
// Perform operations on "pfd".
} catch (IOException e) {
e.printStackTrace();
}
文件流
// Open a specific media item using InputStream.
ContentResolver resolver = getApplicationContext()
.getContentResolver();
try (InputStream stream = resolver.openInputStream(content-uri)) {
// Perform operations on "stream".
}
文件Uri的获取:
//利用Query获取到文件ID后,拼接成Uri
Uri photoUri = Uri.withAppendedPath(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
cursor.getString(idColumnIndex));
//或者
Uri photoUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
- MediaStore
1. 删除文件
删除文件需要询问用户,只有当用户同意以后才能删除。可以批量处理。调用下面代码后,会跳出询问框。
try {
PendingIntent deleteRequest = null;
deleteRequest = MediaStore.createDeleteRequest(context.getContentResolver(), uris);
context.startIntentSenderForResult(deleteRequest.getIntentSender(), requestCode, null, 0, 0, 0, null);
} catch (Exception e) {
e.printStackTrace();
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
EDIT_REQUEST_CODE -> {
if (resultCode == Activity.RESULT_OK) {
Toast.makeText(this, "文件已删除", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "用户没有授权删除", Toast.LENGTH_SHORT).show()
}
}
}
}
2. 创建/更新文件
更新文件也需要询问用户,只有当用户同意以后才能更新。可以批量处理。也会跳出和删除类似的对话框,就是把“delete”改成“modify”。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val urisToModify = listOf(uri1, uri2, uri3, uri4)
val editPendingIntent = MediaStore.createWriteRequest(contentResolver, urisToModify)
startIntentSenderForResult(editPendingIntent.intentSender, EDIT_REQUEST_CODE,
null, 0, 0, 0)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
EDIT_REQUEST_CODE -> {
if (resultCode == Activity.RESULT_OK) {
Toast.makeText(this, "用户已授权", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "用户没有授权", Toast.LENGTH_SHORT).show()
}
}
}
}
3. 还有两个不太常用的
- createFavoriteRequest() 用于请求将多个文件加入到Favorite(收藏)的权限。
- createTrashRequest() 用于请求将多个文件移至回收站的权限。
4. 创建文件
fun addBitmapToAlbum(bitmap: Bitmap, displayName: String, mimeType: String, compressFormat: Bitmap.CompressFormat) {
val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
} else {
values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
val outputStream = contentResolver.openOutputStream(uri)
if (outputStream != null) {
bitmap.compress(compressFormat, 100, outputStream)
outputStream.close()
}
}
}
Android11之前的版本中并没有RELATIVE_PATH,所以我们要使用DATA常量(已在Android 10中废弃),并拼装出一个文件存储的绝对路径才行。values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES.plus("/hello")) 就会在把图片放在/Pictures/hello/目录下
分区存储后门
分区存储也还留有后门的,app可以申请MANAGE_EXTERNAL_STORAGE权限。这是针对那些文件管理App的,比如es explore, 他们必须有这样的权限,要不然文件列表都无法列出来了,尤其是非媒体类型。但是这个权限在上架google play时需要申请的。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!Environment.isExternalStorageManager()) {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
ActivityCompat.startActivity(v.getContext(), intent, null);
return;
}
}
好了,关于分区存储就讲这么多。 首先要搞明白你的app需不需要适配, 是国内市场的还是国外市场的。国内市场的就不需要复杂适配。如果是清理垃圾文件的功能,已经没戏了。