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

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android——Android10的分区存储(Scoped Storage)相关的知识,希望对你有一定的参考价值。

参考技术A

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

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

简单来说就是,在Android10中,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Android 10 分区存储

背景

以前,Android 开发者习惯在根目录建一个自己应用的文件夹,用于存放应用的数据。这样会导致用户卸载后,应用数据不会随之删除。导致手机文件特别混乱,长期占用空间,而且容易泄露用户隐私。

其实 Android 早就提供了 getCacheDir()、getFilesDir()、getExternalFilesDir()、getExternalCacheDir() 等 API 供开发者使用,但是开发者为了方便,没有去用。

为了解决这个问题,从 Android 10 开始,Google 添加了一个新特性 Scoped Storage,我们称之为分区存储,也可以称为沙盒。

在 Android 10 上,仍然可以通过以下两种手段避开分区存储:

  1. targetSdkVersion 设成 29 以下
  2. 在 manifest 中设置 android:requestLegacyExternalStorage=“true”

在 Android 11 上,requestLegacyExternalStorage 会失效,没有效果。但是又增加了 preserveLegacyExternalStorage 属性,对于覆盖安装的应用还能继续用,但是新应用不能用。

至于 targetSdkVersion,上传到 Google Play 的应用,Google 要求必须设成 30 及以上。

分区存储目录

  1. 沙盒目录
    通过 getExternalFilesDir() 等获取到的目录,随着 App 卸载会被删除。
    不过可以在 manifest 中设置 android:hasFragileUserData=“true” 让用户选择是否删除。

  2. 公共目录
    DCIM、Photos、Images、Videos、Audio、Downloads 等目录, App 卸载后会保留。

访问公共目录

重点说下公共目录,沙盒目录就不详细介绍了,沙盒目录可以通过系统提供的接口直接获取,可以直接通过路径读写,也不需要定义任何读写权限,很简单。

访问公共目录需要通过 MediaStore 或者 Storage Access Framework(以下简称 SAF)。媒体文件(图片,音频,视频)能通过 MediaStore 和 SAF 两种方式访问,非媒体文件只能通过 SAF 访问。

MediaStore

关于 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE 读写权限,MediaStore 访问应用自身存放到公共目录下的文件不需要申请权限(但是如果应用卸载后重装,之前保存的文件将不属于本应用创建的文件),而如果要访问其他应用保存到公共目录下的文件则需要申请权限

MediaStore 通过 Uri 操作文件。
各个目录的 Uri 如下:

类型UriUri 常量默认路径
Imagecontent://media/external/images/mediaMediaStore.Images.Media.EXTERNAL_CONTENT_URIPictures
Videocontent://media/external/video/mediaMediaStore.Video.Media.EXTERNAL_CONTENT_URIMovies
Audiocontent://media/external/audio/mediaMediaStore.Audio.Media.EXTERNAL_CONTENT_URIMusic
Downloadcontent://media/external/downloadsMediaStore.Downloads.EXTERNAL_CONTENT_URIDownload
Filecontent://media/external/MediaStore.Files.getContentUri(“external”)Documents

写文件

// 从 Assets 读取 Bitmap
Bitmap bitmap = null;
try 
    bitmap = BitmapFactory.decodeStream(getAssets().open("test.jpg"));
 catch (IOException e) 
    e.printStackTrace();


if (bitmap == null) return;

// 获取保存文件的 Uri
ContentResolver contentResolver = getContentResolver();
ContentValues values = new ContentValues();
Uri insertUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);

// 保存图片到 Pictures 目录下
if (insertUri != null) 
    OutputStream os = null;
    try 
        os = contentResolver.openOutputStream(insertUri);
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, os);
     catch (FileNotFoundException e) 
        e.printStackTrace();
     finally 
        try 
            if (os != null) 
                os.close();
            
         catch (IOException e) 
            e.printStackTrace();
        
    

上面的例子直接把图片保存到 Pictures 根目录,如果要在 Pictures 下创建子目录,需要用到 RELATIVE_PATH(Android 版本 >= 10)。

修改上面的例子,把子目录添加进 ContentValues:

ContentValues values = new ContentValues();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 
    // 指定子目录,否则保存到对应媒体类型文件夹根目录
    values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES +"/test");

还可以向 ContentValues 中添加其他信息,如:文件名,MIME 等
继续修改上面的例子:

ContentValues values = new ContentValues();
// 获取保存文件的 Uri
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
// 指定保存的文件名,如果不设置,则系统会取当前的时间戳作为文件名
values.put(MediaStore.Images.Media.DISPLAY_NAME, "test_" + System.currentTimeMillis() + ".png");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 
    // 指定子目录,否则保存到对应媒体类型文件夹根目录
    values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/test");

删除自己应用创建的文件

获取到对应的 Uri 之后 contentResolver.delete(uri,null,null) 即可。

查询自己应用创建的文件

// 查询
ContentResolver contentResolver = getContentResolver();
Cursor cursor = contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        new String[]
                MediaStore.Images.Media._ID,
                MediaStore.Images.Media.WIDTH,
                MediaStore.Images.Media.HEIGHT
        ,
        MediaStore.Images.Media._ID + " > ? ", new String[]"100",
        MediaStore.Images.Media._ID + " DESC"
);

// 得到所有的 Uri
List<Uri> filesUris = new ArrayList<>();
while (cursor.moveToNext()) 
    int index = cursor.getColumnIndex(MediaStore.Images.Media._ID);
    Uri uri = ContentUris.withAppendedId(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cursor.getLong(index)
    );
    filesUris.add(uri);

cursor.close();

// 通过 Uri 获取具体内容并显示到界面上
ParcelFileDescriptor pfd = null;
try 
    pfd = contentResolver.openFileDescriptor(filesUris.get(0), "r");
    if (pfd != null) 
        Bitmap bitmap = BitmapFactory.decodeFileDescriptor(pfd.getFileDescriptor());
        ((ImageView) findViewById(R.id.image)).setImageBitmap(bitmap);
    
 catch (FileNotFoundException e) 
    e.printStackTrace();
 finally 
    if (pfd != null) 
        try 
            pfd.close();
         catch (IOException e) 
            e.printStackTrace();
        
    

查询其他应用创建的文件

如上文所诉,访问自己应用创建的文件不需要 READ_EXTERNAL_STORAGE 权限。以上代码获取到的 filesUris 只包含本应用之前创建的文件。
如果需要连其他应用的文件一起获取,则申请下 READ_EXTERNAL_STORAGE 权限即可。

修改其他应用创建的文件

同理,需要申请 WRITE_EXTERNAL_STORAGE 权限。
但是,即便申请了 WRITE_EXTERNAL_STORAGE 权限之后,还是会报如下异常:

android.app.RecoverableSecurityException: xxx has no access to content://media/external/images/media/100

这是因为还需要向用户申请修改的权限。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 
    try 
        delete();
     catch (RecoverableSecurityException e) 
        e.printStackTrace();
        // 弹出对话框,向用户申请修改其他应用文件的权限
        requestConfirmDialog(e);
    


private void delete() 
    Uri uri = Uri.parse("content://media/external/images/media/100");
    getContentResolver().delete(uri, null, null);


@RequiresApi(api = Build.VERSION_CODES.Q)
private void requestConfirmDialog(RecoverableSecurityException e) 
    try 
        startIntentSenderForResult(
                e.getUserAction().getActionIntent().getIntentSender()
                , 0, null, 0, 0, 0, null);
     catch (IntentSender.SendIntentException ex) 
        ex.printStackTrace();
    


@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) 
    super.onActivityResult(requestCode, resultCode, data);
    if (resultCode == RESULT_OK)
        delete();
    

将文件下载到 Download 目录

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
    private void downloadApkAndInstall(String downloadUrl, String apkName) 
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) 
            // 使用原始方式
         else 
            new Thread(() -> 
                BufferedInputStream bis = null;
                BufferedOutputStream bos = null;
                try 
                    URL url = new URL(downloadUrl);
                    URLConnection urlConnection = url.openConnection();
                    InputStream is = urlConnection.getInputStream();
                    bis = new BufferedInputStream(is);
                    ContentValues values = new ContentValues();
                    values.put(MediaStore.MediaColumns.DISPLAY_NAME, apkName);
                    values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
                    ContentResolver contentResolver = getContentResolver();
                    Uri uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
                    OutputStream os = contentResolver.openOutputStream(uri);
                    bos = new BufferedOutputStream(os);
                    byte[] buffer = new byte[1024];
                    int bytes = bis.read(buffer);
                    while (bytes >= 0) 
                        bos.write(buffer, 0, bytes);
                        bos.flush();
                        bytes = bis.read(buffer);
                    
                    runOnUiThread(() -> installAPK(uri));
                 catch (IOException e) 
                    e.printStackTrace();
                 finally 
                    try 
                        if (bis != null) bis.close();
                     catch (IOException e) 
                        e.printStackTrace();
                    
                    try 
                        if (bos != null) bos.close();
                     catch (IOException e) 
                        e.printStackTrace();
                    
                
            ).start();
        
    

    private void installAPK(Uri uri) 
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.setDataAndType(uri, "application/vnd.android.package-archive");
        startActivity(intent);
    

SAF

SAF 在 Android 4.4 就支持了。
SAF 通过系统提供的标准化 UI 浏览和修改手机中的文件,如下图

ACTION_CREATE_DOCUMENT 创建文件

    private void createFile() 
        Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        intent.setType("image/*");
        intent.putExtra(Intent.EXTRA_TITLE, "test_create.png");
        startActivityForResult(intent, WRITE_REQUEST_CODE);
    

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) 
        super.onActivityResult(requestCode, resultCode, data);
        if (data == null || resultCode != RESULT_OK) return;
        if (requestCode == WRITE_REQUEST_CODE) 
            Log.d("tianjf", "write uri : " + data.getData());
        
    

运行之后,会启动标准文件管理器 UI 保存文件。
写文件不需要申请写权限。

ACTION_OPEN_DOCUMENT 读文件

因为有可能读取其他应用创建的文件,所以需要申请读权限。

    protected void readFiles() 
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        intent.setType("image/*");
        startActivityForResult(intent, READ_REQUEST_CODE);
    

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) 
        super.onActivityResult(requestCode, resultCode, data);
        if (data == null || resultCode != RESULT_OK) return;
        if (requestCode == READ_REQUEST_CODE) 
            Log.d("tianjf", "read uri : " + data.getData());
            process(data.getData());
        
    

    private void process(Uri uri) 
        String[] selectionArgs = new String[]DocumentsContract.getDocumentId(uri).split(":")[1];
        Cursor cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                null, MediaStore.Images.Media._ID + "=?",
                selectionArgs, null);
        if (null != cursor) 
            if (cursor.moveToFirst()) 
                int index = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
                if (index > -1) 
                    String path = cursor.getString(index);
                    Log.d("tianjf", "onActivityResult path=" + path + ";id=" + selectionArgs[0]);
                
            
            cursor.close();
        
    

ACTION_OPEN_DOCUMENT_TREE 读取文件夹

    protected void readFolder() 
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
        startActivityForResult(intent, READ_FOLDER_REQUEST_CODE);
    

    // 选取文件夹然后在文件夹中创建子文件夹和文件
    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) 
        super.onActivityResult(requestCode, resultCode, data);
        if (data == null || resultCode != RESULT_OK) return;
        if (requestCode == READ_FOLDER_REQUEST_CODE) 
            Log.d("tianjf", "read folder uri : " + data.getData())总结系列-Android10适配-分区存储

总结系列-Android10适配-分区存储

Android 10 分区存储

Android 10 分区存储

Android 10 分区存储

Android - 文件系统与Android11 分区存储