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_URIcontent://media/external/images/media
视频MediaStore.Video.Media.EXTERNAL_CONTENT_URIcontent://media/external/video/media
音频MediaStore.Audio.Media.EXTERNAL_CONTENT_URIcontent://media/external/audio/media
非媒体文件MediaStore.Downloads.Media.EXTERNAL_CONTENT_URIcontent://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()
            }
        }
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值