上一篇讲了 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_STORAGEApp 共享外部存储空间(Environment.getExternalStorageDirectory())
需要用户申请读写权限
3. Android 10+(Scoped Storage)
App 默认只能访问自己的私有目录
共享媒体文件通过 MediaStore
共享其他文件通过 Storage Access Framework (SAF)
三大存储位置
Android 提供三类存储位置,作用不同:
注意: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)
权限映射:
常见问题解决
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 标记保证"写完后再对外可见",不会让用户在写的过程中看到半成品。
真正的最佳实践总结
反模式 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 的演进路径。