距离 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 中加入了如下配置:
<manifest ... >
<application android:requestLegacyExternalStorage="true" ...>
...
</application>
</manifest>
那么 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 权限,如下所示:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.scopedstoragedemo">
<uses-permission android:
tools:ignore="ScopedStorage" />
</manifest>
注意相比于传统声明一个权限,这里增加了 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 上,有需要的朋友可以到以下网址查看:
另外,如果想要学习 Kotlin 和最新的 Android 知识,可以参考我的新书 《第一行代码 第 3 版》
关注我的技术公众号“郭霖”,每个工作日都有优质技术文章推送。