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的权限是保留的,如果想要访问公共资源都是要声明和动态申请读取权限
    <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。


Screen Shot 2020-12-18 at 14.05.15.png
  1. 写入权限在11中被彻底废弃了,想要写入需要通过mediaStore和SAF框架,测试下来并不需要权限就可以通过这两种API写入文件到指定目录。Android10可以使用leagcy的flag保持之前的行为。再声明write权限可以申请maxSdkVerision。
    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />

  1. 新增管理权限
    <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

  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<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
                )
            }
        })
delete_permission.png
  1. SAF框架
    该框架会弹出一个系统级的选择器,用户需要手动操作才能完整走完读写流程,由于用户在操作的时候相当于已经授权了,所以该框架调用不需要权限。相比于MediaStore固定的几个目录,SAF可以操作的目录更自由,但是由于需要用户额外的操作,用户体验并不好。


    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 = 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路径写入内容。


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也会被忽略掉。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容