Android|Android 11存储适配

对于开发来说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
(一)权限更新

  1. Read的权限是保留的,如果想要访问公共资源都是要声明和动态申请读取权限

    动态验证和申请权限的方式和之前一致。
//查询权限 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。

Android|Android 11存储适配
文章图片
Screen Shot 2020-12-18 at 14.05.15.png
  1. 写入权限在11中被彻底废弃了,想要写入需要通过mediaStore和SAF框架,测试下来并不需要权限就可以通过这两种API写入文件到指定目录。Android10可以使用leagcy的flag保持之前的行为。再声明write权限可以申请maxSdkVerision。

  1. 新增管理权限

    该权限的功能和之前的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 = https://www.it610.com/article/Uri.parse("package:" + context!!.packageName) startActivityForResult(intent, 3) } } }

(二)外部存储被限制后Android提供了两种方式去操作 ContentResolver & MediaInfo
Storage access framework
  1. 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().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().contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) try { val outStream = getApplication().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().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 } } } }

【Android|Android 11存储适配】这时候如果需要权限会进到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 ) } })

Android|Android 11存储适配
文章图片
delete_permission.png
  1. SAF框架
    该框架会弹出一个系统级的选择器,用户需要手动操作才能完整走完读写流程,由于用户在操作的时候相当于已经授权了,所以该框架调用不需要权限。相比于MediaStore固定的几个目录,SAF可以操作的目录更自由,但是由于需要用户额外的操作,用户体验并不好。

    Android|Android 11存储适配
    文章图片
    saf.png
  • 读取
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 = 1private 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路径写入内容。

Android|Android 11存储适配
文章图片
save1.png (三)开发时需要注意的问题 https://developer.android.google.cn/training/data-storage/use-cases#migrate-legacy-storage
对于当前应用使用哪种存储方式,起决定性的是tragetAPI的选择,所以开发时可能会遇到如下情况。(由于Android10的存储变化属于过渡阶段,我按Android10已经requestLegacyStorage描述)
  1. 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 theREAD_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,无法正常运行。
  1. target 30,运行在低版本设备上
    可以按照新的代码在低版本设备上正常运行,Android已经做了向下兼容,不用针对API30以下的设备写两套代码。
    但是有一些行为还是略有不同的,比如Android API30 向指定公共目录write时不需要权限,但是在低版本上还是需要动态申请write权限的,API30删除其它APP创建的文件需要逐个授权,低版本不需要。
    所以目前要做到全面兼容还需要全方位的权限申请。
  2. target原先是30以下,升级成30
    会分两种情况处理
  • 应用已经安装在Android11的设备上了,升级target后google建议将外部存储的文件转移到私有目录,添加preserveLegacyExternalStorage flag应用还可以按legacy storage的方式读写,
  • 应用没有在Android11的设备上安装过,则完全按scope storage的方式读写,即使添加了preserveLegacyExternalStorage flag也会被忽略掉。

    推荐阅读