Android 文件系统与存储深度解析——跨平台开发者的数据持久化全指南
面试高频:内部/外部存储边界、Scoped Storage 下 MediaStore 与 SAF 怎么选、SharedPreferences 为何被 DataStore 替代、Room 缓存策略。
一句话概括
Android 存储分两层:应用私有目录(卸载即删、无需权限) 和 共享存储(相册/下载,受 Scoped Storage 管控)。从 Android 10 起,应用不能再随意扫外部存储,写公共文件要走 MediaStore、读用户媒体优先用 Photo Picker。
核心结论:小配置用 DataStore 替代 SharedPreferences;结构化缓存用 Room 并写好 Migration;共享文件用 MediaStore/SAF,跨应用分享用 FileProvider;Android 14 起选图用 Photo Picker 连权限都不用申请。 跨端插件(path_provider/sqflite)底层都映射到这里。
核心知识点
1. 内部 vs 外部存储
1
2
3
4
5
6
context.filesDir // /data/data/包名/files/ 私有,卸载删,无需权限
context.cacheDir // /data/data/包名/cache/ 同上加:空间不足被系统清
context.getExternalFilesDir(null)
// /storage/emulated/0/Android/data/包名/files/ 外部私有,卸载删
context.getExternalCacheDir()
// /storage/emulated/0/Android/data/包名/cache/
| 维度 | 内部私有 | 外部私有 (Android/data/包名) | 共享存储 (相册/Download) |
|---|---|---|---|
| 权限 | 无 | 无 | Scoped Storage 管控 |
| 卸载 | 删除 | 删除 | 保留 |
| 其他应用可见 | 否(root 除外) | 否 | 是 |
// ❌ 把用户下载的 PDF 写进内部 filesDir,卸载后用户找不回 // ✅ 用户要长期持有的文件放共享存储(MediaStore),临时缓存放 cacheDir
2. Scoped Storage:Android 10 起的硬约束
- Android 10(API 29):默认启用,可用
requestLegacyExternalStorage临时回退 - Android 11(API 30):回退被废,强制分区;批量操作走 MediaStore,全量访问需
MANAGE_EXTERNAL_STORAGE(特殊权限,Play 审核) - Android 13(API 33):媒体权限细化
READ_MEDIA_IMAGES/VIDEO/AUDIO - Android 14(API 34):”选定照片”部分访问
READ_MEDIA_VISUAL_USER_SELECTED
写公共下载目录(现代写法,不再用已废弃的 getExternalStoragePublicDirectory):
1
2
3
4
5
6
7
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, "report.pdf")
put(MediaStore.Downloads.MIME_TYPE, "application/pdf")
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/MyApp")
}
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
contentResolver.openOutputStream(uri!!)?.use { it.write(bytes) }
读用户图片——首选 Photo Picker,零权限:
1
2
3
4
val pick = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
uri?.let { /* 拿到 content://,用 ContentResolver 读,无需任何存储权限 */ }
}
pick.launch(PickVisualMedia.ImageOnly)
3. 跨应用分享:FileProvider
直接暴露 file:// 在 Android 7+ 会抛 FileUriExposedException,必须走 FileProvider 生成 content://:
1
2
3
4
5
6
7
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
val share = Intent(Intent.ACTION_SEND).apply {
type = "image/*"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(share, "分享"))
4. SharedPreferences → DataStore
SharedPreferences 的硬伤:主线程同步加载可能卡 UI、apply() 异步写入后立即读可能读到旧值、无类型安全、无法监听变化。DataStore(Jetpack)用协程 + Flow 解决这一切。
1
2
3
4
5
6
7
8
9
10
11
// 顶层扩展属性
val Context.dataStore by preferencesDataStore("settings")
// 写(挂起,不在主线程做 I/O)
suspend fun saveUser(ctx: Context, name: String) {
ctx.dataStore.edit { it[stringPreferencesKey("user_name")] = name }
}
// 读(响应式 Flow)
val nameFlow: Flow<String> = ctx.dataStore.data.map {
it[stringPreferencesKey("user_name")] ?: ""
}
// ❌ 在 UI 线程 prefs.getString() 大文件导致掉帧 // ✅ 新项目直接用 Preferences DataStore;复杂对象用 Proto DataStore(类型安全)
5. Room:结构化缓存
Room 在 SQLite 之上做 ORM,编译期校验 SQL、自带 Migration。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity(tableName = "cache")
data class CacheEntity(
@PrimaryKey val key: String,
@ColumnInfo(name = "data") val json: String,
@ColumnInfo(name = "expires_at") val expiresAt: Long
)
@Dao
interface CacheDao {
@Query("SELECT * FROM cache WHERE key = :k")
suspend fun get(k: String): CacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun put(e: CacheEntity)
@Query("DELETE FROM cache WHERE expires_at < :now")
suspend fun purgeExpired(now: Long)
}
1
2
3
4
5
6
7
8
// 升级必须写 Migration,别用 fallbackToDestructiveMigration(会清空用户数据)
Room.databaseBuilder(ctx, AppDb::class.java, "app.db")
.addMigrations(object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE cache ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
}
})
.build()
「先读缓存 → 展示 → 异步刷新 → 写回」是经典缓存策略;网络失败时返回过期缓存做降级。
6. 缓存清理与跨端路径互通
1
2
3
4
override fun onPause() { // 页面隐藏时暂停 JS/渲染,省资源
super.onPause(); webView?.onPause()
}
// 统计并清理 cacheDir / externalCacheDir 是常规健康度功能
跨端互通的铁律:别硬编码 /data/data/包名/...,通过 MethodChannel 让原生回传真实路径,因为不同版本/厂商路径可能不同,且 Android 10+ 返回的是 content:// 而非 file://。
1
2
3
4
// ✅ Flutter 侧问原生要路径
final path = await channel.invokeMethod('getDataFilePath');
final content = await File(path).readAsString();
// ❌ 自己拼 '/data/data/com.x/files/data.json'
其实你每天都在用
shared_preferences:底层就是 SharedPreferences,小配置够用;新原生模块建议 DataStorepath_provider的 getTemporaryDirectory:对应cacheDir,系统低存储时可能被清,别存需要长期保留的东西image_picker选图:Android 10+ 返回content://,Flutter 插件已适配,自己写要小心- 下载报表到”下载”目录:Android 10+ 必须走 MediaStore.Downloads,否则写不进
- 分享图片给微信:FileProvider 生成
content://+ 临时读权限,否则 7.0+ 崩 sqflite/drift:Flutter 侧 SQLite ORM,等价于原生 Room,但跑在 Dart 层- 应用”清除缓存”按钮:遍历并清理
cacheDir/externalCacheDir
常见误解(FAQ)
❌ 误区一:”SharedPreferences 和 DataStore 差不多,随便用”
SharedPreferences 首次读取在主线程同步解析整个 XML,大键值或低端机上会掉帧;apply() 异步,写后立即读可能拿旧值;还不支持变化监听。DataStore 用协程+Flow,异步、类型安全、可观察。面试答”新项目用 DataStore”是加分项。
❌ 误区二:”Android 10 之后还能用 getExternalStoragePublicDirectory 随便写”
该方法在 API 29 已废弃,Android 11+ 直接不可用。写公共媒体必须走 MediaStore(图片/视频/音频/下载各自集合),且 Android 11+ 用 IS_PENDING 标记写入中状态。想全量访问 MANAGE_EXTERNAL_STORAGE 还要过 Play 审核。
❌ 误区三:”Room 升级加个字段用 fallbackToDestructiveMigration 就行”
它是”没有 Migration 就删库重建“——用户本地数据全没。生产必须写具体 Migration(ALTER TABLE 等)。被问到”数据库升级怎么不丢数据”,这是标准答法。
❌ 误区四:”想读用户相册就申请 READ_EXTERNAL_STORAGE”
Android 13 起该权限拆分细化为 READ_MEDIA_IMAGES/VIDEO/AUDIO,且 Android 14 用户可只授权”选定照片”。最佳实践是直接用系统 Photo Picker(PickVisualMedia),连权限都不用申请,体验最好、隐私最强。
一句话总结
Android 存储的边界感最重要:私有数据进 filesDir/DataStore/Room,用户要留的文件走 MediaStore 与 Photo Picker,跨应用分享用 FileProvider,路径永远别硬编码——选对位置、避开 Scoped Storage 的坑,数据才不会丢、权限才不会烦。