存储体系与 Scoped Storage
★ 数据持久化是客户端的根本。面试不仅考“怎么存”,更考“存哪里最安全”、“大文件怎么管”以及“多进程怎么防丢数据”。
一、轻量级 KV 存储:SharedPreferences 与 DataStore
| 特性 | SharedPreferences (SP) | Preferences DataStore |
|---|---|---|
| 线程安全 | 是(但容易造成主线程阻塞) | 是(基于协程与 Flow) |
| 类型安全 | 弱(可能抛 ClassCastException) | 弱(但有 Type DataStore 支持强类型) |
| 异步 API | 仅 apply() 异步,且可能阻塞生命周期 | 完全原生异步操作 |
| 多进程支持 | MODE_MULTI_PROCESS 已废弃,极不靠谱 | 支持,配合 MMKV 等替代方案更佳 |
SP 的核心痛点:
commit() 同步阻塞;apply() 虽然异步,但在 Activity/Service 销毁时,系统层(QueuedWork)会等待所有 apply 的磁盘写入完成,极易导致 ANR。
二、关系型数据库:SQLite 与 Room
- Room 的优势:编译时 SQL 语法检查;与协程/Flow/LiveData 无缝结合;基于注解的类型转换与实体映射。
- WAL 模式 (Write-Ahead Logging):开启后 (
setWriteAheadLoggingEnabled),读写可以并发执行,极大提升并发性能。写操作先追加到-wal文件,稍后再 Checkpoint 同步到.db。 - 数据库升级 (Migration):
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
}
}
三、文件目录与沙盒机制
Android 的私有目录分为内部存储和外部存储的私有部分,应用卸载时都会被删除:
- 内部存储 (Internal Storage):
Context.getFilesDir(),Context.getCacheDir()。空间非常有限,绝对不能放图片视频等大文件。 - 外部私有存储 (External Storage - App-specific):
Context.getExternalFilesDir(),Context.getExternalCacheDir()。空间较大,适合放应用的业务文件。不需要存储权限即可访问。
四、分区存储 (Scoped Storage) 与 SAF
在 Android 10+ 引入,为了防止应用在 SD 卡上“拉屎”。
- 自己产生的业务文件:放沙盒 (
getExternalFilesDir),不需要权限,卸载自动删。 - 需要分享/保存给用户的多媒体:通过 MediaStore 插入(图片、视频、音频)。插入不需要权限,读取别的应用插入的媒体需要
READ_MEDIA_IMAGES等权限。 - 让用户选择文件/非媒体文件:使用 SAF (Storage Access Framework),即
ACTION_OPEN_DOCUMENT。由系统选择器 UI 授权,返回一个不可伪造的 Document URI。
五、安全存储:Keystore 与 EncryptedSharedPreferences
- 敏感信息(如 Token、密码)怎么存?
不能明文写在 SP 或文件里。推荐使用 Jetpack Security 库中的
EncryptedSharedPreferences。 - 底层原理:它利用 Android Keystore 系统生成并保管主密钥(Master Key),主密钥存放在设备的硬件安全模块(TEE/SE)中,极难被提取。然后用主密钥加密真实的数据。
高频面试题
Q1:SharedPreferences 为什么会导致 ANR?怎么解决?
答:因为 apply() 提交的任务会进入系统级的 QueuedWork 队列。在组件(如 Activity)执行 onStop 时,系统为了保证数据不丢,会强制等待队列中的磁盘写入任务完成。如果写入量大或磁盘 IO 慢,就会阻塞主线程导致 ANR。
解决:迁移到 DataStore 或 MMKV;或者通过反射清理 QueuedWork(黑科技,不推荐)。
Q2:如何优雅地处理几十上百 MB 的大文件下载与缓存清理? 答:
- 存储位置:放在
Context.getExternalCacheDir()下,千万别放内部存储。 - 清理策略:实现 LRU 算法的磁盘缓存工具,设定最大容量(如 200MB)。在每次写入后检查总大小,淘汰最旧的未访问文件。
- 系统干预:可以覆写 Application 的
onTrimMemory,在设备空间不足时主动清理。
Q3:Room 数据库在跨版本升级时,如果用户跨了好几个版本(比如 V1 直接升级到 V4)怎么处理?
答:Room 支持定义多段 Migration,如 1-2, 2-3, 3-4。在构建 Database 时 addMigrations() 传入所有规则。Room 内部会自动寻找最短的迁移路径(按序应用 1-2,然后 2-3,然后 3-4)。如果没有找到路径且没有配置 fallbackToDestructiveMigration(),App 会崩溃。
易错点 / 追问
- 混淆 MediaStore 与 File API:在 Android 11+,即使你拿到了 MediaStore 中的文件真实绝对路径 (
_data字段),用FileAPI 去读写也可能被拒绝,必须使用ContentResolver.openInputStream()处理 URI。 - 忽略多进程 SP 的危险:多进程读写 SP 是数据损坏和丢失的重灾区。追问时一定要提到 MMKV 的共享内存 (mmap) 与文件锁机制是目前多进程 KV 存储的最佳实践。
- 数据库主线程读写:初学者常在主线程做 DB 查询。必须借助协程调度到
Dispatchers.IO。