对于开发来说Android11外部存储的读写迎来了很大的变化,由原来的申请权限后可以自由读写转变成了沙盒模式,在Android10中还可以通过requestLegacyExternalStorage
关闭沙盒存储,到11已经强制推行Scoped storage了。
简单来说Google官方的意图就是希望每个应用都只读写属于自己内存区域的文件,并且读写的文件对于其它应用来说都是互相看不到的,除非有必要才可以申请读写指定目录下的共享文件。更新后无论是原来/data/data/package下的还是sdcard/Android/data/package下的目录都成为了私有目录,对于该目录下的读写都不需要任何权限。
关于变化的详细描述有篇文章描述的比较详细 https://sspai.com/post/61168。
https://developer.android.google.cn/about/versions/11/privacy/storage?hl=en
(一)权限更新
- Read的权限是保留的,如果想要访问公共资源都是要声明和动态申请读取权限
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
动态验证和申请权限的方式和之前一致。
//查询权限
private fun haveStoragePermission() =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PERMISSION_GRANTED
//申请权限
ActivityCompat.requestPermissions(this, permissions, READ_EXTERNAL_STORAGE_REQUEST)
申请之后系统弹框的文案较之前有了变化,会强调是access photos and media。
- 写入权限在11中被彻底废弃了,想要写入需要通过mediaStore和SAF框架,测试下来并不需要权限就可以通过这两种API写入文件到指定目录。Android10可以使用leagcy的flag保持之前的行为。再声明write权限可以申请maxSdkVerision。
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
- 新增管理权限
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
该权限的功能和之前的write权限基本一致,被google归类为特殊权限,想要获得该权限必须要用户手动到应用设置里打开,类似于打开应用通知。如果应用声明了该权限并且想上play store,则一般应用是会被拒掉的,只有类似于文件管理器这种特殊应用才会被允许使用。
该权限的检测和申请可以通过如下方式
private fun requestPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// 先判断有没有权限
if (Environment.isExternalStorageManager()) {
//do something
} else {
//跳转到设置界面引导用户打开
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:" + context!!.packageName)
startActivityForResult(intent, 3)
}
}
}
(二)外部存储被限制后Android提供了两种方式去操作
ContentResolver & MediaInfo
Storage access framework
- MediaStore有固定的几个Type,获得对应的URI如下
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Files.getContentUri("external")
- 读取同上需要先动态申请读取权限
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED
)
val selection = "${MediaStore.Images.Media.DATE_ADDED} >= ?"
val selectionArgs = arrayOf(dateToTimestamp(day = 22, month = 10, year = 2008).toString())
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
getApplication<Application>().contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, //要查询的uri路径
projection, //A list of which columns to return. Passing null will return all columns
selection, //过滤条件,如文件名,日期等
selectionArgs, //过滤条件的参数
sortOrder //排序方式
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val dateModifiedColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val displayNameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
Log.i(TAG, "Found ${cursor.count} images")
while (cursor.moveToNext()) {
// Here we'll use the column indexs that we found above.
val id = cursor.getLong(idColumn)
val dateModified =
Date(TimeUnit.SECONDS.toMillis(cursor.getLong(dateModifiedColumn)))
val displayName = cursor.getString(displayNameColumn)
val contentUri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
val image = MediaStoreImage(id, displayName, dateModified, contentUri)
images += image
// For debugging, we'll output the image objects we create to logcat.
Log.v(TAG, "Added image: $image")
}
}
- 通过MediaStore写入文件, 运行在Android11上不需要权限也可以写入成功
private suspend fun performWriteImage(bitmap: Bitmap) {
withContext(Dispatchers.IO) {
val contentValues = ContentValues()
contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, "test.jpg")
contentValues.put(MediaStore.Images.Media.DESCRIPTION, "test.jpg")
contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
val uri = getApplication<Application>().contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
try {
val outStream = getApplication<Application>().contentResolver.openOutputStream(uri!!)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outStream)
outStream?.close()
} catch (securityException: SecurityException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val recoverableSecurityException = securityException as? RecoverableSecurityException
?: throw securityException
_permissionNeededForDelete.postValue(recoverableSecurityException.userAction.actionIntent.intentSender)
} else {
throw securityException
}
}
}
}
- 删除操作,这个测试下来比较特殊,如果是在公共目录里删除自己写的文件也不需要权限,如果要删除其它应用写入的文件则每次删除都会弹框提示用户。
private suspend fun performDeleteImage(image: MediaStoreImage) {
withContext(Dispatchers.IO) {
try {
getApplication<Application>().contentResolver.delete(
image.contentUri,
"${MediaStore.Images.Media._ID} = ?",
arrayOf(image.id.toString())
)
} catch (securityException: SecurityException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val recoverableSecurityException =
securityException as? RecoverableSecurityException
?: throw securityException
// Signal to the Activity that it needs to request permission and
// try the delete again if it succeeds.
pendingDeleteImage = image
_permissionNeededForDelete.postValue(
recoverableSecurityException.userAction.actionIntent.intentSender
)
} else {
throw securityException
}
}
}
}
这时候如果需要权限会进到securityException里,申请完权限后再进行相同的删除操作就可以了。
viewModel.permissionNeededForDelete.observe(this, Observer { intentSender ->
intentSender?.let {
// On Android 10+, if the app doesn't have permission to modify
// or delete an item, it returns an `IntentSender` that we can
// use here to prompt the user to grant permission to delete (or modify)
// the image.
startIntentSenderForResult(
intentSender,
DELETE_PERMISSION_REQUEST,
null,
0,
0,
0,
null
)
}
})
-
SAF框架
该框架会弹出一个系统级的选择器,用户需要手动操作才能完整走完读写流程,由于用户在操作的时候相当于已经授权了,所以该框架调用不需要权限。相比于MediaStore固定的几个目录,SAF可以操作的目录更自由,但是由于需要用户额外的操作,用户体验并不好。
- 读取
private fun openFile() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(
"application/pdf", // .pdf
"image/jpeg", // .jpeg
"text/plain"))
// Optionally, specify a URI for the file that should appear in the
// system file picker when it loads
}
startActivityForResult(intent, 2)
}
用户选择某个文件后会返回应用,onActivityResult中有文件的URI路径。
- 创建和写入
// Request code for creating a PDF document.
const val CREATE_FILE = 1
private fun createFile(pickerInitialUri: Uri) {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/pdf"
putExtra(Intent.EXTRA_TITLE, "invoice.pdf")
// Optionally, specify a URI for the directory that should be opened in
// the system file picker before your app creates the document.
putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
}
startActivityForResult(intent, CREATE_FILE)
}
这个时候会弹框让用户选择是否保存,保存完后可以根据文件uri路径写入内容。
(三)开发时需要注意的问题
https://developer.android.google.cn/training/data-storage/use-cases#migrate-legacy-storage
对于当前应用使用哪种存储方式,起决定性的是tragetAPI的选择,所以开发时可能会遇到如下情况。(由于Android10的存储变化属于过渡阶段,我按Android10已经requestLegacyStorage描述)
- target仍是30以下,运行在Android11的设备上
如果是要上google play,后面会强制要求targets升级,现在还可以target低一些的版本,按照向下兼容原则是可以按之前未分区时的情况执行的,只不过一些文案有些变化
- The Storage runtime permission is renamed to Files & Media.
- If your app hasn't opted out of scoped storage and requests the
READ_EXTERNAL_STORAGE
permission, users see a different dialog compared to Android 10. The dialog indicates that your app is requesting access to photos and media, as shown in Figure 1.
但是Write权限实际测试下来申请时会被返回deny,无法正常运行。
target 30,运行在低版本设备上
可以按照新的代码在低版本设备上正常运行,Android已经做了向下兼容,不用针对API30以下的设备写两套代码。
但是有一些行为还是略有不同的,比如Android API30 向指定公共目录write时不需要权限,但是在低版本上还是需要动态申请write权限的,API30删除其它APP创建的文件需要逐个授权,低版本不需要。
所以目前要做到全面兼容还需要全方位的权限申请。target原先是30以下,升级成30
会分两种情况处理
- 应用已经安装在Android11的设备上了,升级target后google建议将外部存储的文件转移到私有目录,添加preserveLegacyExternalStorage flag应用还可以按legacy storage的方式读写,
- 应用没有在Android11的设备上安装过,则完全按scope storage的方式读写,即使添加了preserveLegacyExternalStorage flag也会被忽略掉。