———分割线————-

一 分区存储概述

Android 10之前,Android的文件存储现象就像个垃圾桶,但凡app取得了存储空间的读写权限WRITE_EXTERNAL_STORAGE,就可以肆意创建文件,难以管理。用户体验也特别差,打开文件管理器,会发现,想找个具体的文件根本无从下手。

1.1 分区存储原则

为了更好地管理自己的文件并减少混乱,加强隐私保护,Android Q开始引入了分区存储机制。外部存储空间被重新设计,按应用私有和公用共享划分。应用只能访问到自己私有空间,或者通过MediaStore APIStorage Access Framework去访问共享的资源目录。

分区存储主要遵循了三大原则对文件存储进行重新设计:

  1. 记录文件来源:系统会记录文件由哪个应用创建,应用不需要权限就可以对自己创建的文件进行读写;

    MediaStore数据库增加owner_package_name字段记录文件属于哪个应用, 应用卸载后owner_package_name字段会置空,也就是说,卸载重装后,之前创建的文件,已不属于应用创建的了,需要相关存储权限才能再次读写

  2. 应用数据的保护:对外部存储空间进行了访问限制,应用只能访问自身的私有空间或共享空间,即使获得了读写权限,也是无法访问其他应用的私有空间的;

  3. 用户数据保护:当用户下载了一些文件,比如带有敏感信息的邮件附件,这些文件应该对其他应用不可见。添加了pdf、office、doc等文件的访问限制,用户即使申请了存储权限也不能通过MediaStore访问其他应用创建的pdf、office、doc等文件,需要通过Storage Access Framework 框架,由用户参与选择,才能获得访问权限

Android 系统的版本越新,就越依赖于文件的用途而不是位置来确定应用对文件的访问能力

1.2 关于存储方式的兼容和判断

  1. 当targetSdk <= 28时,应用使用传统存储方式;
  2. 当targetSdk <= 29时,可以通过在应用清单的application标签中添加android:requestLegacyExternalStorage="true" ,从而关闭分区存储功能,继续使用传统访问方式。
  3. 当targetSdk>>=30时,Android会强制执行分区存储,无法关闭。
  4. 可以通过Environment.isExternalStorageLegacy()判断应用存储的运行方式,true表示以传统的兼容方式运行,false表示以分区存储运行

注意:当修改了requestLegacyExternalStorage属性的值,必须要卸载掉旧APK,重新安装才会生效

二 存储空间的影响

Android 提供了两类物理存储位置内部存储空间和外部存储空间。在大多数设备上,内部存储空间小于外部存储空间。不过,所有设备上的内部存储空间都是始终可用的,因此在存储应用所依赖的数据时更为可靠。

可移除卷(例如 SD 卡)在文件系统中属于外部存储空间。空间较大,现在的智能机基本都配有,但为了兼容性,也可在使用相关api时检查该空间是否处于可用状态。Environment.getExternalStorageState()

1
2
3
4
5
6
7
8
9
10
11
// 是否可读写
fun isExternalStorageWritable(): Boolean {
return Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
}

// 是否可读
fun isExternalStorageReadable(): Boolean {
return Environment.getExternalStorageState() in
setOf(Environment.MEDIA_MOUNTED, Environment.MEDIA_MOUNTED_READ_ONLY)
}
复制代码

在写入存储之前,需要知道设备有多少空间可用,不够的话会抛出异常。不过现在的智能设备内存也是比较大的,这部分可以参考Google 查询可用空间

2.1 内部存储空间

打开Android studio的 Device File Explorer时,可以看到应用的内部空间目录:/data/data/包名/

内部存储空间本身便是保护应用隐私设计的。这部分是没有变化的。应用不需要任何系统权限即可读取和写入这些目录中的文件。其他应用无法访问存储在内部存储空间中的文件。

内部存储空间为应用提供目录。一个目录专为应用的持久性文件而设计,而另一个目录包含应用的缓存文件。内部存储空间是应用专属的,是可以正常使用File相关api的,所以只要取得路径便可自由发挥:

  1. 持久性文件根目录File:context.filesDir(),/data/data/包名/files/
  2. 缓存性文件根目录File::context.cacheDir(),/data/data/包名/cache/

android也提供了一些简便的api创建删除文件:context.openFileOutput(filename, Context.MODE_PRIVATE)context.openFileInput(filename)context.fileList()context.getDir(dirName, Context.MODE_PRIVATE)context.delefteFile(fileName)

注意:卸载app后,系统会自动移除这些目录释放空间!!

2.2 外部存储空间

/storage/emulated/0/Android/data/包名

Android 10的分区存储特性,对Android系统的外部存储空间重新设计,外部存储被分为应用私有目录以及共享目录两个部分:

  1. 应用私有目录:存储应用私有数据,外部存储应用私有目录对应Android/data/包名
  2. 共享目录:存储其他应用可访问文件, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms, Music, Notifications,Podcasts, Ringtones、Movies、Download等目录。

2.2.1 应用私有空间

与以往相同的是,访问自身的外部存储下的应用私有空间是不需要任何权限的。与内部一样,也有一个目录专为应用的持久性文件而设计,和另一个目录包含应用的缓存文件。也是可以正常使用File相关api的,所以只要取得路径便可自由发挥。

需要注意的不同点是:开启了分区存储特性后,应用只能访问自身的私有空间,即使获得了存储权限,也无法访问其他应用的私有空间

另外与内部空间的区别是,外部存储空间可能被移除也可能有多个,所以返回的是一个数组,对于返回数组中的第一个元素被视为主外部存储卷。除非该卷已满或不可用,否则请使用该卷。

  1. 持久性文件:getExternalFilesDirs(@NonNull Context context, @Nullable String type),type根据文件类型可传系统预定义的子目录常量,如图片Environment.DIRECTORY_PICTURES,此时返回/storage/emulated/0/Android/data/包名/files/Pictures。或者传null直接返回/storage/emulated/0/Android/data/包名/files
  2. 缓存性文件:ContextCompat.getExternalCacheDirs(context)/storage/emulated/0/Android/data/包名/cache

注意:卸载app后,系统会自动移除这些目录释放空间!!

三 共享存储空间的影响

如果用户数据可供或应可供其他应用访问,并且即使在用户卸载应用后也可对其进行保存,请使用共享存储空间。

共享文件类型, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms, Music, Notifications,Podcasts, Ringtones、Movies、Download等目录。Android 分别提供用于获得该类型可共享数据文件Uri的 API:

  • 媒体内容:可以使用 MediaStore API 访问此内容
  • 文档和其他文件:系统有一个特殊目录,用于包含其他文件类型,例如 PDF 文档和采用 EPUB 格式的图书。应用可以使用Storage Access Framework访问这些文件。

对于共享文件,。以往可以通过data column获得路径,再使用File API来操作,现在都会返回失败。开启了分区存储特性之后,应用只能通过系统提供的api来向系统请求得到对应文件的Uri,并通过Uri生成FileDescriptorInputStream等方式进行文件读写:(简而言之,对于共享文件的增删查改,主要问题在于Uri的获取)

注:android 11 又允许通过路径来访问,系统会自动重定向为Uri。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
               val resolver = applicationContext.contentResolver
//读
resolver.openFileDescriptor(content-uri, "r")?.use { pfd ->
val inputStream = FileInputStream(pfd.fileDescriptor)
}
resolver.openInputStream(content-uri).use { stream ->
}

//写
resolver.openFileDescriptor(content-uri, "w")?.use { pfd ->
val outputStream = FileOutputStream(pfd.fileDescriptor)
}
resolver.openOutputStream(content-uri).use { stream ->
}

//图片bitmap
BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor)
复制代码

3.1 MediaStore API

MediaStrore API的增删查改,可参看Google官方指南,主要是通过contentResolver获得对应的uri,这里就不引入了。图片来源

img

3.1.1 MediaStore 概述

Android系统会自动扫描外部存储空间,将媒体文件按类型添加到系统预定义的Images、Videos、Audio files、Downloaded files集合中。Android Q通过MediaStore.Images、MediaStore.Video、MediaStore.Audio、MediaStore.Downloads 访问相对应共享目录文件资源。预定义集合所对应的目录如下表所示:

媒体类型 Uri 默认创建目录 允许创建目录
Image content://media/external/images/media Pictures DCIM,Pictures
Audio content://media/external/audio/media Music Alarms,Music,Notifications,Podcasts,Ringtones
Video content://media/external/video/media Movies DCIM,Movies
Download content://media/external/downloads Download Download

注意:MediaStore.Downloads.EXTERNAL_CONTENT_URI是Android10版本新增API,用于创建、访问非媒体文件

3.1.1.1

系统里有external.db数据库,该数据库里有files表,该表里存放着共享文件的诸多信息,如图片有宽高,经纬度、存放路径等,视频宽高、时长、存放路径等。而文件真正存放的地方在于共享存储空间。

1、保存图片到相册
当App1保存图片到相册时,简单流程如下:

1、将路径信息写入数据库里,并获取Uri
2、通过Uri构造输出流
3、将该图片保存在/sdcard/Pictures/目录下

2、从相册获取图片
当App2从相册获取图片时,简单流程如下:

1、先查询数据库,找到对应的图片Cursor
2、从Cursor里构造Uri
3、从Uri构造输入流读取图片

以上以图片为例简单分析了共享存储空间文件的写入与读取,实际上对于视频、音频步骤亦是如此。

MediaStore.java其内部有Audio、Images等内部类,这些内部类里记录着files表的各个字段名,通过构造这些参数就可以插入相应的字段值以及获取对应的字段值。MediaStore 实际上就是相当于给各个字段起了别名,我们编码的时候更容易记住与使用:

比如想要查询共享存储空间里的图片文件:

1
Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null);

MediaStore.Images.Media.EXTERNAL_CONTENT_URI 意思是指定查询文件的类型是图片,并构造成Uri对象,Uri实现了Parcelable,能够在进程间传递。
接收方(另一个进程收到后),匹配Uri,解析出对应的字段,进行具体的操作。当然,MediaStore是系统提供的方便操作共享存储空间的类,若是自己写ContentProvider,则也可以自定义类似MediaStore的类用来标记自己的数据库表的字段。

3.1.1 MediaStore 的变化

  1. MediaStore API在共享目录指定目录下创建文件或者访问应用自己创建文件,不需要申请存储权限;
  2. MediaStore API访问其他应用在共享目录创建的媒体文件(图片、音频、视频), 需要申请存储权限,未申请存储权限,通过ContentResolver查询不到文件Uri,即使通过其他方式获取到文件Uri,读取或创建文件会抛出异常;
  3. MediaStore API不能够访问其他应用创建的非媒体文件(pdf、office、doc、txt等), Android 10 里唯一一种访问其他应用创建的非媒体文件的途径是使用存储访问框架 (Storage Access Framework) 提供的文档选择器。

3.1.2 通过api创建的文件存放到哪里?如何自定义位置?

当通过MediaStore API创建文件时,文件会默认保存到对应的类型目录,比如图片存到Pictures/目录下,可以往上查看表格的默认目录及允许目录;

可以使用MediaStore.xxx.Media.RELATIVE_PATH自己指定要存放的目录或者子目录,如:contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/自定义子目录"),文件就会放在Pictures/自定义子目录/ 中;或者使用contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment. DIRECTORY_DCIM),将文件放到DCIM/

注意:每一种类型都有对应的可允许创建的目录,否则会返回失败。具体可创建目录可以往上查看表格

3.1.3 extrnal.db

Android 11 (API 级别 30 3030) 之后的存储机制 : Android 中所有文件存储 , 都会将文件的索引存储在数据库中 , 在 /data/data/com.android.providers.media 目录下的文件就是专门用于管理该数据库的 ;

在 /data/data/com.android.providers.media 目录下有 4 44 个目录 , cache , code_cache , databases , shared_prefs , 其中 databases 就是存储的上述文件索引数据库 , 有两个数据库文件 : external.db , internal.db ;

查看命令 :

1
2
3
4
5
6
7
walleye:/ # cd  /data/data/com.android.providers.media
walleye:/data/data/com.android.providers.media # ls
cache code_cache databases shared_prefs
walleye:/data/data/com.android.providers.media # cd databases
walleye:/data/data/com.android.providers.media/databases # ls
external.db internal.db
walleye:/data/data/com.android.providers.media/databases #

将 external.db 数据库拷贝出来 , 使用 SQLite 查看工具查看该数据库 , 该数据库中有一张 files 数据表 , 就是存放文件索引的 , 只有开启分区存储时 , 才将所有的文件都保存在该数据库表中 ;

生成文件索引 :

在老版本的文件系统中 , 只有将文件放在 Pictures , Movies , Music , Document 等目录下时 , 才生文件索引放在数据库中 , 在 SD 卡其它位置创建文件 , 不会生成文件索引 ;

在新版本的分区存储中 , 上述数据表中管理所有 SD 卡上的文件 , 所有的文件和目录都会显示在上述数据库中 ;

external.db 数据库 files 数据表字段简介 :
_id 每个文件都会被赋予一个 id , 不会重复 ;
tittle 文件名称 , 一般是文件的完整名称去掉文件后缀 ;
_display_name 文件的完整名称 , 带后缀 ;
mime_type 文件的 MIME 类型 ;

mediastore使用:

一个 文本文件 , 只能存储在 Download 和 Documents 目录下 , Download 目录可以存放任何类型的文件 , Documents 目录只能存储文本文件 ;

首先通过 MediaStore 获取 Files 内部类对象 , 调用该内部类的 getContentUri(“external”) , 即可获取在 【Android 文件管理】分区存储 ( 分区存储机制 和 文件索引数据 ) 四、文件索引数据库 博客章节提到的文件索引数据库 , 然后就可以通过 ContentValues 向其中插入数据 ;

获取数据库 :

1
2
3
// 操作 external.db 数据库
// 获取 Uri 路径
var uri: Uri = MediaStore.Files.getContentUri("external")

插入数据时 , 构造 ContentValues 数据结构 , 主要是设置 external.db 数据库中 files 数据表对应的条目 , 设置该条目的主要字段值 ;

构造 ContentValues 数据 :

1
2
3
4
5
6
7
8
9
10
11
12
// 将要新建的文件的文件索引插入到 external.db 数据库中
// 需要插入到 external.db 数据库 files 表中, 这里就需要设置一些描述信息
var contentValues: ContentValues = ContentValues()

// 设置插入 external.db 数据库中的 files 数据表的各个字段的值

// 设置存储路径 , files 数据表中的对应 relative_path 字段在 MediaStore 中以常量形式定义
contentValues.put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/hello")
// 设置文件名称
contentValues.put(MediaStore.Downloads.DISPLAY_NAME, "hello.txt")
// 设置文件标题, 一般是删除后缀, 可以不设置
contentValues.put(MediaStore.Downloads.TITLE, "hello")

ContentValues 构造成功后 , 使用 ContentResolver 将数据插入数据库中 ; 系统会自动创建对应的文件 ;

向数据库中插入数据 :

1
2
// uri 表示操作哪个数据库 , contentValues 表示要插入的数据内容
var insert: Uri = contentResolver.insert(uri, contentValues)!!

系统自动创建的文件是一个目录文件 , 向其中写出 “Hello World” 文本数据 , 即可完成相关文件创建 ;

通过返回的 Uri 打开输出流 , 向文件中写出数据 :

1
2
3
4
5
// 向 Download/hello/hello.txt 文件中插入数据
var os: OutputStream = contentResolver.openOutputStream(insert)!!
var bos = BufferedOutputStream(os)
bos.write("Hello World".toByteArray())
bos.close()

3.2 Storage Access Framework

SAF框架支持用户与系统选择器互动,从而选择文档提供器以及供您的应用创建、打开或修改的特定文档和其他文件。由于用户参与了文件的选择,因此该机制无需任何系统权限。

应用通过调用 ACTION_CREATE_DOCUMENT , ACTION_OPEN_DOCUMENT , 和ACTION_OPEN_DOCUMENT_TREE Intent获取Document provider提供的文件,并在onActivityResult接口接收返回的被选择文件的Uri。另外,在配置 intent 时,应指定文件的名称和 MIME 类型,并且还可以根据需要使用 EXTRA_INITIAL_URI intent extra 指定文件选择器在首次加载时应显示的文件或目录的 URI。

这部分也是没变化的,可参考官方指南:从共享存储空间访问文档和其他文件

3.2.1 获取持久权限

对于通过SAF框架获得的uri权限,可以通过申请持久权限,不用每次重启手机都要重新请求。

1
2
3
4
5
contentResolver.takePersistableUriPermission(
documentUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
复制代码

四 存储特性Android版本差异概览

图片来源

imgimg

4.1 其他变化:图片位置信息

一些照片在元数据中包含位置信息,以便用户查看照片的拍摄地点。由于此位置信息属于敏感信息,如果应用使用了分区存储,默认情况下 Android 10 会对应用隐藏此信息。

如果应用需要访问照片的位置信息:

  1. 在应用清单中请求ACCESS_MEDIA_LOCATION权限
  2. 通过调用 setRequireOriginal(),从 MediaStore 对象获取照片的确切字节,并传入照片的 URI

图片中的位置信息获取

五 更新

5.1 Android 10 的分区存储”bug”

Android 10 通过媒体MediaStore API 删除(delete)一个媒体文件,只是简单移除了MediaStore数据库的索引,并不会真正删除物理存储上的实体文件,而且只要手机重启,则索引又被加上去了。issue

这需求也比较少见,只是刚好测试发现了。网上查了下,的确存在这个问题,Android 11 就可以正常删除了。 要是有什么解决方案,热烈欢迎指出!!

5.2 Android 11 的存储变更

5.2.1 允许继续使用原始文件路径

可以再次使用文件路径,系统自动重定向为Uri

5.2.2 增加批量操作

在 Android 10 中,应用在对MediaStore的每一个文件请求编辑或删除时都必须一个个地得到用户的确认。而在 Android 11 中,应用可以一次请求修改或者删除多个媒体文件。

Screenshot_20201203_193743

主要通过以下新增的批量操作api

方法 说明
MediaStore.createDeleteRequest (resolver, uris) 批量删除(不放入回收站)
MediaStore.createFavoriteRequest(resolver, uris) 批量收藏
MediaStore.createTrashRequest (resolver, uris) 批量移入回收站
MediaStore.createWriteRequest(resolver, uris) 批量获得写入权限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val uris = ...
val pi = MediaStore.createWriteRequest(contentResolver,
uris)
startIntentSenderForResult(pi.intentSender, REQUEST_CODE, null, 0, 0, 0)

//相应
override fun onActivityResult(xxx) {
when (requestCode) {
REQUEST_CODE ->
if (resultCode == Activity.RESULT_OK) {
//获得权限,继续操作
} else {
// 用户拒绝了权限授予
}
}
}
复制代码

六 参考链接

作者:CYQ
链接:https://juejin.cn/post/6902285295513763848
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

———分割线————-

Saf访问框架

文章源

1.介绍

Android 4.4 就引入了存储访问框架 (SAF)。借助 SAF,用户可轻松在其所有首选文档存储提供程序中浏览并打开文档、图像及其他文件。用户可通过易用的标准界面,以统一方式在所有应用和提供程序中浏览文件,以及访问最近使用的文件。

SAF 提供的部分功能:

  • 让用户浏览所有文档提供程序的内容,而不仅仅是单个应用的内容。
  • 让您的应用获得对文档提供程序所拥有文档的长期、持续性访问权限。用户可通过此访问权限添加、编辑、保存和删除提供程序上的文件。
  • 支持多个用户帐户和临时根目录,如只有在插入驱动器后才会出现的 USB 存储提供程序。

虽说早在Android 4.4就已经引入了,但是我却从未使用过。。。然而在适配Android 10中它却是一个无法忽略的存在。因为Android 10的外部存储访问限制,我们无法像以前一样自由的操作文件。SAF就是应对这一限制的方法之一。

2.使用

选择文件

使用Intent.ACTION_OPEN_DOCUMENT可以调起文件选择页面,选择一个文件。我以选择图片文件为例:

1
2
3
4
5
6
7
8
    //通过系统的文件浏览器选择一个文件
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
//筛选,只显示可以“打开”的结果,如文件(而不是联系人或时区列表)
intent.addCategory(Intent.CATEGORY_OPENABLE);
//过滤只显示图像类型文件
intent.setType("image/*");
startActivityForResult(intent, REQUEST_CODE_FOR_SINGLE_FILE);
复制代码

文件选择页面如下(系统MIUI 11):

在这里插入图片描述

onActivityResult获取文件Uri,同时也可以通过ContentResolver查询文件信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private final String[] IMAGE_PROJECTION = {
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media._ID };

@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
Uri uri = null;
if (resultData != null) {
// 获取选择文件Uri
uri = resultData.getData();
// 获取图片信息
Cursor cursor = this.getContentResolver()
.query(uri, IMAGE_PROJECTION, null, null, null, null);

if (cursor != null && cursor.moveToFirst()) {
String displayName = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[0]));
String size = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[1]));
Log.i(TAG, "Uri: " + uri.toString());
Log.i(TAG, "Name: " + displayName);
Log.i(TAG, "Size: " + size);
}
cursor.close();
}
}
}
复制代码

创建文件

这部分的用法我暂时也只在淘宝App -> 商品评论 -> 保存评论图片的地方看到过。有兴趣的可以去试试。

具体用法(我以创建txt文件为例):

1
2
3
4
5
6
7
8
9
10
    public void createFile() {
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
// 文件类型
intent.setType("text/plain");
// 文件名称
intent.putExtra(Intent.EXTRA_TITLE, System.currentTimeMillis() + ".txt");
startActivityForResult(intent, WRITE_REQUEST_CODE);
}
复制代码

交互页面如下:

在这里插入图片描述

读取文件

获得文件的 Uri 后,就可以对其执行任何操作。

  1. Bitmap
1
2
3
4
5
6
7
8
9
    private Bitmap getBitmapFromUri(Uri uri) throws IOException {
ParcelFileDescriptor parcelFileDescriptor =
getContentResolver().openFileDescriptor(uri, "r");
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
parcelFileDescriptor.close();
return image;
}
复制代码
  1. 获取 InputStream
1
2
3
4
5
6
7
8
9
10
11
12
13
    private String readTextFromUri(Uri uri) throws IOException {
StringBuilder stringBuilder = new StringBuilder();
try (InputStream inputStream = getContentResolver().openInputStream(uri);
BufferedReader reader = new BufferedReader(
new InputStreamReader(Objects.requireNonNull(inputStream)))) {
String line;
while ((line = reader.readLine()) != null) {
stringBuilder.append(line);
}
}
return stringBuilder.toString();
}
复制代码

修改文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    private void alterDocument(Uri uri) {
if (uri != null) {
OutputStream outputStream = null;
try {
// 获取 OutputStream
outputStream = getContentResolver().openOutputStream(uri);
outputStream.write("Storage Access Framework Example".getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
Toast.makeText(this, "修改文件失败!", Toast.LENGTH_SHORT).show();
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.fillInStackTrace();
}
}
}
}
}
复制代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    private void alterDocument(Uri uri) {
try {
ParcelFileDescriptor pfd = getContentResolver().
openFileDescriptor(uri, "w");
FileOutputStream fileOutputStream =
new FileOutputStream(pfd.getFileDescriptor());
fileOutputStream.write(("Storage Access Framework Example").getBytes());
fileOutputStream.close();
pfd.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
复制代码

删除文件

使用DocumentsContract.deleteDocument 方法进行删除。

1
2
3
4
5
6
7
8
9
10
    public void deleteFile(Uri uri) {
if (uri != null) {
try {
DocumentsContract.deleteDocument(getContentResolver(), uri);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
}
复制代码

选择目录(Android 5.0以上支持)

使用Intent.ACTION_OPEN_DOCUMENT_TREE可以调起文件目录选择页面,选择一个目录,并将其子文件夹的读写权限授予APP。

1
2
3
4
5
6
    private void selectDir() {
// 用户可以选择任意文件夹,将它及其子文件夹的读写权限授予APP。
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, REQUEST_CODE_FOR_DIR);
}
复制代码

交互页面如下:

在这里插入图片描述

onActivityResult获取目录的Uri,并创建DocumentFile来进行文件操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
if (requestCode == REQUEST_CODE_FOR_DIR && resultCode == Activity.RESULT_OK) {
Uri uriTree = null;
if (data != null) {
uriTree = data.getData();
}
if (uriTree != null) {
// 创建所选目录的DocumentFile,可以使用它进行文件操作
DocumentFile root = DocumentFile.fromTreeUri(this, uriTree);
// 比如使用它创建文件夹
DocumentFile dir = root.createDirectory(”Test“);
}
}
}
复制代码

当然每次这样选择授权会很麻烦,所以我们也可以在首次授权时保存获取的目录权限:

1
2
3
4
5
6
7
8
9
10
11
	// 获取权限
final int takeFlags = resultData.getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(uri, takeFlags);
// 保存获取的目录权限
SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sp.edit();
editor.putString("uriTree", uri.toString());
editor.apply();
复制代码

使用时从SharedPreferences获取uriTree,不存在或是无权限则重新授权:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
String uriTree = sp.getString("uriTree", "");
if (TextUtils.isEmpty(uriTree)) {
// 重新授权
} else {
try {
Uri uri = Uri.parse(uriTree);
final int takeFlags = getIntent().getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(uri, takeFlags);
DocumentFile root = DocumentFile.fromTreeUri(this, uri);
} catch (SecurityException e) {
// 重新授权
}
}
复制代码

上面代码中使用到的takePersistableUriPermission方法是为了检查最新的数据。防止另一个应用可能删除或修改了文件导致Uri失效。

有了授权就有撤销授权,使用releasePersistableUriPermissionrevokeUriPermission方法就可以实现权限的撤销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    public void releasePermission(View view) {
SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
String uriTree = sp.getString("uriTree", "");
if (!TextUtils.isEmpty(uriTree)) {
Uri uri = Uri.parse(uriTree);
final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;

getContentResolver().releasePersistableUriPermission(uri, takeFlags);
//
this.revokeUriPermission(uri, takeFlags);
// 重启才会生效,所以可以清除uriTree
SharedPreferences.Editor editor = sp.edit();
editor.putString("uriTree", "");
editor.apply();
}
}

复制代码

或者在应用设置页面点击取消访问权限手动删除(MIUI 11 上未发现此按钮):

在这里插入图片描述

本篇都是具体场景的的使用示例,完整的代码我已上传GitHub。可以去自行查看体验。

3.参考

作者:唯鹿
链接:https://juejin.cn/post/6844904058743078919
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!