Android-10、11-存储完全适配(上)

6,877 阅读8分钟

前言

存储适配系列文章:

Android-存储基础
Android-10、11-存储完全适配(上)
Android-10、11-存储完全适配(下)
Android-FileProvider-轻松掌握

上篇文章分析了Android 存储相关的基础知识,说到了各个目录下文件的访问方式。本篇将着重分析Android 系统版本变更对存储访问权限的影响及其适配方法。
通过本篇文章,你将了解到:

1、存储基本知识
2、Android 10.0 之前访问方式
3、Android 10.0 访问方式变更
4、如何不适配Android 10.0

1、存储基本知识

先来看看存储区域划分:

image.png

其中,以下目录无需存储权限即可访问:

1、App自身的内部存储
2、App自身的自带外部存储-私有目录

剩下的都需要申请存储权限,Android 10.0前后对于存储作用域访问的区别就体现在如何访问剩余这些目录内的文件。

重点在自带外部存储之共享存储空间和其它目录

2、Android 10.0 之前访问方式

继续细分为Android 6.0 之前和之后。

Android 6.0 之前访问方式

Android 6.0 之前是无需申请动态权限的,在AndroidManifest.xml 里声明存储权限:

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

就可以访问共享存储空间、其它目录下的文件了。

Android 6.0 之后的访问方式

动态申请权限

Android 6.0 后需要动态申请权限,除了在AndroidManifest.xml 里声明存储权限外,还需要在代码里动态申请。

    //检查权限,并返回需要申请的权限列表
    private List<String> checkPermission(Context context, String[] checkList) {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < checkList.length; i++) {
            if (PackageManager.PERMISSION_GRANTED != ActivityCompat.checkSelfPermission(context, checkList[i])) {
                list.add(checkList[i]);
            }
        }
        return list;
    }

    //申请权限
    private void requestPermission(Activity activity, String requestPermissionList[]) {
        ActivityCompat.requestPermissions(activity, requestPermissionList, 100);
    }

    //用户作出选择后,返回申请的结果
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        if (requestCode == 100) {
            for (int i = 0; i < permissions.length; i++) {
                if (permissions[i].equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                    if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
                        Toast.makeText(MainActivity.this, "存储权限申请成功", Toast.LENGTH_SHORT).show();
                    } else {
                        Toast.makeText(MainActivity.this, "存储权限申请失败", Toast.LENGTH_SHORT).show();
                    }
                }
            }
        }
    }

    //测试申请存储权限
    private void testPermission(Activity activity) {
        String[] checkList = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE};
        List<String> needRequestList = checkPermission(activity, checkList);
        if (needRequestList.isEmpty()) {
            Toast.makeText(MainActivity.this, "无需申请权限", Toast.LENGTH_SHORT).show();
        } else {
            requestPermission(activity, needRequestList.toArray(new String[needRequestList.size()]));
        }
    }

申请权限后,提示用户作出选择:

image.png

访问文件

权限申请成功后,即可对自带外部存储之共享存储空间和其它目录进行访问。
分别以共享存储空间和其它目录为例,阐述访问方式:

访问共享存储空间

共享存储空间分为两类文件:媒体文件和文档/其它文件。

访问媒体文件

目的是拿到媒体文件的路径,有两种方式获取路径:

1、直接构造路径
以图片为例,假设图片存储在/sdcard/Pictures/目录下。

    private void testShareMedia() {
        //获取目录:/storage/emulated/0/
        File rootFile = Environment.getExternalStorageDirectory();
        String imagePath = rootFile.getAbsolutePath() + File.separator + Environment.DIRECTORY_PICTURES + File.separator + "myPic.png";
        Bitmap bitmap = BitmapFactory.decodeFile(imagePath);
    }

如上,myPic.png的路径:/storage/emulated/0/Pictures/myPic.png,拿到路径后就可以解析并获取Bitmap。

2、通过MediaStore获取路径
沿用上篇的demo:

private void getImagePath(Context context) {
        ContentResolver contentResolver = context.getContentResolver();
        Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null);
        while(cursor.moveToNext()) {
            String imagePath = cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA));
            Bitmap bitmap = BitmapFactory.decodeFile(imagePath);
            break;
        }
    }

同样的,也是拿到图片路径后获取Bitmap。

还有一种不直接通过路径访问的方法:

3、通过MediaStore获取Uri

    private void getImagePath(Context context) {
        ContentResolver contentResolver = context.getContentResolver();
        Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null);
        while(cursor.moveToNext()) {
            //获取唯一的id
            long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
            //通过id构造Uri
            Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
            openUri(uri);
            break;
        }
    }

与直接拿到路径不同的是,此处拿到的是Uri。图片的信息封装在Uri里,通过Uri构造出InputStream,再进行图片解码拿到Bitmap

访问文档和其它文件

1、直接构造路径
与媒体文件一样,可以直接构造路径访问。

2、通过SAF访问
Storage Access Framework 简称SAF:存储访问框架。相当于系统内置了文件选择器,通过它可以拿到想要访问的文件信息。
同样的以获取图片为例:

    private void startSAF() {
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        //选择图片
        intent.setType("image/jpeg");
        startActivityForResult(intent, 100);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (requestCode == 100) {
            //选中返回的图片封装在uri里
            Uri uri = data.getData();
            openUri(uri);
        }
    }

    private void openUri(Uri uri) {
        try {
            //从uri构造输入流
            InputStream fis = getContentResolver().openInputStream(uri);
            Bitmap bitmap = BitmapFactory.decodeStream(fis);
        } catch (Exception e) {

        }
    }

可以看出,通过SAF并不能直接拿到图片的路径,图片的信息封装在Uri里,通过Uri构造出InputStream,再进行图片解码拿到Bitmap。

访问其它目录

有两种方式:
1、直接构造路径
在/sdcard/目录下直接创建目录:

    private void testPublicFile() {
        File rootFile = Environment.getExternalStorageDirectory();
        String imagePath = rootFile.getAbsolutePath() + File.separator + "myDir";
        File myDir = new File(imagePath);
        if (!myDir.exists()) {
            myDir.mkdir();
        }
    }

image.png

可以看出,/sdcard/myDir/目录创建成功。

2、通过SAF访问
与共享存储空间SAF访问方式一致。

Android 10.0 之前访问方式总结

由上面分析的共享存储空间/其它目录访问方式可知,访问目录/文件可通过如下两个方法:

1、通过路径访问。路径可以直接构造也可以通过MediaStore获取。
2、通过Uri访问。Uri可以通过MediaStore或者SAF获取。

Android 6.0 以下访问共享存储空间/其它目录步骤:

1、AndroidManifest.xml里声明存储权限 2、通过路径或者Uri访问文件

Android 6.0(含)~Android 10.0(不含)访问共享存储空间/其它目录步骤:

1、AndroidManifest.xml里声明存储权限
2、动态申请存储权限
3、通过路径或者Uri访问文件

3、Android 10.0 访问方式变更

为什么要变更

你可能已经发现了上面访问方式的弊端,比如我们能够直接在/sdcard/目录下创建目录/文件。事实上,很多App就是这么干的,看图说话:

image.png

image.png

可以看出/sdcard/目录下,如淘宝、qq、qq浏览器、微博、支付宝等都自己建了目录。 这么看来,导致目录结构很乱,而且App卸载后,对应的目录并没有删除,于是就是遗留了很多"垃圾"文件,久而久之不处理,用户的存储空间越来越小。
总结弊端如下:

1、在设置里"Clear storage"或者"Clear cache"并不能删除该目录下的文件
2、卸载App也不能删除该目录下的文件
3、App可以随意修改其它目录下的文件,如修改别的App创建的文件等,不安全

你也许会问,为什么要在/sdcard/目录下新建自己的目录呢?
大体有以下两个原因:

1、此处新建的目录不会被设置里的App存储用量统计,让用户"看起来"自己的App占用的存储空间很小
2、方便操作文件

如何变更

面对众多App不讲"码德"随意新建目录/文件的现象,Google在Android 10.0上重拳出击了。

引入Scoped Storage

翻译成中文有好几个版本:作用域存储、分区存储、沙盒存储。 具体中文翻译不重要,下面以分区存储指代。 分区存储原理:

1、App访问自身内部存储空间、访问外部存储空间-App私有目录不需要任何权限(这个与Android 10.0之前一致)
2、外部存储空间-共享存储空间、外部存储空间-其它目录 App无法通过路径直接访问,不能新建、删除、修改目录/文件等
3、外部存储空间-共享存储空间、外部存储空间-其它目录 需要通过Uri访问

分区存储的变更在于第二点、第三点。

为什么Uri能够访问

先来看为什么通过路径无法直接访问。
我们知道访问文件最终是通过构造InputStream/OutputStream来实现的,以InputStream为例,看看其构造方法:

#FileInputStream.java
    //文件描述符
    private final FileDescriptor fd;
    public FileInputStream(File file) throws FileNotFoundException {
        String name = (file != null ? file.getPath() : null);
        ...
        //传入name,构造FileDescriptor
        //没有权限访问,则此处抛出异常
        fd = IoBridge.open(name, O_RDONLY);
        ...
    }

可以看出,要想FileInputStream 能读入文件,核心是需要构造FileDescriptor,而对于Android 10.0,直接通过路径构造FileDescriptor 会抛出异常。
那么我们自然会想到,有没有通过构造好的FileDescriptor 来生成FileInputStream对象,进而使用read(xx)方法读取数据。
还真有,请看:通过Uri构造InputStream。

InputStream fis = getContentResolver().openInputStream(uri);

进入看其源码:

#ContentResolver.java
    public final @Nullable
    InputStream openInputStream(@NonNull Uri uri)
            throws FileNotFoundException {
        ...
        if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
            ...
        } else if (SCHEME_FILE.equals(scheme)) {
            ...
        } else {
            //通过Uri构造fd是被允许的
            AssetFileDescriptor fd = openAssetFileDescriptor(uri, "r", null);
            try {
                //反过来创建InputStream
                return fd != null ? fd.createInputStream() : null;
            } catch (IOException e) {
                throw new FileNotFoundException("Unable to create stream");
            }
        }
    }

AssetFileDescriptor 持有ParcelFileDescriptor 引用,而ParcelFileDescriptor 持有FileDescriptor 引用。
同理也适用于FileOutputStream。因此,通过Uri能够访问文件。

4、如何不适配Android 10.0

从以上分析可知,适配Android 10.0 有点麻烦,问题来了有没有简单的方法绕过检测。
第一种方法

1、Android 10.0 及其以后才会有分区存储功能,只要Android 设备不升级系统到Android 10.0以后,就不会有问题。
2、可能觉得这是句废话,其实不然,有些定制的设备系统一般都不会升级的。

如果不能使用第一种方法,还可以采用第二种方法。
第二种方法

1、Android 一般升级功能的时候都会配合targetSdkVersion使用。只要targetSdkVersion<=28,分区存储功能就不会开启。

有关targetSdkVersion 作用请移步:targetSdkVersion、compileSdkVersion、minSdkVersion作用与区别

如果第二种方法也不能使用,则还有第三种方法。
第三种方法

在AndroidManifest.xml 里application标签下添加: android:requestLegacyExternalStorage="true" 可禁用分区存储

从长远的角度看,以上三个方法都不是一劳永逸的方法,其中第二种、第三种方法是Google 留给App开发者适配的缓冲时间。
对于第二种方法:

Google 在App上架App Store 时候可能会强制要求升级targetSdkVersion,因此该方法不保险。

对于第三种方法:

在Android 11会忽略该字段,强制开启分区存储,该字段也不怎么靠谱。

因此,最终还是需要老老实实按照Google 的要求适配Android 10.0,下篇将重点分析Android 10.0/11 该如何来适配。

本文基于Android 10.0。

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android