官方页面 参考文章
一、概念
分区存储(Scoped Storage)的推出是针对 APP 访问外部存储的行为(乱建乱获取文件和文件夹)进行规范和限制,以减少混乱使得用户能更好的控制自己的文件。
公有目录被分为两大类:媒体文件(图片、音频、视频)的访问使用 MediaStore,其它文件通过系统的文件选择器访问 Storage Access Framework(简称SAF)。
二、MediaStore
跳转ContentProvider
class MediaStore.Images | 所有图片内容的类。 |
class MediaStore.Video | 所有视频内容的类。 |
class MediaStore.Audio | 所有音频内容的类。 |
class MediaStore.Files | 文件储存库中所有文件的索引,包括非媒体文件和媒体文件类。 |
interface MediaStore.MediaColumns | 文件储存库中表的公共字段(文件的各种信息)。 |
2.1 获取 Uri
使用 Context 获取到 ContentResolver 对象,通过 Uri 即可获取各种媒体库的 ContentProvider,从而对媒体文件进行操作。
文件类型 | MediaStore 常量 | Uri 地址 |
图片 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI | content://media/external/images/media |
视频 | MediaStore.Video.Media.EXTERNAL_CONTENT_URI | content://media/external/video/media |
音频 | MediaStore.Audio.Media.EXTERNAL_CONTENT_URI | content://media/external/audio/media |
非媒体文件 | MediaStore.Downloads.Media.EXTERNAL_CONTENT_URI | content://media/external/downloads |
val uri1 = Uri.parse("content://media/external/images/media")
val uri2 = MediaStore.Images.Media.getContentUri("external")
val uri3 = MediaStore.Images.Media.EXTERNAL_CONTENT_URI //推荐
2.2 读取媒体文件
列名(文件信息)可以在 MediaStore.MediaColumns 取公共常量字段,也可以根据文件类型的不同在具体内部类中取值。
文件类型 | MediaStore 常量(常用列名) | 说明 |
图片 | MediaStore.Images.Media._ID | 磁盘上文件的路径 |
MediaStore.Images.Media.DATA | 磁盘上文件的路径 | |
MediaStore.Images.Media.DATE_ADDED | 文件添加到media provider的时间(单位秒) | |
MediaStore.Images.Media.DATE_MODIFIED | 文件最后一次修改单元的时间 | |
MediaStore.Images.Media.DISPLAY_NAME | 文件的显示名称 | |
MediaStore.Images.Media.HEIGHT | 图像/视频的高度,以像素为单位 | |
MediaStore.Images.Media.MIME_TYPE | 文件的 MIME 类型 | |
MediaStore.Images.Media.SIZE | 文件的字节大小 | |
MediaStore.Images.Media.TITLE | 标题 | |
MediaStore.Images.Media.WIDTH | 图像/视频的宽度,以像素为单位 | |
视频 | MediaStore.Video.Media.TITLE | 名称 |
MediaStore.Video.Media.DURATION | 总时长 | |
MediaStore.Video.Media.DATA | 地址 | |
MediaStore.Video.Media.SIZE | 大小 | |
MediaStore.Video.Media.WIDTH | 视频的宽度,以像素为单位 | |
MediaStore.Video.Media.HEIGHT | 视频的高度,以像素为单 | |
音频 | MediaStore.Audio.Media.TITLE | 歌名 |
MediaStore.Audio.Media.ARTIST | 歌手 | |
MediaStore.Audio.Media.DURATION | 总时长 | |
MediaStore.Audio.Media.DATA | 地址 | |
MediaStore.Audio.Media.SIZ | 大小 |
public final Cursor query ( Uri uri, //要查询的 ContentProvider 的 Uri String[] projection, //要查询的字段(列Column),用 null 表示返回所有字段内容。 String selection, //查询条件,相当于SQL语句中的where,用 null 表示不进行筛选。 String[] selectionArgs, //如果 selection 里有?符号这里可以以实际值代替。没有的话可以为null。 String sortOrder //对结果进行排序,相当于SQL语句中的Order by,升序 asc /降序 desc,null为默认排序。 ) 返回的是一个封装了结果集的游标对象 Cursor ,资源用完需要调用 close() 关闭。 |
//获取图片类型的Uri
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
//要获取的信息(列名)
val projection = arrayOf(
MediaStore.Images.Media._ID, //获取ID
MediaStore.Images.Media.MIME_TYPE, //获取MIME_TYPE
MediaStore.Images.Media.DISPLAY_NAME //获取DISPLAY_NAME
)
//筛选条件(png格式的图片)
val selection = "${MediaStore.Images.Media.DISPLAY_NAME}='.png'" // ='xx.png' 改成 =?
//筛选条件的参数
val selectionArgs = arrayOf(".png") //替换筛选条件语句中?部分
//对结果的排序方式
val sortOrder = "${ContactsContract.Contacts._ID} DESC" //注意:desc前有空格
//开始查询(返回的是一个封装了结果集的游标对象,资源用完需要关闭使用use函数)
contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)?.use { cursor ->
//表都是通过行和列定位到具体的位置然后数据将其取出
cursor.run {
//获取字段在第几列(查询什么才能取出什么,否则空指针异常)
val idIndex = getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
val displayNameIndex = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
//循环取出每一行对应字段的数据
while (moveToNext()) {
val id = getLong(idIndex)
val mineType = getString(mimeTypeIndex)
val displayName = getString(displayNameIndex)
//合成图片的Uri
ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
//TODO...
}
}
}
//获取到的 Uri 可以通过 Glide 显示
Glide.with(context).load(uri).into(imageView)
//手动解析成图片的话
contentResolver.openFileDescriptor(uri, "")?.use {
val bitmap = BitmapFactory.decodeFileDescriptor(it.fileDescriptor)
imageView.setImageBitmap(bitmap)
}
2.3 写入媒体文件
通过 MediaStore 创建文件会保存到对应类型的默认目录中,也可以指定存放到其它同类型的公有目录或子文件夹中。如果存放到不同类型的公有目录中会报错 IllegalArgumentException(但是三种都可以存到Download中)。
文件类型 | mimeType 文件类型 | 默认存储目录(其它允许存储目录) |
图片 | image/* | Pictures(DICM) |
视频 | video/* | Movies(DICM) |
音频 | audio/* | Music(Alarms、Notifications、Podcasts、Ringtones) |
文件 | file/* | Download |
public final Uri insert( Uri url, ContentValues values) 构造一个 ContentValues 对象通过 ContentResolver.insert 插入到对应的目录中,对返回的 Uri 对象进行文件流写入即可。 |
val values = ContentValues().apply {
//指定 MimeType
put(MediaStore.Images.Media.MIME_TYPE,"image/png")
//指定文件名
put(MediaStore.Images.Media.DISPLAY_NAME,"${System.currentTimeMillis()}.png")
//指定保存的文件目录(如果不设置这个值,则会被默认保存到对应的系统几个媒体文件夹根目录下)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//Android 10中新增了一个RELATIVE_PATH常量,表示文件存储的相对路径,可选值有DIRECTORY_DCIM、DIRECTORY_PICTURES、DIRECTORY_MOVIES、DIRECTORY_MUSIC
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/DemoPicture")
} else {
//之前的系统版本中并没有RELATIVE_PATH,所以要使用 DATA 并拼装出一个文件存储的绝对路径才行
put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}${File.separator}${Environment.DIRECTORY_DCIM}${File.separator}${System.currentTimeMillis()}.png")
}
}
//插入文件数据库并获取到文件的Uri
val uri= contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
//对Uri进行文件流写入
uri?.let {
//通过outputStream将本地图片bitmap或网络图片输入流写入Url
contentResolver.openOutputStream(it)?.use { outputStream ->
//TODO...
//bitmap.compress(Bitmap.CompressFormat.PNG,100, outputStream)
}
}
2.4 下载文件到Download目录
方式和上面的写入一样,将网络获取的输入流写入。
- 注意:MediaStore.Downloads是Android 10中新增的API,Android 9及以下的系统版本仍然使用之前的代码来进行文件下载。
val inputStream = XXX.inputStream
val bis = BufferedInputStream(inputStream)
val buffer = ByteArray(1024)
//对Uri进行文件流写入
insertUri?.let {
//通过outputStream将本地bitmap或网络输入流写入Url
contentResolver.openOutputStream(it)?.use { outputStream ->
BufferedOutputStream(outputStream).use { bos ->
var bytes = bis.read(buffer)
while (bytes >= 0) {
bos.write(buffer, 0, bytes)
bos.flush()
bytes = bis.read(buffer)
}
}
}
}
三、使用文件选择器 SAF
对于非媒体文件,无法像之前那样手写一个文件浏览器,而是必须使用系统提供的内置文件选择器。通过 Intent 启动系统的文件选择器,然后在 onActivityResult() 中获取到用户选中文件的 Uri 通过ContentResolver打开文件输入流来进行读取就可以了。
const val PICK_FILE = 1
private fun pickFile() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
startActivityForResult(intent, PICK_FILE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
PICK_FILE -> {
if (resultCode == Activity.RESULT_OK && data != null) {
val uri = data.data
if (uri != null) {
val inputStream = contentResolver.openInputStream(uri)
// 执行文件读取操作
}
}
}
}
}
四、第三方库不支持的解决办法
编写一个文件复制功能,将Uri对象所对应的文件复制到应用程序的关联目录下,然后再将关联目录下这个文件的绝对路径传递给第三方SDK,这样就可以完美进行适配了。
fun copyUriToExternalFilesDir(uri: Uri, fileName: String) {
val inputStream = contentResolver.openInputStream(uri)
val tempDir = getExternalFilesDir("temp")
if (inputStream != null && tempDir != null) {
val file = File("$tempDir/$fileName")
val fos = FileOutputStream(file)
val bis = BufferedInputStream(inputStream)
val bos = BufferedOutputStream(fos)
val byteArray = ByteArray(1024)
var bytes = bis.read(byteArray)
while (bytes > 0) {
bos.write(byteArray, 0, bytes)
bos.flush()
bytes = bis.read(byteArray)
}
bos.close()
fos.close()
}
}
五、管理设备上所有的文件(公有目录 + 自定义目录)
绝大部分的应用程序都不应该申请这个权限,仅适用于文件浏览器、病毒查杀类APP,需要跳转到系统页面让用户手动授权,Play商店上架也会更严格。即便得到授权也只能访问 公有目录 + 自定义目录,依然无法访问私有目录。
5.1 权限声明
//不加 ignore 属性 AndroidStudio 会用警告提醒。
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
5.2 跳转系统页面授权
//系统低于11或者方法返回true说明已经拥有整个SD卡管理权限
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()
}
六、修改其它APP贡献的文件
修改其它APP贡献的文件是不安全的行为,默认情况下会抛异常,需要跳转到系统页面让用户手动授权,仅适用于美图秀秀类APP。在 Android 10 中每次跳转授权只能操作一张图片,如果一个程序需要修改很多张图片会很麻烦,在 Android 11 中提供了 Batch Operations 从而一次性对多个文件的操作权限进行申请。
- 由于10 之前没有分区存储,10 和 11以后是两套处理方案,专门针对 10 一个版本去写处理方案会很麻烦,由于 10 不是强制启用分区存储,可以在 AndroidManifest 中配置 requestLegacyExternalStorage 来禁用。
createWriteRequest() 请求对多个文件的写入权限。 |
createFavoriteRequest() 请求将多个文件加入到Favorite(收藏)的权限。 |
createTrashRequest() 请求将多个文件移至回收站的权限。 |
createDeleteRequest() 请求将多个文件删除的权限。 |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
/创建了一个集合用于存放所有要批量申请权限的文件Uri
val urisToModify = listOf(uri1, uri2, uri3, uri4)
//创建一个PendingIntent
val editPendingIntent = MediaStore.createWriteRequest(contentResolver, urisToModify)
//进行权限申请
startIntentSenderForResult(editPendingIntent.intentSender, EDIT_REQUEST_CODE, null, 0, 0, 0)
}
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()
}
}
}
}