Android 本地持久化全解:选型、原理与业务思考
从文件存储、SQLite/Room、Realm 到 SharedPreferences/MMKV/DataStore,系统梳理 Android 持久化方案的技术原理、底层机制与海外合规、离线缓存等业务选型。
面试开场:「用户登录态、列表缓存、隐私偏好,你会分别用什么存?SharedPreferences 写主线程真的没问题吗?」
这道题表面问 API 选型,实际在考察你对 Android 存储分层、IO 线程模型、Schema 迁移、数据合规 是否有一整套判断框架——不是背「Room 好、SP 坏」,而是能根据数据形态、读写频率、一致性要求和业务边界做出合理决策。
这篇按 业务背景 → 选型总览 → 四类存储深入 → 安全与架构 → 业务思考 的顺序展开。
一、为什么本地持久化是「必答题」
1.1 海外合规:数据不能上云
海外 App 开发里,本地持久化往往是硬性需求而非优化项。GDPR、CCPA 等法规对「个人可识别信息(PII)」的收集、存储、跨境传输有严格限制——很多数据不允许放在服务端数据库,必须在设备本地处理并在用户请求时删除。
典型场景:
| 场景 | 本地存储诉求 | 业务约束 |
|---|---|---|
| 健康 / 金融类 | 敏感记录仅存本地 | 加密、可审计、用户可导出/删除 |
| 离线优先 | 无网络时仍可用 | 冲突合并、过期策略 |
| 隐私偏好 | 追踪开关、广告 ID 同意状态 | 不可同步到未授权服务端 |
| 会话令牌 | Token 本地缓存 | 安全存储,非明文 SP |
1.2 国内场景:缓存层与体验兜底
国内 App 同样大量依赖本地存储,最常见的是缓存层:
- 上次网络请求成功的数据落盘,下次请求失败时先展示本地数据(Stale-While-Revalidate)
- 图片、静态配置、字典表等变更频率低的数据本地预置或增量更新
- 草稿、表单中途退出恢复
技术层面都能实现;是否展示过期数据、过期多久、如何提示用户,是产品决策。工程师的职责是提供可靠机制(时间戳、版本号、强制刷新入口),而不是替 PM 做体验取舍。
1.3 与架构的关系
在 MVVM / SSOT 分层里,持久化属于 Data Layer 的 Local Data Source:
┌─────────────────────────────────────────────────────────────┐
│ Presentation(UI / ViewModel) │
└────────────────────────────┬────────────────────────────────┘
│ 观察 Flow / LiveData
▼
┌─────────────────────────────────────────────────────────────┐
│ Repository(单一数据源入口) │
│ · 决定读本地还是远程 · 合并冲突 · 暴露统一 API │
└──────────────┬──────────────────────────────┬───────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ Local(Room / DataStore)│ │ Remote(Retrofit / gRPC) │
└──────────────────────────┘ └─────────────────────────────┘持久化方案选错了,Repository 再优雅也救不了——Schema 无法迁移、主线程 ANR、明文泄露,都是 Data Layer 选型阶段的债。
二、选型总览:按数据形态决策
Android 没有「一个方案打天下」。先问四个问题:
| 维度 | 问题 | 影响 |
|---|---|---|
| 结构 | 扁平键值 vs 关系型多表 vs 非结构化文档 | 决定 Room / DataStore / 文件 |
| 体量 | 几条配置 vs 万级列表 vs 大文件 | 决定 KV vs DB vs 文件系统 |
| 读写模式 | 读多写少 vs 高频写入 vs 批量事务 | 决定 MMKV vs Room 事务 vs 追加写 |
| 生命周期 | 会话级缓存 vs 长期持久 vs 可迁移备份 | 决定 cache 目录 vs database vs backup rules |
需要本地持久化?
│
┌───────────────┼───────────────┐
▼ ▼ ▼
结构化多表 轻量 KV 大对象/媒体
│ │ │
▼ ▼ ▼
Room (SQLite) DataStore / MMKV 文件 / MediaStore
│ (替代 SP)
▼
需要跨平台对象模型、
弱 Schema 约束?
│
┌─────┴─────┐
▼ ▼
Realm 继续 Room| 方案 | 典型用途 | 避免用于 |
|---|---|---|
| SharedPreferences / DataStore | 用户偏好、开关、少量配置 | 列表数据、复杂查询 |
| MMKV | 高频读写 KV、多进程共享配置 | 需要 SQL 查询的场景 |
| Room (SQLite) | 结构化业务数据、离线列表、关系查询 | 单键布尔值 |
| Realm | 对象图、快速原型、跨平台模型 | 强依赖 SQL、复杂 JOIN |
| 文件 (JSON/Proto) | 资产包、导出、简单快照 | 频繁 Schema 变更的核心业务 |
三、文件存储:灵活与风险的平衡
3.1 Android 存储分区
Android 应用数据落在几类路径,权限与生命周期不同:
| 路径 | API | 卸载删除 | 其他 App 可读 | 典型用途 |
|---|---|---|---|---|
内部存储 filesDir / cacheDir | 无需权限 | 是 | 否(同 UID) | 私有 DB、配置、缓存 |
外部私有 getExternalFilesDir() | 无需权限(Scoped Storage 后) | 是 | 否 | 大文件、下载内容 |
| 共享存储 MediaStore / SAF | 需权限或系统选择器 | 否 | 视 URI 授权 | 用户可见媒体 |
// 内部私有文件 — 其他应用无法访问
context.filesDir.resolve("user_snapshot.json")
// 缓存 — 系统低存储时可清理
context.cacheDir.resolve("image_thumb.cache")原理:内部存储路径在 /data/data/<package>/ 下,由 Linux UID 隔离;外部私有目录在 Android/data/<package>/,卸载时一并删除,但不参与 MediaStore 索引。
3.2 序列化与 ER 映射
文件存储常见做法:建立 Entity 关系 → 序列化为 JSON / CSV / Protobuf 字符串 → 写入文件;读取时反序列化回对象,作为 Repository 的 Local Source。
// 示意:JSON 文件作为数据源
class UserSnapshotStore(private val file: File) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun save(users: List<User>) = withContext(Dispatchers.IO) {
file.writeText(json.encodeToString(users))
}
suspend fun load(): List<User>? = withContext(Dispatchers.IO) {
if (!file.exists()) return@withContext null
runCatching { json.decodeFromString<List<User>>(file.readText()) }.getOrNull()
}
}3.3 优势与陷阱
优势
- 零依赖,调试直观(直接打开文件看内容)
- 适合只读资产、导出备份、一次性快照
- 与后端 JSON 格式对齐时迁移成本低
陷阱
| 问题 | 后果 | 应对 |
|---|---|---|
| 自研序列化不可靠 | 字段缺失、类型错误、Silent corruption | TDD 覆盖读写往返;ignoreUnknownKeys;版本字段 |
| Schema 升级 | 旧文件无法解析 → Crash 或丢数据 | 版本号 + 迁移函数;失败时降级为空并上报 |
| 并发读写 | 文件损坏 | 单线程 Dispatcher;写临时文件再 rename(atomic write) |
| 无事务 | 写一半进程被杀 → 半文件 | 先写 .tmp 再原子替换 |
| ER 关系复杂 | 表结构变更成本高 | 核心业务不要用纯文件;交给 Room 做 Migration |
文件存储适合边界清晰、变更少的数据;产品会频繁改表结构的业务,SQLite + Room 的 Migration 机制远比手写文件升级可靠。
四、关系型存储:SQLite 与 Room
4.1 SQLite 在 Android 中的位置
Android 内置的关系型引擎只有 SQLite——单文件、嵌入式、零配置,非常适合移动端:
- 数据库文件默认位于
context.getDatabasePath("app.db")(内部存储) - 每个数据库对应一个进程内连接池;SQLiteDatabase 线程安全,但同一连接上的并发写会串行
- WAL(Write-Ahead Logging)模式在 API 16+ 默认开启,读不阻塞写
4.2 原生 SQLiteOpenHelper 的问题
// 传统方式:大量样板代码
class UserDbHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE user (...)")
}
override fun onUpgrade(db: SQLiteDatabase, old: Int, new: Int) {
// 手写 ALTER / 重建表 — 易出错
}
}问题:SQL 字符串散落、编译期无类型检查、Cursor 手动映射、onUpgrade 维护地狱。于是 GreenDAO、LitePal 等 ORM 曾流行,但第三方维护周期与 Google 新特性(Coroutines、Flow、Paging) 脱节是结构性风险——LitePal 对 RxJava / Coroutines 的支持就是典型例子。
4.3 Jetpack Room:官方 ORM 的设计
Room 在 SQLite 之上提供 编译期校验 + 抽象层:
┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐
│ Entity │ │ Dao │ │ Database │
│ 表结构映射 │ ◄── │ CRUD 接口 │ ◄── │ 版本 / Migration │
└─────────────┘ └─────────────┘ └─────────────────────┘
│
▼
SQLite (WAL)@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: String,
val name: String,
val updatedAt: Long,
)
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<UserEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(users: List<UserEntity>)
@Query("DELETE FROM users WHERE updatedAt < :before")
suspend fun deleteStale(before: Long)
}
@Database(entities = [UserEntity::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}Room 在编译期做的事
- SQL 语句合法性检查(
@Query中的表名、列名) - 生成
_Impl类,避免反射 - 根据
@Entity生成CREATE TABLE - 可选 export Schema JSON,用于 CI 审查 Migration
4.4 Migration:Schema 演进的核心
业务表结构一定会变。Room 通过 Migration 显式定义升级路径:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE users ADD COLUMN avatarUrl TEXT")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()| 策略 | 适用 | 风险 |
|---|---|---|
ALTER TABLE ADD COLUMN | 加列 | 低 |
| 新表 + 数据拷贝 + 删旧表 | 大改结构 | 中;需事务 |
fallbackToDestructiveMigration() | 仅开发 / 可丢数据 | 生产慎用 — 用户数据全清 |
原理:Migration 在 onOpen 时比较 version 字段,按链式执行 1→2→3;任一步失败则打开数据库失败,应用需捕获并降级。
4.5 线程与 Flow
Room 禁止主线程查询(除非 allowMainThreadQueries(),仅测试用)。生产路径:
// suspend — 自动在 IO 线程(配合 Coroutines)
suspend fun getUser(id: String): UserEntity?
// Flow — 表变化自动 re-emit
fun observeUser(id: String): Flow<UserEntity?>
// RxJava — Single / Observable 同样支持
@Query("SELECT * FROM users WHERE id = :id")
fun getUserRx(id: String): Single<UserEntity>Invalidate 机制:Room 在表变更时标记 @Query 的 Flow 为 invalid,重新执行 SQL——这是 UI 层 collectAsState 自动刷新的基础。
4.6 与 Paging、RemoteMediator
列表 + 网络分页时,Room 作为 PagingSource 的本地页缓存:
RemoteMediator (网络) ──► 写入 Room ──► PagingSource 读 Room ──► UI
离线时 Paging 仍可从 Room 读已有页;这是「网络失败展示本地」在架构上的标准解法,比手写 JSON 文件缓存可维护得多。
五、非关系型存储:Realm
5.1 与 Room 的本质区别
| Room / SQLite | Realm | |
|---|---|---|
| 引擎 | SQLite 关系表 | Realm Object Store(类 MongoDB 文档模型) |
| 数据模型 | 表 + 外键 + JOIN | 嵌套对象、List 字段 |
| 查询语言 | SQL | Realm Query API |
| Schema | 严格 Migration | 自动轻量迁移 + 可选手动 |
Realm 不是「又一个 SQLite ORM」,而是换了一套存储引擎——直接存对象图,没有 JOIN 的概念,和习惯 MySQL 范式的工程师直觉相悖,但在「对象即文档」的移动端模型里往往代码量更少。
5.2 Freeze 与线程模型
Realm 的对象默认 live object,跨线程访问会抛异常。 Realm.freeze()(冷冻模型)生成不可变快照,可安全跨线程传递——类似 Room 的 @Immutable Entity + 每次查询新实例。
// 示意:冷冻快照跨线程
val frozenResults = realm.query<User>().find().freeze()
// frozenResults 可在任意线程读取5.3 选型建议
适合 Realm
- 快速迭代、对象嵌套深、不想写 SQL
- 历史项目已深度绑定 Realm
- MongoDB Atlas Device Sync 等同步场景(需评估官方路线图)
优先 Room
- 团队熟悉 SQL、需要复杂 JOIN / 聚合
- 与 Jetpack Paging、WorkManager、Google 文档对齐
- 长期维护、招聘与社区资源
Realm SDK 在 MongoDB 收购后路线有调整,新项目应查 官方状态 再定;2024 年后 Google 生态默认答案仍是 Room。
六、键值存储:SharedPreferences、MMKV、DataStore
键值存储像后端的 Redis 语义:一个 Key 对应一个值,无复杂查询,适合小、 flat、读多写少的配置。
6.1 SharedPreferences:XML 文件的真相
context.getSharedPreferences("settings", Context.MODE_PRIVATE)
.edit()
.putBoolean("dark_mode", true)
.apply() // 异步写内存 + 后台落盘底层原理
- 文件路径:
/data/data/<package>/shared_prefs/settings.xml apply():内存 Map 立即更新,磁盘写入提交到 QueuedWork 单线程队列(API 26+ 有优化)commit():同步写盘,返回 boolean;ANR 风险更高- 类型有限:
String / int / long / float / boolean / Set<String>
主线程陷阱
getString()首次读取会 parse XML 到内存 Map,大文件可能阻塞主线程apply()在 ActivityonPause时可能 强制 flush(QueuedWork.waitToFinish()),导致卡顿- 多进程
MODE_MULTI_PROCESS已废弃,不可靠
结论:SP 适合极小配置;新代码应迁移到 DataStore;绝不存大 JSON 或 Token 明文。
6.2 MMKV:mmap 与多进程
腾讯微信团队开源,核心思路:
| 特性 | SharedPreferences | MMKV |
|---|---|---|
| 序列化 | XML 全量 parse | Protobuf 编码 |
| IO 模型 | 读整文件 / 写队列 | mmap 内存映射,增量更新 |
| 多进程 | 不推荐 | 支持 |
| 性能 | 小数据够用 | 高频读写显著更快 |
原理简述:mmap 把文件映射到进程地址空间,修改内存即映射到文件(由 OS 页缓存刷盘),避免每次 read/write 系统调用;Protobuf 比 XML 紧凑且解析快。
MMKV.initialize(context)
val kv = MMKV.defaultMMKV()
kv.encode("token", "xxx")
kv.decodeString("token")适用:开关、计数器、中等体量 KV、多进程(如 :push 进程读配置)。仍不适合 relational 数据。
6.3 Jetpack DataStore:官方替代 SP
Google 推荐用 DataStore 替代 SP,分两种:
| 类型 | API | 存储格式 | 适用 |
|---|---|---|---|
| Preferences DataStore | Preferences Key | Proto 二进制 | 替代 SP |
| Proto DataStore | 自定义 Serializer<T> | 强类型 Proto | 复杂配置对象 |
// Preferences DataStore
val Context.dataStore by preferencesDataStore(name = "settings")
suspend fun setDarkMode(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[DARK_MODE_KEY] = enabled
}
}
context.dataStore.data // Flow<Preferences>
.map { it[DARK_MODE_KEY] ?: false }
.collect { isDark -> /* UI */ }设计原理
- Flow 一等公民:数据变化 push 到 collector,无轮询
- 单线程写 + 事务:
edit {}原子更新,无 SP 的apply乱序问题 - 异常处理:
CorruptionHandler可重建默认数据 - Migration:
SharedPreferencesMigration从 SP 平滑迁移
Room.databaseBuilder(...)
// DataStore 迁移示例
context.dataStore.edit { /* ... */ }
// Builder
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("settings") },
migrations = listOf(SharedPreferencesMigration(context, "settings"))
)选型:新功能 Preferences DataStore;需要版本化配置对象用 Proto DataStore;极端性能多进程 KV 考虑 MMKV;不要再新写 SP。
七、安全、加密与备份
7.1 敏感数据存储
| 数据 | 错误做法 | 推荐 |
|---|---|---|
| Access Token | SP 明文 | EncryptedSharedPreferences / Encrypted DataStore + Android Keystore |
| 数据库 | 裸 SQLite 文件 | SQLCipher 或 Room + SupportFactory |
| 密钥 | 硬编码在 APK | Android Keystore / Remote attestation |
// EncryptedSharedPreferences(AndroidX Security)
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context, "secret_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)Keystore 原理:密钥材料存在 TEE / StrongBox,应用只持有引用;即 root 后 dump 文件仍是密文。
7.2 备份与自动云备份
android:allowBackup="true" 时,adb backup / Google 自动备份可能把 shared_prefs 和 databases 打包上传。含 Token 的文件需:
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="sharedpref" path="secret_prefs.xml"/>
<exclude domain="database" path="app.db"/>
</full-backup-content>合规场景还要支持 用户数据导出与删除(GDPR Right to erasure)——Repository 层提供 clearAllLocalUserData() 统一清 Room + DataStore + 缓存目录。
八、架构实践:Repository 与异常策略
8.1 统一入口
所有 UI / ViewModel 禁止直接 context.getSharedPreferences 或 db.userDao(),经 Repository:
class UserRepository @Inject constructor(
private val userDao: UserDao,
private val api: UserApi,
private val settings: SettingsDataStore,
) {
fun observeUsers(): Flow<List<User>> =
userDao.observeAll().map { entities -> entities.map { it.toDomain() } }
suspend fun refreshUsers(): Result<Unit> = runCatching {
val remote = api.fetchUsers()
userDao.upsertAll(remote.map { it.toEntity() })
}
}这与 SSOT 一致:本地 Room 是离线真相源之一,网络刷新通过 Repository 写入,UI 只观察 Flow。
8.2 异常与降级
原文提到的 RxJava onError / Coroutines try-catch,在持久化层应 分级处理:
| 异常类型 | 策略 | 用户感知 |
|---|---|---|
| 网络失败 + 本地有缓存 | 返回 Result.success(staleData) + 标记 stale | 展示数据 + Toast「离线数据」 |
| DB Migration 失败 | 上报 + 安全模式(空库或引导重装) | 对话框,避免 Silent Crash |
| 文件解析失败 | 删除损坏文件 + 默认值 | 无感或一次提示 |
| 磁盘满 | 清 cache + 重试 | 明确错误文案 |
suspend fun loadUsers(): Result<List<User>> = withContext(Dispatchers.IO) {
runCatching { userDao.getAll().map { it.toDomain() } }
.recoverCatching { e ->
logger.error("db_read_failed", e)
emptyList() // 或 throw 给上层决定
}
}九、业务层思考:和技术选型同样重要
9.1 和产品对齐的问题清单
在写第一行存储代码前,建议与 PM 对齐:
- 离线看到旧数据,算不算 bug? —— 决定 stale 策略与 UI 提示
- 卸载 App 数据是否必须消失? —— 决定内部存储 vs 备份 vs 账号云同步
- 哪些字段永不上传? —— 决定本地-only 表设计
- Schema 多久会变? —— 决定 Room Migration 投入 vs 文件方案
- 多设备同步要不要? —— 本地只是缓存还是 Source of Truth 副本
9.2 海外合规 checklist
- 本地 PII 是否加密 at rest
- 是否提供「删除我的数据」并真正删除 DB / 文件 / cache
- Backup rules 是否排除敏感文件
- 隐私政策是否披露本地存储内容与用途
- 跨境:数据是否仅驻留设备、有无意外同步到 Firebase Analytics 等
9.3 性能与 ANR
| 操作 | 线程 | 工具验证 |
|---|---|---|
| Room 查询 / 写入 | IO / Default | StrictMode detectDiskReads |
DataStore edit | suspend,内部单线程 | Profiler IO 轨道 |
| 大 JSON 文件解析 | IO | 主线程 >16ms 查 Systrace |
| SP 首次 load | 避免主线程 | 启动 trace |
9.4 团队工程化
- Room export Schema 进 Git,Migration 做 Code Review
- 存储层 TDD:Migration 测试用
MigrationTestHelper - 封装重复 CRUD,但不过早抽象「万能 StorageManager」
- 技术选型文档记录:为什么选 Room 不选文件 —— 半年后换同事仍能理解
十、速查:常见场景推荐方案
| 场景 | 推荐 | 备注 |
|---|---|---|
| 深色模式开关 | Preferences DataStore | Flow 驱动 UI |
| 用户登录 Token | Encrypted DataStore + Keystore | 禁 SP 明文 |
| 首页 Feed 列表 | Room + Paging + RemoteMediator | 标准离线缓存 |
| 搜索历史(100 条) | Room 单表 或 DataStore Proto | 需模糊查询则 Room |
| 应用配置(远程下发 JSON) | 文件 cache + Room 元数据 | 版本号校验 |
| 聊天草稿 | Room 或 Proto DataStore | 按字段复杂度 |
| 多进程 Push 配置 | MMKV | 注意加密需求 |
| 图片 / 视频 | 文件 + Coil 磁盘缓存 | 非 SQL 范畴 |
| 一次性导出 CSV | filesDir + SAF 分享 | 用户主动触发 |
十一、小结
Android 本地持久化不是「选一个库」——而是 Data Layer 的基础设施决策:
- 按数据形态选型:KV → DataStore/MMKV;结构化 → Room;大文件 → 文件系统;对象文档 → Realm(谨慎评估)
- 理解底层:SP 是 XML;Room 是 SQLite + WAL;MMKV 是 mmap + Protobuf;文件要写 atomic 与版本迁移
- 线程与安全:IO 离主线程;敏感数据加密;Backup 排除
- 架构:Repository 统一入口,Flow 驱动 UI,Migration 可测试
- 业务:离线 stale、合规删除、Schema 变更频率——技术实现服务于产品边界,而不是反过来
把持久化当成「填坑的 SharedPreferences」,迟早会在 Migration 失败、合规审计或启动 ANR 上还债;把它当成 Data Layer 的一等公民,才能支撑真正的离线优先与海外上架。