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

前言

存储适配系列文章:

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

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

推荐阅读更多精彩内容