Administrator
发布于 2026-08-25 / 28 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 69 篇:Scoped Storage 与 Android 文件存储全攻略

上一篇讲了 Compose 编译器原理,进入 Android 存储生态——Scoped Storage。Android 10 引入的存储沙箱彻底改变了 App 访问文件的方式,旧项目迁移一开始很痛,但理解后其实更简洁。

Android 存储发展的三个阶段

1. Android 4.4 之前(遗留)

  • 所有文件 /sdcard/ 完全开放

  • App 不需要申请权限

  • 删别人的文件也很容易

2. Android 4.4 - 9.x(旧模型)

  • 引入 READ_EXTERNAL_STORAGE / WRITE_EXTERNAL_STORAGE

  • App 共享外部存储空间(Environment.getExternalStorageDirectory())

  • 需要用户申请读写权限

3. Android 10+(Scoped Storage)

  • App 默认只能访问自己的私有目录

  • 共享媒体文件通过 MediaStore

  • 共享其他文件通过 Storage Access Framework (SAF)

三大存储位置

Android 提供三类存储位置,作用不同:

类型

路径

生命周期

卸载是否清除

App 私有

/data/data/{package}/files

App 卸载时清除

是

App 私有(外部)

/sdcard/Android/data/{package}/

App 卸载时清除

是

共享媒体

/sdcard/Pictures/、Music/

不变

否

公共位置

Downloads/、Documents/

不变

否

注意:Android/data/{package}/ 在 Android 11+ 用户在 Settings 里也能清除,且 root 用户依然可见——实际只是"几乎私有"。

App 私有目录(推荐)

每个 App 有自己专属的存储位置,不需要任何权限:

// 内部存储
val internalDir = File(filesDir, "media").apply { mkdirs() }
val internalCache = cacheDir

// 外部私有(不需要 WRITE_EXTERNAL_STORAGE)
val externalDir = getExternalFilesDir("images")
val externalCache = externalCacheDir

特点:

  • 不需要任何权限

  • App 卸载清除

  • 其他 App 默认访问不到

绝大多数场景用 App 私有目录就够。

Scoped Storage 关键变化

Android 10 起,App 不能直接访问 /sdcard/ 下其他 App 的文件。需要通过 MediaStore:

// 错误:直接写文件到 /sdcard/Downloads/example.pdf
File("/sdcard/Downloads/example.pdf").writeText("hello")
// Android 10+:IOException: Permission denied

// 正确:用 MediaStore
val values = ContentValues().apply {
    put(MediaStore.Downloads.DISPLAY_NAME, "example.pdf")
    put(MediaStore.Downloads.MIME_TYPE, "application/pdf")
    put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}

val uri = contentResolver.insert(
    MediaStore.Downloads.EXTERNAL_CONTENT_URI,
    values
) ?: return

contentResolver.openOutputStream(uri)?.use { output ->
    // 写文件
    content.writeTo(output)
}

MediaStore 详解

MediaStore 提供:

// 媒体库 URI
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
MediaStore.Downloads.EXTERNAL_CONTENT_URI

查询:

val projection = arrayOf(
    MediaStore.Images.Media._ID,
    MediaStore.Images.Media.DISPLAY_NAME,
    MediaStore.Images.Media.DATE_ADDED
)

val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"

contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    projection,
    null,
    null,
    sortOrder
)?.use { cursor ->
    val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
    val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
    
    while (cursor.moveToNext()) {
        val id = cursor.getLong(idCol)
        val name = cursor.getString(nameCol)
        val uri = ContentUris.withAppendedId(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            id
        )
        Log.d("Media", "$name: $uri")
    }
}

Storage Access Framework(SAF)

让用户主动选择文件 / 目录:

// 打开文件选择器
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "image/*"
}

startActivityForResult.launch(intent)

// 用户选择后回 onActivityResult
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    val uri: Uri? = data?.data
    // uri 是一次性授权,读这个 uri 不需要权限
}

// 持久授权
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)

// 让用户选文件夹
val pickFolder = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)

优势:

  • SAF 不需要 WRITE_EXTERNAL_STORAGE 权限

  • 用户明确选择,体验清晰

  • 拿到 URI 后用 DocumentFile API 操作

DocumentFile 实战

// 拿到选中的目录 URI
val treeUri: Uri = intent.data!!

// 把目录操作能力 wrap 到 DocumentFile
val dir = DocumentFile.fromTreeUri(context, treeUri) ?: return

// 创建一个新文件
val newFile = dir.createFile("text/plain", "notes.txt") ?: return

// 写入
contentResolver.openOutputStream(newFile.uri)?.use { os ->
    "Hello".toByteArray().let(os::write)
}

// 列举子文件
dir.listFiles().forEach { file ->
    Log.d("Doc", "${file.name}, size=${file.length()}")
}

重要的权限请求

Android 10+ 的请求方式完全变了:

// 旧的请求方式(Android 13+ 完全失效)
ActivityCompat.requestPermissions(
    this,
    arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
    101
)

// Android 13+:分媒体权限
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    arrayOf(
        Manifest.permission.READ_MEDIA_IMAGES,
        Manifest.permission.READ_MEDIA_VIDEO,
        Manifest.permission.READ_MEDIA_AUDIO
    )
} else {
    arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}

ActivityCompat.requestPermissions(this, permissions, 101)

权限映射:

Android 版本

权限

13+

READ_MEDIA_IMAGES / VIDEO / AUDIO

12

READ_EXTERNAL_STORAGE(读限制)

11

MANAGE_EXTERNAL_STORAGE(all-files-access)

10

READ_EXTERNAL_STORAGE

9-

WRITE_EXTERNAL_STORAGE

常见问题解决

1. 老 App 兼容

requestLegacyExternalStorage="true" 是 Android 10 的兼容开关:

<application
    android:requestLegacyExternalStorage="true"
    android:preserveLegacyExternalStorage="true">

Android 11 起 Google 强制 Scoped Storage,这两条基本失效。如果还没迁移,会出 bug。

2. 高频文件操作性能

MediaStore 每次操作一次 IO,批量操作要:

val batchUris = mutableListOf<Uri>()

// 用 ContentResolver.bulkInsert()
contentResolver.bulkInsert(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    batchValues
)

// 用 applyBatch()
val operations = listOf(
    ContentProviderOperation.newInsert(...)
)
contentResolver.applyBatch(MediaStore.AUTHORITY, operations)

3. 大量小文件存储

如果 App 需要存大量文件(如笔记、数据库、JSON),用 App 私有目录:

val db = File(filesDir, "user_data.json")
val json = listOf(
    Note("Title 1"),
    Note("Title 2")
)
db.writeText(Json.encodeToString(json))

不要在共享媒体存储小文件——一是违反设计意图;二是用户能看到,会困惑。

4. 共享文件给其他 App

用 FileProvider:

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>
<!-- res/xml/file_paths.xml -->
<paths>
    <files-path name="my_files" path="." />
    <external-files-path name="my_external" path="." />
    <cache-path name="my_cache" path="." />
</paths>
// 拿 URI
val uri = FileProvider.getUriForFile(context, "${packageName}.fileprovider", file)

// 分享到其他 App
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "image/*"
    putExtra(Intent.EXTRA_STREAM, uri)
    flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}

startActivity(Intent.createChooser(intent, "分享到"))

5. SaveImageToGallery 实际例子

fun saveImageToGallery(context: Context, bitmap: Bitmap) {
    val filename = "IMG_${System.currentTimeMillis()}.jpg"
    
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, filename)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
        put(MediaStore.Images.Media.IS_PENDING, 1) // 标记 pending
    }
    
    val uri = context.contentResolver.insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        values
    ) ?: return
    
    context.contentResolver.openOutputStream(uri)?.use { output ->
        bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output)
    }
    
    // 标记完成
    values.clear()
    values.put(MediaStore.Images.Media.IS_PENDING, 0)
    context.contentResolver.update(uri, values, null, null)
}

IS_PENDING 标记保证"写完后再对外可见",不会让用户在写的过程中看到半成品。

真正的最佳实践总结

场景

推荐

App 配置、缓存

App 私有(filesDir / cacheDir)

用户创建的内容、要给其他 App 用

MediaStore

让用户主动选文件

SAF

共享文件给其他 App

FileProvider

数据库

App 私有(databases/)

临时缓存

cacheDir / externalCacheDir

多媒体捕获

Camera Intent

反模式 vs 推荐做法

反模式 1:硬编码全局路径

// 错
val path = "/sdcard/DCIM/myapp/photo.jpg"
File(path).writeBytes(data)

推荐:用 path API

// 对
val picturesDir = Environment.getExternalStoragePublicDirectory(
    Environment.DIRECTORY_PICTURES
)
val myAppDir = File(picturesDir, "MyApp")
myAppDir.mkdirs()

反模式 2:所有 App 数据放 MediaStore

100 个笔记文件在 Pictures 目录下:

  • 用户看到 Pictures 一堆 .json 会困惑

  • 其他 App 不应该读你的数据

  • 卸载时数据丢失

推荐:用 App 私有目录

val notesDir = File(filesDir, "notes")
notesDir.mkdirs()

实战案例:图片选择器

class ImagePicker(private val activity: ComponentActivity) {
    private val launcher = activity.registerForActivityResult(
        ActivityResultContracts.PickVisualMedia()
    ) { uri ->
        if (uri != null) {
            loadImage(uri)
        }
    }

    fun pickImage() {
        launcher.launch(
            PickVisualMediaRequest(
                ActivityResultContracts.PickVisualMedia.ImageOnly
            )
        )
    }

    private fun loadImage(uri: Uri) {
        activity.contentResolver.openInputStream(uri)?.use { input ->
            val bitmap = BitmapFactory.decodeStream(input)
            // ...
        }
    }
}

Photo Picker(Android 13+)不需要任何权限,最佳方案。Android 11/12 的兼容版本通过 Google Play 服务提供:

// 兼容老版本
implementation("com.google.android.gms:play-services-picker:1.0.0")

小结

Android 存储从"开放文件系统"走向"细粒度权限",这是 OS 演进的趋势。Scoped Storage 让 App 默认安全,共享用 MediaStore / SAF / FileProvider。掌握这一套 API 是 Android 开发基本功。最大的反模式是"用全局 sdcard 路径",最大收益是"用 App 私有目录"。

下一篇进入 Android 应用架构——从 MVP 到 Clean Architecture 的演进路径。


评论