在 Android Kitkat (Android 4.4 Api 19)开始,Android 提供了一套存储访问框架(Storage Access Framework),简称 SAF。开发者可以在应用内使用该框架,通过用户的操作获取/保存/修改手机中的文件等

SAF 包括三个部分

  • DocumentsProvider
    内容提供程序,提供内容存储服务的应用可以实现该类,例如 Google Driver,Dropbox,OneDriver 等云存储服务甚至是本地存储服务,实现后用户可以在 Picker 中找到该程序所提供的内容
  • Client
    客户端程序,即发起存储访问请求的客户端
  • Picker
    一个系统界面,用户可以在该页面上操作符合条件的文件

这里有一张 Google 文档上的图,展示了如何通过 SAF 访问存储数据

通过 SAF 访问存储数据

通过 SAF 读写文件并不需要申请 WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE 权限

代码示例

了解了 SAF 大致的工作原理后,我们还是回归到实践中,这里演示一下在应用开发中,如何通过 SAF 去访问用户手机上的内容

其实最主要的就是通过 Intent 唤起 Picker,交给用户去操作,然后在 onActivityResult 中获取到相对应的数据再由客户端进行处理

创建文件

创建文件需要让用户先通过 Picker 创建一个文件,再将该写入的路径提供给 Client 以供写入
Intent.ACTION_CREATE_DOCUMENT

那如何让用户打开 Picker 呢,则需要 Client 通过 Intent 唤起 Picker 页面,由用户选择保存的位置和文件名后,点击确认后返回应用内。由客户端获取到 Uri 后对该文件进行写入等

1
2
3
4
5
6
7
8
9
//重点在于这里的 Intent Action
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
//告知要保存的文件的 MIME 类型
type = "image/png"
//提供保存的文件名,可选
putExtra(Intent.EXTRA_TITLE,"myPicture.png")
}
startActivityForResult(intent, REQUEST_CODE_FOR_WRITE_IMAGE)

读取文件

读取文件需要用户选择文件后提供给 Client
Intent.ACTION_OPEN_DOCUMENT

比方说,我需要用户选择一张图片作为头像

则需要通过 Intent 唤起 Picker

1
2
3
4
5
6
7
8
9
10
11
12
companion object{
private const val REQUEST_CODE_FOR_IMAGE = 1
}

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply{
//对结果进行过滤,只显示可打开的文件
addCategory(Intent.CATEGORY_OPENABLE)
//过滤非 image 类型的文件
type = "image/*"
}

startActivityForResult(intent,REQUEST_CODE_FOR_IMAGE)

再在 Activity 的 onActivityResult() 回调中获取用户选择的文件的 Uri(即 data.data)

1
2
3
4
5
6
7
8
9
10
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK){
when(requestCode){
REQUEST_CODE_FOR_IMAGE ->{
data?.data?.let { showImage(it) }
}
}
}
}

获取到该 Uri 后则可以将该 Uri 转成 Bitmap 展示在 ImageView 中

编辑文件

编辑文件,同样的道理,你只需要通过 Intent 唤起 Picker,让用户选取文件后进行读写即可

删除文件

同样的,删除文件也需要获取到该文件的 uri 后才能进行操作
通过 Picker 获取 Uri 的代码可以参考上文的获取文件

1
DocumentsContract.deleteDocument(contentResolver, uri)

获取文件夹权限

1
2
3

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent,REQUEST_CODE_FOR_DIR)

通过唤起 Picker ,让用户选择目录授予 Client 该文件夹的完整访问权限,包括当前存储在该文件夹下的文件以及日后存储在该文件夹下的文件

同理,在 onActivityResult 中可以获取到该文件夹的 Uri 并进行读写操作。

在用户点击「允许访问 xx 」时,会弹出一个授权提示,如下图
Screenshot_20200418-221258

如果用户授权之后,在应用管理中,我们也可以看到该 APP 多了一个「取消访问权限」的按钮

Screenshot_20200418-221628

一旦用户点击「取消访问限制」,上图中「总计」下面所罗列出来的存储位置的权限都会被取消,并且 App 不会像点击应用管理中的 「清除缓存」那样被杀死,而是还会继续在运行,所以对于应用来说,要处理好对于文件夹 Uri 的权限处理

Uri 权限

权限时间

根据官方文档所述,我们通过上述的方式获取到的 Uri ,事实上系统会对该 Uri 对我们的 Client 进行授权,直到用户重启设备(正常情况下是这样)

因为事实上还可能有上述取消访问权限的情况

例如说,如果我们将获取到的 Uri 进行保存(存为字符串形式),后续再通过 Uri.parse(String urlString) 方法构建出来的对象,也是可以对文件进行访问的(在用户授权后至重启之间)

如果需要在设备重启后还拥有对该 Uri 的权限,则需要获取系统提供的 Uri 持久授权,这样用户则可以在设备重启后继续在该 App Client 中持续访问该文件

1
2
3
4
//对 Uri 权限进行持久化
val takeFlags: Int = intent.flags and
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
contentResolver.takePersistableUriPermission(uri, takeFlags)

Caution: Even after calling takePersistableUriPermission(), your app doesn’t retain access to the URI if the associated document is moved or deleted. In those cases, you need to ask permission again to regain access to the URI.
还有最后一个步骤。应用最近访问的 URI 可能不再有效,原因是另一个应用可能删除或修改了文档。因此,您应始终调用 getContentResolver().takePersistableUriPermission(),以检查有无最新数据。

官方文档上还有上述这一段描述,但是我的理解中,如果一个文件被移动或者删除了,那它所对应的 Uri 即便通过 takePersistableUriPermission 方法再次授权了,也是没有多大作用的呀??这个方法本身不会有返回值告知开发者该 Uri 是否还能继续用,通过我的实验,在获取到 Uri 后,通过文件管理器等将文件进行删除,调用 takePersistableUriPermission 方法也不会 throw Exception,所以官方文档上的这个 move or deleted 我抱有疑问,望赐教

运行时权限处理

如果用户在应用管理中取消了访问权限,在 App 中通过 contentResolver.takePersistableUriPermission方法对该 Uri 进行权限申请则会 throw 下面的 Exception

1
java.lang.SecurityException: No persistable permission grants found for UID 10200 and Uri  [user 0]

所以我们可以通过 try catch 判断是否拥有对该目录的访问权限?

其实大可不必,通过 contentResolver.getPersistedUriPermissions 方法可以获取到该应用当前所拥有的权限列表,判断要使用的权限是否在列表当中即可

另外,授予了的 Uri 权限也可以通过 contentResolver.releasePersistableUriPermission 方法主动释放

总结

SAF 其实就是通过用户在 Picker 获取 DocumentProvider 提供的内容,转为 Uri 对象提供给 Client 对其进行操作,而不是 Client 直接通过 File Api 操作 External Storage ,通过将权限由开发者申请转变为了让用户自行通过系统改的 Picker 选择,从而避免了申请 WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE 权限

参考文章:

官方文档:
中文版:使用存储访问框架打开文件
英文版:Access documents and other files from shared storage

附录

对于 Uri 来说,可以通过 ContentResolver 的 Api 对文件进行处理

例如上文中提到的将 Uri 处理为 Bitmap 的方法
后续有空再来研究一下这些 api 以及 FileStream 的使用

下文代码大多转载于上述参考文章中的 Google 官方文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//将图片 Uri 转为 Bitmap
private fun showImage(uri: Uri){
GlobalScope.launch(Dispatchers.Main){
imageView.setImageBitmap(getBitmapFromUri(this@SAFActivity,uri))
}
}

suspend fun getBitmapFromUri(context: Context,uri: Uri): Bitmap{
return withContext(Dispatchers.IO){
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri,"r")
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
val image = BitmapFactory.decodeFileDescriptor(fileDescriptor)
parcelFileDescriptor?.close()
return@withContext image
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//编辑文本文件 Uri 的内容
private fun alterDocument(uri: Uri) {
try {
//"w" 指写(write)权限
//如果仅需要读(read)权限,传入 "r" 即可
contentResolver.openFileDescriptor(uri, "w")?.use {
// use{} lets the document provider know you're done by automatically closing the stream
FileOutputStream(it.fileDescriptor).use {
it.write(
("Overwritten by MyCloud at ${System.currentTimeMillis()}\n").toByteArray()
)
}
}
} catch (e: FileNotFoundException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//读取文本 Uri 中的内容
private fun getTextFromUri(uri: Uri): String{
val text = StringBuilder()
contentResolver.openFileDescriptor(uri,"r")?.use {
FileInputStream(it.fileDescriptor).use{
BufferedReader(InputStreamReader(it)).use { bufferedReader->
var line: String? = bufferedReader.readLine()
while (null != line){
text.append(line)
line = bufferedReader.readLine()
}
}
}
}

return text.toString()
}