Android Scoped storage 分区存储

Android存储目录

  1. 内部存储
  • getFilesDir - 应用内部存储 放在data/data/packagename/files/
  • getCacheDir - 应用内部存储 放在data/data/packagename/cache/
  1. 外部存储
  • getExternalFilesDir - 放在外部存储mnt/sdcard/Android/data/packagename/files/ 外部存储私有目录
    • 应用卸载就会删除
    • 5.0及以上不需要WRITE_EXTERNAL_STORAGE READ_EXTERNAL_STORAGE
    • 不安全,别的应用可以写入数据到此目录
    • Media扫描不出来,不会出现在相册
  • getExternalCacheDir - 存放临时缓存数据 放在外部存储mnt/sdcard/Android/data/packagename/cache/

对应设置选项设置->应用->应用详情里面的"清除数据"与"清除缓存“选项"

  • getExternalStorageDirectory - 在sd卡目录mnt/sdcard,外部存储共享目录 外部存储中,除了私有目录以外的目录,都是共享目录。程序保存在共享目录中的数据,在应用被删除后,仍然保留。

Scoped storage in Android 10

在Android 10以前,只要程序获得了READ_EXTERNAL_STORAGE权限,就可以随意读取外部存储的共享目录;只要程序获得了WRITE_EXTERNAL_STORAGE权限,就可以随意在外部存储的共享目录上新建文件夹或文件。

于是Google终于开始动手了,在Android 10中提出了分区存储,意在限制程序对外部存储中共享目录的为所欲为。分区存储对 内部存储目录 和 外部存储私有目录 都没有影响。

简而言之,在Android 10中,对于私有目录的读写没有变化,仍然可以使用File那一套,且不需要任何权限。而对于共享目录的读写,则不能按照原来操作方法。

Android 11对共享目录的访问
共享目录文件需要通过MediaStore API或者Storage Access Framework方式访问。

  • MediaStore API在共享目录指定目录下创建文件或者访问文件自己创建文件,不需要申请存储权限
  • MediaStore API访问其他应用在共享目录创建的媒体文件(图片、音频、视频), 需要申请存储权限,未申请存储权限,通过ContentResolver查询不到文件Uri,即使通过其他方式获取到文件Uri,读取或创建文件会抛出异常
  • MediaStore API 目录对应系统文件的文件夹
    • MediaStore.Images --> DCIM/ 和 Pictures
    • MediaStore.Video --> DCIM/, Movies/, 和 Pictures/
    • MediaStore.Audio --> Alarms/, Audiobooks/, Music/, Notifications/, Podcasts/ 和 Ringtones/
    • MediaStore.Downloads --> android 10新加目录,Download/
    • MediaStore.Files --> android 10之后可用。如果使用分区存储,只包含当前应用的图片,视频,音频,如果不使用分区存储,则包含所有其它媒体类型
  • MediaStore API不能够访问其他应用创建的非媒体文件(pdf、office、doc、txt等), 只能够通过Storage Access Framework方式访问

Storage Access Framework 存储访问框架方式访问

Android 4.4 就引入了存储访问框架 (SAF)。借助 SAF,用户可轻松在其所有首选文档存储提供程序中浏览并打开文档、图像及其他文件。用户可通过易用的标准界面,以统一方式在所有应用和提供程序中浏览文件,以及访问最近使用的文件。

文档和其它文件写入外部共享存储
不需要申请写权限,会弹出系统文件页面,用户手动创建文件

val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            type = "text/plain"
            addCategory(Intent.CATEGORY_OPENABLE)
            intent.putExtra(Intent.EXTRA_TITLE, )
        }
 startActivityForResult(intent, REQUEST_CREATE_DOCUMENT)

Android 11 Scoped storage变更

  • 对于启用了Scoped storage的应用,如果有需要授权,对话框发生变化,提示应用正在请求访问照片和媒体,以前包含文件
    [图片上传失败...(image-7b0557-1592559787255)]
  • 在android 11上,WRITE_EXTERNAL_STORAGEWRITE_MEDIA_STORAGE将不能提供相应的访问权限
  • 在 Android 11 上,应用无法再访问外部存储设备中的任何其他应用的私有目录的文件

实例演练

  1. 写入外部公有存储,在Android 11上向外部存储创建一个文件,通过传统file path形式创建, 先授权,不能创建成功。授权已经不起作用
val file = File(Environment.getExternalStorageDirectory(), packageName)
        if (!file.exists()) {
            Log.d("ScopedStorageActivity", "create external file state:${file.mkdirs()}")
        }

create external file state:false
  1. 读取外部共享存储,android 11通过传统方式读取外部存储
val file = File(Environment.getExternalStorageDirectory(), "test.txt")
Log.d("ScopedStorageActivity", "test read external file content:${file.readText()}")

Caused by: java.io.FileNotFoundException: /storage/emulated/0/test.txt: open failed: EACCES (Permission denied)

不管授权与否都会报错,不能获取文件。
可以采采用存储访问框架来读取, 这样需要用户打开文件,来选择
https://developer.android.com/guide/topics/providers/document-provider?hl=zh-cn

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            type = "application/*"
            // 我们需要使用ContentResolver.openFileDescriptor读取数据
            addCategory(Intent.CATEGORY_OPENABLE)
        }
        startActivityForResult(intent, REQUEST_OPEN_DOCUMENT)

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        when (requestCode) {
            REQUEST_OPEN_DOCUMENT -> {
                if (resultCode == Activity.RESULT_OK) {
                    data?.data.also { documentUri ->
                        Log.d("ScopedStorageActivity", "fileDescriptor documentUri:$documentUri")
                    }
                }
            }
        }
    }

在onActivityResult回调中就能获取到文件的uri,然后对其进行相应的操作

  1. 通过MediaStore API 向共享存储中写入媒体文件,不需要申请权限
    MediaStore API
    ContentResolver API
val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.timg)
        val displayName = "${System.currentTimeMillis()}.png"
        val mimeType = "image/png"
        val compressFormat = Bitmap.CompressFormat.PNG

        val contentValues = ContentValues()
        contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
        contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
        val path = getAppPicturePath()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, path)
        } else {
            val fileDir = File(path)
            if (!fileDir.exists()) {
                fileDir.mkdir()
            }
            contentValues.put(MediaStore.MediaColumns.DATA, path)
        }

        val uri =
            contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
        uri?.also {
            val outputStream = contentResolver.openOutputStream(it)
            outputStream?.also { os ->
                bitmap.compress(compressFormat, 100, os)
                os.close()
                Toast.makeText(this, "添加图片成功", Toast.LENGTH_SHORT).show()
            }
        }
  1. 通过MediaStore API 向共享存储中读取媒体文件,不需要申请权限
private fun readExternalFileByMediaStore() {
        var pathKey = ""
        var pathValue = ""
        pathKey = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            MediaStore.MediaColumns.DATA
        } else {
            MediaStore.MediaColumns.RELATIVE_PATH
        }
        // RELATIVE_PATH会在路径的最后自动添加/
        pathValue = getAppPicturePath()
        val cursor = contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            null,
            if (pathKey.isEmpty()) {
                null
            } else {
                "$pathKey LIKE ?"
            },
            if (pathValue.isEmpty()) {
                null
            } else {
                arrayOf("%$pathValue%")
            },
            "${MediaStore.MediaColumns.DATE_ADDED} desc"
        )

        cursor?.also {
            while (it.moveToNext()) {
                val id = it.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
                val displayName = it.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
                val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
                Log.d("ScopedStorageActivity", "read external uri:$uri, name:$displayName")
                Toast.makeText(this, "$displayName", Toast.LENGTH_LONG).show()
            }
        }
        cursor?.close()
    }

然后通过uri可以得到Bitmap

val openFileDescriptor = contentResolver.openFileDescriptor(uri, "r")
        openFileDescriptor?.apply {
            val bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
            readImg.setImageBitmap(bitmap)
        }
        openFileDescriptor?.close()
  1. 如果要读取其它应用的媒体文件,扫出应用的相册,就需要申请READ_EXTERNAL_STORAGE权限,然后通过MediaStorage API读取

Scoped storage兼容

  • 对于通过filepath读取比较重的应用,requestLegacyExternalStorage = true 继续保留,在android 10,维持原状
  • android 11之后requestLegacyExternalStorage失效,必须适配
  • 如果当前应用以兼容模式运行,覆盖安装后应用仍然会以兼容模式运行,卸载重新安装应用才会以分区存储模式运行
  • 文件迁移是将应用共享目录文件迁移到应用私有目录或者Android10要求的media集合目录
  • android 11可以用传统File path直接操作方式读媒体文件,性能会有影响,底层还是要转化成ContentResolver.openFileDescriptor读取, 会比较耗时,官方建议用MediaStore
val file = File(
            Environment.getExternalStorageDirectory().absolutePath
                    + File.separator + Environment.DIRECTORY_PICTURES + File.separator + APP_FOLDER_NAME,
            "rebase.png")
        Log.d("ScopedStorageActivity", "${file.path}")
        showImage(Uri.fromFile(file))

官方文档

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