Android 10 分区存储

作者: teletian | 来源:发表于2022-09-16 08:22 被阅读0次

    背景

    以前,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 如下:

    类型 Uri Uri 常量 默认路径
    Image content://media/external/images/media MediaStore.Images.Media.EXTERNAL_CONTENT_URI Pictures
    Video content://media/external/video/media MediaStore.Video.Media.EXTERNAL_CONTENT_URI Movies
    Audio content://media/external/audio/media MediaStore.Audio.Media.EXTERNAL_CONTENT_URI Music
    Download content://media/external/downloads MediaStore.Downloads.EXTERNAL_CONTENT_URI Download
    File content://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());
                DocumentFile selectedFolder = DocumentFile.fromTreeUri(this, data.getData());
                DocumentFile newFolder = selectedFolder.createDirectory("newFolder");
                DocumentFile newFile = newFolder.createFile("text/plain", "test.txt");
                try {
                    getContentResolver().openOutputStream(newFile.getUri()).write("Hello".getBytes());
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
    

    相关文章

      网友评论

        本文标题:Android 10 分区存储

        本文链接:https://www.haomeiwen.com/subject/qckmgrtx.html