Android - 分区存储 MediaStore、SAF

官方页面

参考文章

一、概念

分区存储(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 |

Kotlin 复制代码
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() 关闭。 |

Kotlin 复制代码
//获取图片类型的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 对象进行文件流写入即可。 |

Kotlin 复制代码
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及以下的系统版本仍然使用之前的代码来进行文件下载。
Kotlin 复制代码
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打开文件输入流来进行读取就可以了。

Kotlin 复制代码
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,这样就可以完美进行适配了。

Kotlin 复制代码
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 权限声明

XML 复制代码
//不加 ignore 属性 AndroidStudio 会用警告提醒。
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
    tools:ignore="ScopedStorage" />

5.2 跳转系统页面授权

Kotlin 复制代码
//系统低于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() 请求将多个文件删除的权限。 |

Kotlin 复制代码
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()
            }
        }
    }
}
相关推荐
约翰先森不喝酒5 小时前
Android RecyclerView 实现 GridView ,并实现点击效果及方向位置的显示
android
wk灬丨6 小时前
Android Choreographer 监控应用 FPS
android·kotlin
大胃粥6 小时前
Android U WMS : Activity 冷启动(2) 添加启动窗口
android
魏大橙7 小时前
长亭WAF绕过测试
android·运维·服务器
志尊宝7 小时前
Android 中使用高德地图实现根据经纬度信息画出轨迹、设置缩放倍数并定位到轨迹路线的方法
android
吾爱星辰7 小时前
Kotlin while 和 for 循环(九)
android·开发语言·kotlin
我命由我123457 小时前
Kotlin 极简小抄 P3(函数、函数赋值给变量)
android·开发语言·java-ee·kotlin·android studio·学习方法·android-studio
大风起兮云飞扬丶8 小时前
Android——内部/外部存储
android
niurenwo9 小时前
Android深入理解包管理--记录存储模块
android
文 丰10 小时前
【Android Studio】使用雷电模拟器调试
android·ide·android studio