Android 11新特性,Scoped Storage又有了新花样

本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。
距离Android 11正式发布已经半年有余,也该是时候写写Android 11新特性这方面的文章了。

当初我有大概了解过一些Android 11上的行为变更,总体变化虽然不少,但是要求我们必须去适配的地方并不算多。其中一个可能需要适配的地方是Android 11的权限变更,关于这部分内容我在 PermissionX现在支持Java了!还有Android 11权限变更讲解
这篇文章中已经做了比较详细的讲解。
除此之外,在Scoped Storage这块,Android 11上又有了一些新的变化,本篇文章我们就重点来讨论一下这部分内容。

Scoped Storage

事实上,Scoped Storage并不是Android 11上推出的新功能,而是在Android 10中就已经有了,并且我当时还专门写了一篇文章讲解此功能,可以参考 Android 10适配要点,作用域存储

不用担心,之前这篇文章中介绍的内容并没有过时。当时在Android 10上可以使用的功能,现在在Android 11上依然可以使用,只不过Android 11对于Scoped Storage又做了一些丰富与扩展。那么毫无疑问,这就是我们本篇文章的重点。

强制启用Scoped Storage

首先,在Android 11中,Scoped Storage被强制启用了。
那么强制启用是什么意思呢?
在Android 10中虽然也有Scoped Storage功能,但是Google考虑到广大应用程序适配也是需要时间的,因此并没有强制启用这个功能。
只要应用程序指定的targetSdkVersion低于29,或targetSdkVersion等于29,但在AndroidManifest.xml中加入了如下配置:

  
    ...
  

那么Scoped Storage功能就不会被启用。
在Android 11中以上配置依然有效,但仅限于targetSdkVersion小于或等于29的情况。如果你的targetSdkVersion等于30,Scoped Storage就会被强制启用,requestLegacyExternalStorage标记将会被忽略。
那么强制启用了Scoped Storage之后对开发者而言有什么影响吗?
其实如果你的应用程序已经按照 Android 10适配要点,作用域存储 这篇文章中讲解的方式对Scoped Storage进行了适配,那么恭喜你,现在你什么都不需要做,就已经能够适配Android 11系统了。
也就是说,对于绝大部分开发者而言,强制启用Scoped Storage其实并没有什么影响,只要你的应用程序在之前已经适配了Android 10的Scoped Storage。
但是有一类应用程序非常特殊,就是文件浏览器,如Root Explorer、ES Explorer等。这类程序本身提供的功能就是对SD上的文件进行浏览与管理,而强制启用了Scoped Storage之后,本质上就没有文件浏览的概念了,我们也无法以文件的真实路径来对文件进行管理。
从这个角度上看,Scoped Storage对于文件浏览器类的程序造成了毁灭性的打击。不过不用担心,Google仍然还是给这类程序提供了另外一种解决方案,下面我们就来学习一下。

管理设备上所有的文件

首先明确一点,Android 11中强制启用Scoped Storage是为了更好地保护用户的隐私,以及提供更加安全的数据保护。对于绝大部分应用程序来说,使用MediaStore提供的API就已经可以满足大家的开发需求了。如果你没有类似于开发文件浏览器这种需求,请尽可能不要使用接下来即将介绍的技术。
拥有对整个SD卡的读写权限,在Android 11上被认为是一种非常危险的权限,同时也可能会对用户的数据安全造成比较大的影响。
但文件浏览器就是要对设备的整个SD卡进行管理的,这怎么办呢?对于这类危险程度比较高的权限,Google通常采用的做法是,使用Intent跳转到一个专门的授权页面,引导用户手动授权,比如悬浮窗,无障碍服务等。
没错,在Android 11中,如果你想要管理整个设备上的文件,也需要使用类似的技术。
首先,你必须在AndroidManifest.xml中声明MANAGE_EXTERNAL_STORAGE权限,如下所示:


    

注意相比于传统声明一个权限,这里增加了tools:ignore=”ScopedStorage”这样一个属性。因为如果不加上这个属性,Android Studio会用一个警告提醒我们,绝大部分的应用程序都不应该申请这个权限,正如我前面介绍的一样。
接下来的工作也相当简单,我们可以使用ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION这个action来跳转到指定的授权页面,可以通过Environment.isExternalStorageManager()这个函数来判断用户是否已授权,下面我写了一段比较简单的代码来演示这个功能:

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
        Environment.isExternalStorageManager()) {
 
    Toast.makeText(this, "已获得访问所有文件权限", Toast.LENGTH_SHORT).show()
} else {
 
    val builder = AlertDialog.Builder(this)
        .setMessage("本程序需要您同意允许访问所有文件权限")
        .setPositiveButton("确定") {
  _, _ ->
            val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
            startActivity(intent)
        }
    builder.show()
}

可以看到,这里首先判断如果系统版本低于Android 11,或者Environment.isExternalStorageManager()返回true,那么就说明我们已经拥有管理整个SD卡的权限了。现在你可以直接使用传统的写法,以文件真实路径的形式对文件进行操作。
而如果还没有管理SD卡的权限,则会弹出一个对话框,告知用户申请权限的原因,然后使用Intent跳转到指定的授权页面,让用户手动进行授权。
程序的运行效果如下图所示:


有了这个权限之后,你就可以用过去熟知的方式去开发文件浏览器了。
不过还有一点需要注意,即使我们获得了管理SD卡的权限,对于Android这个目录下的很多资源仍然是访问受限的,比如说Android/data这个目录在Android 11中使用任何手段都无法访问。因为很多应用程序的数据信息都会存放在这个目录下,做这个限制的目的主要还是考虑到用户的数据安全吧。不然的话,允许微信去读取淘宝中的数据,怎么想好像都是不合适的。

Batch operations

下面我们再来看Android 11中关于Scoped Storage的另外一个新特性。
Scoped Storage规定,每个应用程序都有权限向MediaStore贡献数据,比如说插入一张图片到手机相册当中。也有权限读取其他应用程序所贡献的数据,比如说获取手机相册中的所有图片。这些功能我在 Android 10适配要点,作用域存储 这篇文章中都进行了演示。
但是,假如你要修改其他应用程序所贡献的数据,那不好意思,Scoped Storage是不允许你这样做的。
原因也很简单,如果一张图片是你插入到手机相册的,你当然有权限对它进行任意修改。但是如果这张图片是其他应用程序插入到手机相册的,你还能对它进行任意修改,这在Google看来就又是一个安全隐患,所以Scoped Storage限制了这个功能。
不过,如果有些应用程序就是需要修改别的应用所贡献的数据呢?这种例子也不难找,比如Photoshop、美图秀秀等,它们的目的就是为了修改手机相册中的图片,不管这个图片是不是它们自己所创建的。
针对这个问题,Android 10中提供了一种解决方案:

try {
 
    contentResolver.openFileDescriptor(imageContentUri, "w")?.use {
 
        Toast.makeText(this, "现在可以修改图片的灰度了", Toast.LENGTH_SHORT).show()
    }
} catch (securityException: SecurityException) {
 
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
 
        val recoverableSecurityException = securityException as?
            RecoverableSecurityException ?:
            throw RuntimeException(securityException.message, securityException)

        val intentSender = recoverableSecurityException.userAction.actionIntent.intentSender
        intentSender?.let {
 
            startIntentSenderForResult(intentSender, IMAGE_REQUEST_CODE,
                    null, 0, 0, 0, null)
        }
    } else {
 
        throw RuntimeException(securityException.message, securityException)
    }
}

下面我来简单解释一下这段代码。
首先这段代码的目的是为了修改一张图片的灰度,但由于这张图片并不是由当前应用程序所贡献的,所以理论上当前应用程序并没有权限去修改这张图片的灰度。
那么明明没有权限去修改,但是我们还是执意去修改会发生什么情况呢?这个很好理解,当然是抛异常了。于是这里用try catch的方式包裹了修改图片灰度的操作,然后在catch的代码块中判断,如果当前系统版本大于等于Android 10,并且异常的类型是RecoverableSecurityException,那么就说明这是一个由于Scoped Storage限制导致操作没有权限的异常。
接下来会从RecoverableSecurityException对象中获取一个intentSender,再借助这个intentSender进行页面跳转,引导用户手动授予我们修改这张图片的权限。运行效果如下:


这种方式虽然可行,但却有一个非常明显的缺点:每次我们只能操作一张图片。如果一个程序需要修改很多张图片,没有什么好办法,只能每张图片都用上述方式去申请权限。
相信Google也是意识到了这个问题,于是在Android 11中引入了一个新的功能,叫作Batch operations,从而允许我们可以一次性对多个文件的操作权限进行申请。
关于Batch operations的用法也很好理解,Google一共提供了4种类型的权限申请API,如下所示:

  • createWriteRequest() 用于请求对多个文件的写入权限。
  • createFavoriteRequest() 用于请求将多个文件加入到Favorite(收藏)的权限。
  • createTrashRequest() 用于请求将多个文件移至回收站的权限。
  • createDeleteRequest() 用于请求将多个文件删除的权限。

其中最常用的主要是createWriteRequest()和createDeleteRequest()这两个接口,这里我们以createWriteRequest()举例。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
 
    val urisToModify = listOf(uri1, uri2, uri3, uri4)
    val editPendingIntent = MediaStore.createWriteRequest(contentResolver, urisToModify)
    startIntentSenderForResult(editPendingIntent.intentSender, EDIT_REQUEST_CODE,
            null, 0, 0, 0)
}

代码非常简单,首先我们创建了一个集合,用于存放所有要批量申请权限的文件Uri,然后调用createWriteRequest()函数去创建一个PendingIntent,接下来再调用startIntentSenderForResult进行权限申请即可。
关于权限申请的结果,我们可以在onActivityResult()中进行监听:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
 
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
 
        EDIT_REQUEST_CODE -> {
 
            if (resultCode == Activity.RESULT_OK) {
 
                Toast.makeText(this, "用户已授权", Toast.LENGTH_SHORT).show()
            } else {
 
                Toast.makeText(this, "用户没有授权", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

程序的运行结果如下图所示:


其它几个API的用法都是完全相同的,这里就不再重复举例了。
看到这里,有的朋友可能会说,Android 10和Android 11提供的API完全不同,Android 10是要依赖于异常捕获机制,从RecoverableSecurityException中解析出intentSender,而Android 11可以借助Batch operations提供的API直接创建intentSender。我该不会需要在一个项目中针对Android 10和Android 11分别写两套代码去进行适配吧?
这确实是个头疼的问题,而且我觉得主要是由于Google一开始在Android 10中API设计不合理所导致的。依赖于异常捕获机制的方案,无论如何都不能说是一种出色的API设计。
不过随着后来更多的思考,我发现这并不是一个无法解决的问题,并且解决方案还非常简单。
为什么呢?别忘了,Android 10中的Scoped Storage并不是强制启用的,我们可以在AndroidManifest.xml中配置requestLegacyExternalStorage标记来禁用Scoped Storage。这样的话,Android 10就是不需要适配的,我们只需要在Android 11中使用更加科学规范的API来进行Scoped Storage适配就可以了。
好了,本篇文章就到这里,文中所有的代码示例我都写成了一个Demo,放到了GitHub上,有需要的朋友可以到以下网址查看:

https://github.com/guolindev/ScopedStorageDemo

另外,如果想要学习Kotlin和最新的Android知识,可以参考我的新书 《第一行代码 第3版》
点击此处查看详情

关注我的技术公众号,每个工作日都有优质技术文章推送。
微信扫一扫下方二维码即可关注: