Android——Android10的分区存储(Scoped Storage)

分区存储介绍

在Android10以前,只要程序获得了READ_EXTERNAL_STORAGE权限,就可以随意读取外部的存储公有目录。只要程序获得了WRITE_EXTERNAL_STORAGE权限,就可以随意在写入外部存储的公有目录上新建文件或文件夹


Android Q之前,应用存储视图

于是Google在Android10中提出了分区存储,意在限制程序对外部存储中公有目录的使用。
分区存储对内部存储私有目录和外部存储私有目录都没有影响

Android Q之后应用存储视图

简单来说就是,在Android10中,

  • 对于私有目录的读写没有变化,仍然可以使用File那一套,且不需要任何权限。
  • 对于公有目录的读写,则必须使用MediaStore提供的API或是SAF(存储访问框架)

在后续的Android11中,没有了Android10中的兼容模式,不能使用File I/O来读取App外置存储的目录

使用分区存储的应用对自己创建的文件始终拥有读/写权限,无论文件是否位于应用的私有目录内,所以,如果应用仅保存和访问自己创建的文件,则无需请求获得READ_EXTERNAL_STORAGE或WRITE_EXTERNAL_STORAGE权限

如果要访问其他应用创建的文件,则需要READ_EXTERNAL_STORAGE权限。并且仍然只能使用MediaStore提供的API或是SAF访问。
这里需要注意的是,MediaStore提供的API只能访问图片、视频、音频,如果需要访问其它任意格式的文件,需要使用SAF,它会调用系统内置的文件浏览器供用户自主选择文件

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


SAF交互使用介绍

Android Q规定了App有两种存储空间模式视图:Legacy View、Filtered View

  • Legacy View(兼容模式) 跟以前Android Q一样,App访问Sdcard一样
  • Filtered View(沙箱模式)
    App只能直接访问App-specific目录文件,没有权限访问App-specific外的文件。访问其他目录,只能通过MediaStore、SAF、或者其他App提供ContentProvider访问

Scoped Storage的存储空间

  • 公共目录:Downloads、Documents、Pictures、DCIM、Movies、Music、Ringtones
    • 公共目录的文件在App卸载后,不会删除
    • 可以通过SAF、MediaStore接口访问
  • App-specific目录
    • 对于Filtered View App,App-specific目录只能自己直接访问
    • App卸载,数据会清除

运行视图

App运行视图

系统通过下列方式确定App的运行模式:

  • App的TargetSDK>=Q,默认为Filtered View
  • App的TargetSDK<Q,声明了READ_EXTERNAL_STORAGE或者WRITE_EXTERNAL_STORAGE权限,默认Legacy View
  • 应用通过AndroidManifest.xml设置 requestLegacyExternalStorage
    • true:表示兼容模式Legacy View
    • false:表示沙箱模式 Filtered View

判断当前App的运行模式

判断当前App运行的是什么模式,可以通过Environment提供的API进行判断

Environment.isExternalStorageLegacy()

MediaStore的Uri定义

MediaStore提供了下列几种类型的访问Uri,通过查找对应Uri数据,达到访问的目的。

获取所有的Volume

我们还可以使用getContentUri获取所有<volumeName>


image.png

Uri跟公共目录关系

MediaProvider对于App存放到公共目录文件,通过ContentResolver insert方法中Uri来确定


image.png

权限

MediaStroe通过不同Uri,为用户提供了增、删、改方法,权限对应如下


image.png
  • 由上表可以看出没在操作共享存储空间时,获取的权限不同可以对应不同的操作共享存储空间的方式
    • WRITE_EXTERNAL_STORAGE:获取这个权限,可以修改所有的app新建的文件,但是都需要授予权限
    • READ_EXTERNAL_STORAGE:获取这个权限,可以读取所有的app新建的文件,不能修改其他App新建的文件
    • 什么权限都不获取的话,只能读取、修改自己app新建的文件

操作共享存储空间,读写公共目录

通过Media定义的URI

新建文件(通过ContentResolver的insert接口,使用不同的Uri选择存储到不同的目录)


    /**
     * 通过SAF创建文件文件夹
     *
     * @param view
     */
    public void createSAF(View view) {
        Uri uri = MediaStore.Files.getContentUri("external");
        ContentResolver contentResolver = this.getContentResolver();
        //定义path
        String path = Environment.DIRECTORY_DOWNLOADS + "/wx";
        ContentValues contentValues = new ContentValues();
        contentValues.put(MediaStore.Downloads.RELATIVE_PATH, path);
        contentValues.put(MediaStore.Downloads.DISPLAY_NAME, "wxPic");
        contentValues.put(MediaStore.Downloads.TITLE, "it's title");
        Uri resultUri = contentResolver.insert(uri, contentValues);
        if (resultUri != null) {
            Toast.makeText(this, "创建文件夹成功", Toast.LENGTH_SHORT).show();
        }
    }

新建一张图片

 /**
     * 插入一张图片
     *
     * @param view
     */

    public void insertImage(View view) {
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.moto);
        String disPlayPicName = System.currentTimeMillis() + "123.jpg";
        String mimeType = "image/jpeg";
        ContentValues contentValues = new ContentValues();
        contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, disPlayPicName);
        contentValues.put(MediaStore.Images.ImageColumns.MIME_TYPE, mimeType);
        contentValues.put(MediaStore.Images.ImageColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + File.separator + "wx" + File.separator);
        imageUri = getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues);
        OutputStream outputStream = null;
        try {
            outputStream = getContentResolver().openOutputStream(imageUri);
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
            outputStream.close();
            FileDescriptor fileDescriptor = getContentResolver().openFileDescriptor(imageUri, "r").getFileDescriptor();
        } catch (Exception e) {
            e.printStackTrace();
        }
        Toast.makeText(this, "添加图片成功", Toast.LENGTH_SHORT).show();
    }

修改照片

   /**
     * 修改数据
     *
     * @param view
     */
    public void updateImage(View view) {
        ContentValues contentValues = new ContentValues();
        contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, "test.jpg");
        int update = getContentResolver().update(imageUri, contentValues, null, null);
        if (update > 0) {
            Toast.makeText(this, "修改成功", Toast.LENGTH_SHORT).show();
        }
    }

查询数据

  /**
     * 查询数据
     *
     * @param view
     */
    public void query(View view) {
        //获取URI
        Uri external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
        //创建selection
        String selection = MediaStore.Images.Media.DISPLAY_NAME + "=?";
        String[] arg = new String[]{"test.jpg"};
        Cursor cursor = getContentResolver().query(external, null, selection, arg, null);
        if (cursor != null && cursor.moveToFirst()) {
            Uri idUri = ContentUris.withAppendedId(external, cursor.getLong(0));
            Toast.makeText(this, "获取成功" + idUri, Toast.LENGTH_SHORT).show();
            cursor.close();
        }
    }

删除数据

 /**
     * 删除图片
     *
     * @param view
     */
    public void deleteImage(View view) {
        int delete = getContentResolver().delete(imageUri, null);
        if (delete > 0) {
            Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show();
        }
    }

关于RecoverableSecurityException异常
当我们删除其他应用创建的资源时会报出RecoverableSecurityException异常,我们可以捕获这个异常然后提示给与uri修改或删除的权限

private fun deleteImage(imageUri: Uri, adapterPosition: Int) {
    var row = 0
    try {
        // Android 10+中,如果删除的是其它应用的Uri,则需要用户授权
        // 会抛出RecoverableSecurityException异常
        row = contentResolver.delete(imageUri, null, null)
    } catch (securityException: SecurityException) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val recoverableSecurityException =
                securityException as? RecoverableSecurityException
                    ?: throw securityException
            pendingDeleteImageUri = imageUri
            pendingDeletePosition = adapterPosition
            // 我们可以使用IntentSender向用户发起授权
            requestRemovePermission(recoverableSecurityException.userAction.actionIntent.intentSender)
        } else {
            throw securityException
        }
    }

    if (row > 0) {
        Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show()
        pictureAdapter.deletePosition(adapterPosition)
    }
}

private fun requestRemovePermission(intentSender: IntentSender) {
    startIntentSenderForResult(intentSender, REQUEST_DELETE_PERMISSION, 
        null, 0, 0, 0, null)
}

private fun deletePendingImageUri(){
    pendingDeleteImageUri?.let {
        pendingDeleteImageUri = null
        deleteImage(it,pendingDeletePosition)
        pendingDeletePosition = -1
    }
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (resultCode == Activity.RESULT_OK &&
        requestCode == REQUEST_DELETE_PERMISSION
    ) {
        // 执行之前的删除逻辑
        deletePendingImageUri()
    }
}

如果想获取Download文件夹下的某个非媒体文件怎么办

例如PDF,PDF为非媒体类文件,因此我们不能通过MediaStore来获取,对于这种其他类型的文件,一般使用SAF来让用户选择

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

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
        REQUEST_OPEN_PDF -> {
            if (resultCode == Activity.RESULT_OK) {
                data?.data?.also { documentUri ->
                    val fileDescriptor =
                        contentResolver.openFileDescriptor(documentUri, "r") ?: return
                    // 现在,我们可以使用PdfRenderer等类通过fileDescriptor读取pdf内容
                    Toast.makeText(this, "pdf读取成功", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

如何创建任意类型的文件

我们也推荐使用SAF让用户自己去创建,IntentAction为:ACTION_CREATE_DOCUMENT

访问App-specific目录

访问app-specific分为两种情况,一种是访问App自身App-specific目录,第二是访问其他App目录文件

App访问自身App-specific目录

Android Q,App如果启动了Filtered View,那么只能直接访问自己目录的文件:

  • Environment.getExternalStorageDirectory、getExternalStoragePublicDirectory这些接口在Android Q上废弃,App是Filtered View,无法直接访问这个目录。

  • 通过File(“/sdcard/”)访问App是Filtered View,无法直接访问这个目录。

  • 获取App-specific目录

    • 获取Media接口:getExternalMediaDirs
    • 获取Cache接口:getExternalCacheDirs
    • 获取Obb接口:getObbDirs
    • 获取Data接口:getExternalFilesDirs

App访问App-sepecific目录内部的多媒体文件

  • App自身访问,和App访问自身的App-soecific目录一样
  • 其他App访问
    • 默认情况下,Media Scanner不会扫描App-specific里的多媒体文件,如果需要扫描通过MediaScannerConnection.scanFile添加到MediaProvider数据库中,访问方式和访问共享存储空间方式一样
    • App通过创建ContentProvider共享出去

App访问其他App目录文件

App是FilteredView,其他App无法直接访问当前App私有目录,需要通过以下方法:

通过SAF文件

  • App自定义DocumentsProvider
  • 访问App通过ACTION_OPEN_DOCUMENT启动SAF浏览

实现FileProvider(某些手机可能有问题)

App自定义私有Provider

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

推荐阅读更多精彩内容