Android分区存储适配

157 阅读6分钟

分区存储

Android系统中的存储区域

在学习分区存储之前,先了解Android系统中存储区域的划分,可以更好的理解分区存储的概念。

  • 什么是内部存储?什么是外部存储?
  • 什么是应用专属目录?什么是共享目录?

更多内容可参考文章: 官方文档:存储空间简介

Android存储区域介绍

分区存储

首先看下,官方文档:分区存储的描述:

为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。此类应用只能访问外部存储空间上的应用专属目录,以及本应用所创建的特定类型的媒体文件。

然后看下,官方文档:Android 存储用例和最佳做法对分区存储的描述:

为了让用户更好地控制自己的文件并减少混乱,Android 10 针对应用推出了一种新的存储范例,称为分区存储。分区存储改变了应用在设备的外部存储空间中存储和访问文件的方式。为便于迁移应用以支持分区存储,请遵循本指南中有关常见存储用例的最佳做法。这些用例分为两类:处理媒体文件和处理非媒体文件。

结合官方文档对分区存储的描述,我们可以简单理解为:

  • 分区存储对应用内部存储私有目录和外部存储私有目录下的文件访问没有影响。
  • 分区存储主要影响的是应用对共享目录中文件的访问权限和方式。
  • 共享目录下的文件分为媒体文件和非媒体文件,每类文件又分为应用自己创建的文件和其他应用创建的文件。
    • 媒体文件:图片、视频、音频。
    • 非媒体文件:图片、视频、音频文件之外的其它文件都属于非媒体文件,包括pdf、odt和txt等文件。

适配方案

MediaStore API

在Android 9及以下版本,可以使用File API直接访问共享目录中的文件。

在Android 10 及以上版本,使用MediaStore API或者Storage Access Framework方式访问共享目录中的文件。

  • MediaStore API 在共享目录下访问应用自己创建文件,不需要申请任何存储权限。
  • MediaStore API访问其他应用在共享目录创建的媒体文件(图片、音频、视频), 需要申请存储权限。
  • MediaStore API不能够访问其他应用创建的非媒体文件(pdf、office、doc、txt等), 只能够通过Storage Access Framework方式访。

更多介绍请参考 官方文档:访问共享存储空间中的媒体文件

官方文档:访问您自己的媒体文件

官方文档:访问其他应用的媒体文件

官方文档:从共享存储空间访问文档和其他文件

官方文档:Android 存储用例和最佳做法

File API

还有一种方案是所有Android 版本都使用File API直接访问共享目录中的文件。

  • 在Android 10版本下,可以选择停用分区存储,依然使用File API访问文件。
  • 在Android 11及更高版本,依然允许使用File API来访问共享目录中的媒体文件。
    • 使用File API随机读取和写入媒体文件时,进程的速度可能最多会慢一倍。在此类情况下,我们建议您改为使用 MediaStore API。

更多介绍请参考暂时停用分区存储

更多介绍请参考直接文件路径访问共享媒体文件

MediaStore API 和Storage Access Framework

我们通过以下几个小例子来演示MediaStore API 和Storage Access Framework:

  1. 在共享目录下创建A.txt文件,并将"Hello World"写入到文件中。
  2. 读取共享目录下的A.txt文件内容。
  3. 修改共享目录下的A.txt文件内容。
  4. 删除共享目录下的A.txt文件
  5. 读取其它应用在共享目录下创建的B.txt文件内容。

创建文件并写入内容

在共享目录下创建A.txt文件,并将"Hello Word"写入到文件。

Android 10及以上版本

在Android 10及以上版本中,由于引入了更严格的存储访问权限(Scoped Storage),推荐使用MediaStore API 来访问和管理共享目录中的文件。

 private fun writeContentAboveQ() {
    // 新文件的URI
    val uri = getInsertUri() ?: return

    // 通过URI获取文件的输出流,以便写入文件内容
    applicationContext.contentResolver.openOutputStream(uri)?.use { outputStream ->
        outputStream.write("Hello World".toByteArray()) // 将字符串"Hello World"写入文件
    }
}

private fun getInsertUri(): Uri? {
    // 创建包含文件信息的ContentValues对象
    val values = ContentValues().apply {
        put(MediaStore.Files.FileColumns.DISPLAY_NAME, "A.txt") // 设置文件显示名称为A.txt
        put(MediaStore.Files.FileColumns.MIME_TYPE, "text/plain") // 设置文件的MIME类型为text/plain
        put(MediaStore.Files.FileColumns.RELATIVE_PATH, Environment.DIRECTORY_DOCUMENTS) // 设置文件相对路径为Documents目录
    }

    // 获取ContentResolver对象
    val resolver = applicationContext.contentResolver

    // 返回外部存储文件的 Uri。在参数中,"external" 表示外部存储(即外部SD卡或内部存储的共享文件)
    val externalUri: Uri = MediaStore.Files.getContentUri("external")

    // 插入新文件的URI到MediaStore中
    return resolver.insert(externalUri, values)
}

Android 9及以下版本

在Android 9及以下版本中,可以直接使用File API 来访问和管理共享目录中的文件。

private fun writeContentBelowQ() {
    val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
    if (!documentsDir.exists()) {
        documentsDir.mkdirs()
    }

    val file = File(documentsDir, "A.txt")
    file.writeText("Hello World")
}

读取共享目录下本应用创建的文件内容

读取共享目录下的A.txt文件内容。

Android 10及以上版本,使用Scoped Storage方式读取文件内容:

private fun readContentAboveQ() {
    // 获取文件的URI
    val uri = getQueryUri() ?: return

    // 通过URI获取文件的输入流,以便读取文件内容
    applicationContext.contentResolver.openInputStream(uri)?.use { inputStream ->
        val content = inputStream.bufferedReader().use { it.readText() } // 读取文件内容
        // 处理文件内容,例如打印输出
        println("File content: $content")
    }
}

private fun getQueryUri(): Uri? {
    // 创建查询参数
    val queryUri = MediaStore.Files.getContentUri("external")
    val projection = arrayOf(
        MediaStore.Files.FileColumns._ID,
        MediaStore.Files.FileColumns.DISPLAY_NAME,
        MediaStore.Files.FileColumns.MIME_TYPE,
        MediaStore.Files.FileColumns.RELATIVE_PATH
    )
    val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
    val selectionArgs = arrayOf("A.txt")

    // 执行查询
    applicationContext.contentResolver.query(
        queryUri,
        projection,
        selection,
        selectionArgs,
        null
    )?.use { cursor ->
        return if (cursor.moveToFirst()) {
            val idColumn = cursor.getColumnIndex(MediaStore.Files.FileColumns._ID)
            val id = cursor.getLong(idColumn)
            val uri = ContentUris.withAppendedId(queryUri, id)
            uri
        } else {
            null
        }
    }
    return null
}

Android 9及以下版本,直接读取文件内容:

private fun readContentBelowQ() {
    val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
    val file = File(documentsDir, "A.txt")
    if (file.exists()) {
        val content = file.readText()
        // 处理文件内容,例如打印输出
        println("File content: $content")
    }
}

修改共享目录下本应用创建的文件内容。

修改共享目录下的A.txt文件内容,在A.txt文件原内容后追加内容"\nHello File"。

Android 10及以上版本,使用Scoped Storage方式修改文件内容:

private fun appendContentAboveQ() {
    // 查询指定文件的URI
    val uri = getUriForFile() ?: return

    // 打开文件的输出流,并追加内容
    applicationContext.contentResolver.openOutputStream(uri, "wa")?.use { outputStream ->
        outputStream.write("\nHello MediaStore".toByteArray())
    }
}

// 根据文件名获取文件的URI
private fun getUriForFile(): Uri? {
    // 返回外部存储文件的 Uri。在参数中,"external" 表示外部存储(即外部SD卡或内部存储的共享文件)
    val externalUri = MediaStore.Files.getContentUri("external")

    // 查询条件
    val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
    val selectionArgs = arrayOf("A.txt")

    // 查询文件的URI
    applicationContext.contentResolver.query(externalUri, null, selection, selectionArgs, null)?.use { cursor ->
        if (cursor.moveToFirst()) {
            val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
            val id = cursor.getLong(idColumn)
            return ContentUris.withAppendedId(externalUri, id)
        }
    }
    return null
}

Android 9及以下版本,直接修改文件内容:

private fun appendContentBelowQ() {
    val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
    if (!documentsDir.exists()) {
        documentsDir.mkdirs()
    }

    val file = File(documentsDir, "A.txt")
    file.appendText("Hello File")
}

删除共享目录下的A.txt文件

Android 10及以上版本

private fun deleteFileAboveQ() {
    // 获取文件的URI
    val uri = getQueryUri() ?: return

    // 通过URI删除文件
    applicationContext.contentResolver.delete(uri, null, null)
}

private fun getQueryUri(): Uri? {
    // 创建查询参数
    val queryUri = MediaStore.Files.getContentUri("external")
    val selection = "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
    val selectionArgs = arrayOf("A.txt")

    // 执行查询
    applicationContext.contentResolver.query(
        queryUri,
        null,
        selection,
        selectionArgs,
        null
    )?.use { cursor ->
        return if (cursor.moveToFirst()) {
            val idColumn = cursor.getColumnIndex(MediaStore.Files.FileColumns._ID)
            val id = cursor.getLong(idColumn)
            ContentUris.withAppendedId(queryUri, id)
        } else {
            null
        }
    }
    return null
}

Android 9及以下版本

直接使用File API删除文件

private fun deleteFileBelowQ() {
    val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
    val file = File(documentsDir, "A.txt")
    if (file.exists()) {
        file.delete()
    }
}

读取其它应用在共享目录下创建的B.txt文件内容。

Android 10及以上版本,

如果要读取其它应用在共享目录下创建的B.txt文件内容,使用MediaStore API获取不到文件URI, 即ContentResolver的query查询不到文件的URI,所以需要使用Storage Access Framework (SAF)。

注:如果明确知道B.txt文件的URI,也可以使用MediaStore API读取文件

请求访问文件的权限

首先,用户需要在应用B中选择需要访问的文件。这可以通过启动一个文件选择器Intent来实现。

private const val READ_REQUEST_CODE = 42

private fun performFileSearch() {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = "text/plain"
    }
    startActivityForResult(intent, READ_REQUEST_CODE)
}
处理文件选择结果

用户选择文件后,应用会接收到文件的URI。在onActivityResult方法中处理这个URI,并读取文件内容。

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)
    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            readTextFromUri(uri)
        }
    }
}

private fun readTextFromUri(uri: Uri) {
    contentResolver.openInputStream(uri)?.use { inputStream ->
        val content = inputStream.bufferedReader().use { it.readText() }
        // 处理文件内容,例如打印输出
        println("File content: $content")
    }
}

Android 9及以下版本,直接读取文件内容:

private fun readFile() {
    val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
    val file = File(documentsDir, "B.txt")

    if (file.exists()) {
        val content = file.readText()
        // 处理文件内容,例如打印输出
        println("File content: $content")
    } else {
        println("File not found")
    }
}

注意事项:

  1. 权限请求: 需要适当的权限(如READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE)来读取文件。

  2. 异常处理: 类似于写入操作,在读取文件时也应该考虑到可能的IOException异常,并进行适当的处理。

  3. 线程安全性: 确保在UI线程之外进行文件读取操作,以避免可能的ANR(Application Not Responding)问题。

更多

更多MediaStore API和SAF使用请参考: 官方文档:访问共享存储空间中的媒体文件

官方文档:从共享存储空间访问文档和其他文件