Android文件系统(02)存储访问框架SAF的增删改查

资料

SAFGoogle官网

Android SAF(Storage Access Framework)是Android操作系统提供的一种文件访问机制,它允许应用程序在设备上访问和共享存储媒体(如照片、视频、音频等)的文件。

SAF提供了一种安全的方式来访问文件,它使用运行时权限模型,允许用户控制应用程序可以访问哪些文件。应用程序可以通过SAF的API请求访问文件的权限,并向用户显示一个文件选择器,以便用户可以选择要访问的文件。

SAF包括两部分:

  1. 媒体存储(MediaStore):是一个系统级别的媒体数据库,提供了对设备上的媒体文件的访问。应用程序可以使用MediaStore API查询和检索设备上的媒体文件,如照片、视频和音频。
  2. 文件和文件提供者(File and File Provider):文件和文件提供者API允许应用程序访问设备上的其他类型的文件,如文档、PDF、电子书等。应用程序可以使用文件和文件提供者API来查询、检索和操作这些文件。

SAF的好处是它可以简化应用程序的文件访问,提高用户体验,同时提供更强的隐私保护和安全性。它让用户可以更好地控制哪些应用程序可以访问他们的文件,并且可以减少应用程序对设备的负面影响,如文件冗余、权限滥用等问题。

正文

通过上面的描述大概可以知道,SAF机制可以提供返回参数的,而且这个这个是跨进程的,而Android中什么可以提供这种逻辑了?那就是通过intent去。持有bundle对象,所以这个玩意的回调,一定是走的是 activityResult。

activityResult

既然都23年了,写onActivityResult会提示过期了。所以我们换一种思路。先写一个简单的,先定义不可变量:

aidl 复制代码
    private  val  activityResultLauncher: ActivityResultLauncher<Intent> =registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode === RESULT_OK) {
            // 处理Activity返回的结果
            LogUtils.e("成功")
            result.data?.let {
                LogUtils.e(it.data)
            }

        } else if (result.resultCode === RESULT_CANCELED) {
            // 处理Activity被取消的情况
            LogUtils.e("取消")
        }
    }

发送intent:

aidl 复制代码
activityResultLauncher.launch(intent)

SAF简介

SAF使用教程官方文档

ini 复制代码
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
activityResultLauncher.launch(intent)

当我们选择一张图片成功后,result返回的是一个uri:

content://com.android.providers.media.documents/document/image%3A306

通过上面的简单例子,我可以大概的知道,SAF的一些基本信息。

  • 需要一个action
  • 需要一个category
  • 可以设置mime type
  • 返回的是Uri

action

在Android系统中,类似于ACTION_OPEN_DOCUMENT的Intent action还有很多,它们都是Android系统为了方便应用程序之间的文件交互而设计的一些标准操作。以下是一些常见的Intent action:

  1. Intent.ACTION_VIEW:用于打开一个文件或URI,类似于文件的查看操作。
  2. Intent.ACTION_EDIT:用于编辑一个文件,类似于文件的编辑操作。
  3. Intent.ACTION_INSERT:用于在一个应用程序中插入一个文件或URI,类似于文件的插入操作。
  4. Intent.ACTION_DELETE:用于删除一个文件或URI,类似于文件的删除操作。
  5. Intent.ACTION_CREATE_DOCUMENT:用于在一个应用程序中创建一个新文件或URI,类似于文件的创建操作。
  6. Intent.ACTION_RENAMING:用于重命名一个文件或URI,类似于文件的重命名操作。

通过这些Intent action,应用程序可以方便地实现文件的打开、编辑、插入、删除、创建和重命名等操作,使得不同应用程序之间的文件交互变得更加便捷。

category

类似于CATEGORY_OPENABLE,Android系统中还有其他的Intent category,用于指示不同类型的操作或意图。以下是一些常见的Intent category:

  1. Intent.CATEGORY_DEFAULT:用于指示一个默认的Intent,可以用于启动一个应用程序或执行其他操作。
  2. Intent.CATEGORY_APP_BROWSER:用于指示启动一个浏览器应用程序,用于浏览互联网或本地文件系统。
  3. Intent.CATEGORY_LAUNCHER:用于指示启动一个主屏幕应用程序,用于启动应用程序并显示应用程序的启动页面。
  4. Intent.CATEGORY_INFO:用于指示一个信息类的Intent,可以用于获取关于某个应用程序或数据的详细信息。
  5. Intent.CATEGORY_PROVIDE:用于指示一个提供服务的Intent,可以用于启动一个服务并提供数据或资源给其他应用程序。
  6. Intent.CATEGORY_OPENABLE:用于指示一个可打开的Intent,可以用于打开一个文件或URI并供应用程序读取或处理。
  7. Intent.CATEGORY_SELECTED_ORGANIZER:用于指示一个被选中的组织者Intent,可以用于启动一个日历应用程序并显示特定的日程安排。

这些Intent category可以与Intent action结合使用,以实现不同类型的文件操作或启动不同的应用程序。请注意,使用合适的category来指示您的Intent可以提高用户体验并确保您的应用程序与系统的无缝集成。

MIME type

MIME类型是一个互联网标准,它扩展了电子邮件标准,使其能够支持非ASCII字符、二进制格式附件等多种格式的邮件消息。而且,新的MIME类型也在不断添加和更新,无法穷举。

但是,以下是一些常见的MIME类型:

  1. 文本(Text):用于标准化地表示的文本信息,文本消息可以是多种字符集和或者多种格式的。常见的文本MIME类型包括"text/plain","text/html","text/xml"等。
  2. 多部分(Multipart):用于连接消息体的多个部分构成一个消息,这些部分可以是不同类型的数据。常见的多部分MIME类型包括"multipart/mixed","multipart/form-data"等。
  3. 应用程序(Application):用于传输应用程序数据或者二进制数据。常见的应用程序MIME类型包括"application/octet-stream","application/pdf","application/excel"等。
  4. 消息(Message):用于包装一个E-mail消息。常见的消息MIME类型包括"message/rfc822"等。
  5. 图像(Image):用于传输静态图片数据。常见的图像MIME类型包括"image/jpeg","image/gif","image/png"等。
  6. 音频(Audio):用于传输音频或者音声数据。常见的音频MIME类型包括"audio/mpeg","audio/wav"等。
  7. 视频(Video):用于传输动态影像数据,可以是与音频编辑在一起的视频数据格式。常见的视频MIME类型包括"video/mp4","video/mpeg"等。

基于教程敲一遍

SAF使用教程官方文档 ,其实官网对于增删改查等一系列的操作都写得贼详细了,但是笔记嘛,为了避免看了就会了,所以说,还是自己敲了一遍。

创建新文件

使用 ACTION_CREATE_DOCUMENT intent 操作加载系统文件选择器,支持用户选择要写入文件内容的位置。此流程类似于其他操作系统使用的"另存为"对话框中使用的流程。

ACTION_CREATE_DOCUMENT 无法覆盖现有文件。如果您的应用尝试保存同名文件,系统会在文件名的末尾附加一个数字并将其包含在一对括号中 。例如,如果您的应用尝试将名为 confirmation.pdf 的文件保存到已有同名文件的目录中,系统会以 confirmation(1).pdf 作为名称来保存新文件。

在配置 intent 时,应指定文件的名称和 MIME 类型,并且还可以根据需要使用 EXTRA_INITIAL_URI intent extra 指定文件选择器在首次加载时应显示的文件或目录的 URI。

scss 复制代码
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "application/pdf"
    putExtra(Intent.EXTRA_TITLE, "invoice.pdf")
    // 可选参数为要打开的目录指定一个URI 应用程序创建文档之前的系统文件选择器。
    //putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
}
activityResultLauncher.launch(intent)

上面的代码在我模拟器上是跳转到下载目录下,文件名是invoice.pdf 当用户点击保存按钮的时候,会返回一个Uri,我们拿到这个Uri 就可以将文件流写入进去了,所以这个玩意是占坑的,文件流都没有丢进去。这个支持设置一个uri。但是我传递MediaStore.Images.Media.EXTERNAL_CONTENT_URI 跳转进去还是下载目录。还没有尝试自己去查询一个目录的URI的方式。

同时官网文档上有一个访问权限限制

在 Android 11(API 级别 30)及更高版本中,您不能使用 ACTION_OPEN_DOCUMENT intent 操作来请求用户从以下目录中选择单独的文件:

  • Android/data/ 目录及其所有子目录。
  • Android/obb/ 目录及其所有子目录。

总结

  • 创建文件逻辑上可以指定文件的目录,前提是你得有这个目录或者这个目录下文件的URI。
  • 使用了ACTION_CREATE_DOCUMENT和CATEGORY_OPENABLE,MIME type

授予对目录内容的访问权限

在Android 5.0(API 21)之后才提供。

文件管理和媒体创建应用通常在目录层次结构中管理文件组。如需在您的应用中提供此功能,请使用 ACTION_OPEN_DOCUMENT_TREE intent 操作,它支持用户授予应用对整个目录树的访问权限,但在 Android 11(API 级别 30)及以上版本中会有一些例外情况。然后,您的应用便可以访问所选目录及其任何子目录中的任何文件。

使用 ACTION_OPEN_DOCUMENT_TREE 时,您的应用只能访问用户所选目录中的文件。您无权访问位于用户所选目录之外的其他应用的文件。借助这种由用户控制的访问权限,用户可以确切选择自己想要与您的应用共享的具体内容。

您可以根据需要使用 EXTRA_INITIAL_URI intent extra 指定文件选择器在首次加载时应显示的目录的 URI。

如果您在使用 ACTION_OPEN_DOCUMENT_TREE 访问的目录中遍历大量文件,应用的性能可能会降低。

在 Android 11(API 级别 30)及更高版本中,您不能使用 ACTION_OPEN_DOCUMENT_TREE intent 操作来请求访问以下目录:

  • 内部存储卷的根目录。
  • 设备制造商认为可靠的各个 SD 卡卷的根目录,无论该卡是模拟卡还是可移除的卡。可靠的卷是指应用在大多数情况下可以成功访问的卷。
  • Download 目录。

此外,在 Android 11(API 级别 30)及更高版本中,您不能使用 ACTION_OPEN_DOCUMENT_TREE intent 操作来请求用户从以下目录中选择单独的文件:

  • Android/data/ 目录及其所有子目录。
  • Android/obb/ 目录及其所有子目录。

示例代码:

scss 复制代码
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
    // 可选参数为要打开的目录指定一个URI 应用程序创建文档之前的系统文件选择器。
    //putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
}
activityResultLauncher.launch(intent)

在所选位置执行操作

上面主要是对于文件进行创建,目录权限获取等。不同的内容提供器支持对文档执行不同的操作,例如复制文档或查看文档的缩略图。如需确定指定提供器支持哪些操作,请查看 Document.COLUMN_FLAGS 的值。应用的界面只会显示提供器支持的选项。

保留权限

当您的应用打开文件进行读取或写入时,系统会向应用授予对该文件的 URI 的访问权限,该授权在用户重启设备之前一直有效。但是,假设您的应用是图片编辑应用,而且您希望用户能够直接从应用中访问他们最近修改的 5 张图片,那么在用户重启设备后,您就必须让用户返回到系统选择器来查找这些文件。

如需在设备重启后保留对文件的访问权限并提供更出色的用户体验,您的应用可以"获取"系统提供的永久性 URI 访问权限,如以下代码段所示:

kotlin 复制代码
val contentResolver = applicationContext.contentResolver
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
        Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, takeFlags)

如果关联的文档被移动或删除,即使调用了 takePersistableUriPermission(),您的应用也无法保留对该 URI 的访问权限。在此类情况下,您需要再次请求权限才能重新获得对该 URI 的访问权限。

通过URI 获取文件

官方还是建议通过contenResolver 去查询到文件真实的path,或者转化为bitmp,但是Android 很多图片加载框架支持直接丢URI,非图片还是需要自己查询,获得文件的绝对路径。建议先看Android文件系统(01)文件及其目录的基础知识 里面对于转化有一些常见的汇总。

删除文件

如果您获得了文档的 URI,并且该文档的 Document.COLUMN_FLAGS 包含 SUPPORTS_DELETE,您便可以删除该文档。所以,这个玩意还得有权限,其他APP创建的估计直接删除不了。

bash 复制代码
myUri?.let { DocumentsContract.deleteDocument(requireActivity().contentResolver, it) }

检测等效的媒体URL

我们随便打印一个SAF返回的URI。

content://com.android.providers.downloads.documents/document/4

可以看到,这个是fileProvider 提供的,所以我们需要获取到他的真实的地址。官方也提供了方案。

`getMediaUri()`\]([developer.android.com/reference/a...](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fprovider%2FMediaStore%3Fhl%3Dzh-cn%23getMediaUri(android.content.Context "https://developer.android.com/reference/android/provider/MediaStore?hl=zh-cn#getMediaUri(android.content.Context"), android.net.Uri)) 方法可提供与给定文档提供程序 URI 等效的媒体 URI。这两个 URI 指的是同一个基础项。使用媒体库 URI,您可以更轻松地[访问共享存储空间中的媒体文件](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Ftraining%2Fdata-storage%2Fshared%2Fmedia%3Fhl%3Dzh-cn "https://developer.android.com/training/data-storage/shared/media?hl=zh-cn")。 **注意** :此方法不授予任何新权限。您的应用必须具有必要的权限才能访问给定的文档提供程序 URI,例如[打开文档](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Ftraining%2Fdata-storage%2Fshared%2Fdocuments-files%3Fhl%3Dzh-cn%23open "https://developer.android.com/training/data-storage/shared/documents-files?hl=zh-cn#open")。 `getMediaUri()` 方法支持 `ExternalStorageProvider` URI。在 Android 12(API 级别 31)及更高版本中,此方法还支持 `MediaDocumentsProvider` URI。 #### 打开虚拟文件(这个没有懂,原封不动的复制官方文档的过来了) 在 Android 7.0(API 级别 25)及更高版本中,您的应用可以使用存储访问框架提供的虚拟文件。即使虚拟文件没有二进制文件表示形式,您的应用也可以通过以下方法打开文件中的内容:将虚拟文件强制转换为其他文件类型,或使用 [`ACTION_VIEW`](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fcontent%2FIntent%3Fhl%3Dzh-cn%23ACTION_VIEW "https://developer.android.com/reference/android/content/Intent?hl=zh-cn#ACTION_VIEW") intent 操作查看这些文件。 为了打开虚拟文件,您的客户端应用需要包含用于处理此类文件的特殊逻辑。如果您想要获取文件的字节表示形式(例如,为了预览文件),您需要从文档提供器请求其他 MIME 类型。 **注意** :由于应用无法使用 [`openInputStream()`](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fcontent%2FContentResolver%3Fhl%3Dzh-cn%23openInputStream(android.net.Uri) "https://developer.android.com/reference/android/content/ContentResolver?hl=zh-cn#openInputStream(android.net.Uri)") 方法直接打开虚拟文件,因此在创建包含 `ACTION_OPEN_DOCUMENT` 或 `ACTION_OPEN_DOCUMENT_TREE` 操作的 intent 时,请勿使用 [`CATEGORY_OPENABLE`](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fcontent%2FIntent%3Fhl%3Dzh-cn%23CATEGORY_OPENABLE "https://developer.android.com/reference/android/content/Intent?hl=zh-cn#CATEGORY_OPENABLE") 类别。 在用户做出选择后,请使用结果数据中的 URI 来确定文件是否为虚拟文件,如以下代码段所示: ```kotlin private fun isVirtualFile(uri: Uri): Boolean { if (!DocumentsContract.isDocumentUri(this, uri)) { return false } val cursor: Cursor? = contentResolver.query( uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS), null, null, null ) val flags: Int = cursor?.use { if (cursor.moveToFirst()) { cursor.getInt(0) } else { 0 } } ?: 0 return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0 } ``` 在验证文档为虚拟文件后,您可以将其强制转换为另一种 MIME 类型,例如 `"image/png"`。以下代码段展示了如何查看某个虚拟文件是否可以表示为图片,如果可以,则从该虚拟文件获取输入流: ```kotlin @Throws(IOException::class) private fun getInputStreamForVirtualFile( uri: Uri, mimeTypeFilter: String): InputStream { val openableMimeTypes: Array? = contentResolver.getStreamTypes(uri, mimeTypeFilter) return if (openableMimeTypes?.isNotEmpty() == true) { contentResolver .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null) .createInputStream() } else { throw FileNotFoundException() } } ``` ## 自定义DocumentsProvider [Google官方文档](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Fguide%2Ftopics%2Fproviders%2Fcreate-document-provider%3Fhl%3Dzh-cn "https://developer.android.com/guide/topics/providers/create-document-provider?hl=zh-cn") ,[官网提供的自定义DocumentsProvider Demo gitHub地址](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fandroid%2Fstorage-samples%2Ftree%2Fmaster%2FStorageProvider "https://github.com/android/storage-samples/tree/master/StorageProvider")。从逻辑上讲,如果一个APP 实现热更新或者可以拿到按照APP的权限,自己写一个docmentsProvider 下载或者按照到手机里面,把启动图标屏蔽掉。感觉也可以自己用,这么更新到也是两个APP,但是通常还是自己基于MediaStore自己查询,做不出来一个生态圈或者系统,还是自己查询自己整相对安全些。 ## 总结 在国内,我们使用这个得概率是不大的,但是架不住可能有其他不是图片、音频、视频等文件的需求,可能需要通过这种方式获取,因为这个要打开一个系统的Document APP,打开别人的APP,还是容易被搞的。

相关推荐
2501_915909062 小时前
HTTPS 错误解析,常见 HTTPS 抓包失败、443 端口错误与 iOS 抓包调试全攻略
android·网络协议·ios·小程序·https·uni-app·iphone
程序猿陌名!5 小时前
Android开发 AlarmManager set() 方法与WiFi忘记连接问题分析
android
骐骥16 小时前
2025-09-08升级问题记录: 升级SDK从Android11到Android12
android·android12·sdk31
CV资深专家10 小时前
Android 各分区模块编译配置(mk/bp)总结
android
louisgeek12 小时前
Java 线程池取消的方式
android
Billy_Zuo12 小时前
人工智能机器学习——模型评价及优化
android·人工智能·机器学习
tangweiguo0305198713 小时前
Flutter与原生混合开发:实现完美的暗夜模式同步方案
android·flutter
雨白14 小时前
深入理解 Android 触摸事件:以实现 ViewPager 为例
android
shenshizhong14 小时前
看懂鸿蒙系统源码 比较重要的知识点
android·harmonyos
一只修仙的猿16 小时前
再谈性能优化,一次项目优化经历分享
android·性能优化