返回 Android
Android
23 分钟阅读

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 corruptionTDD 覆盖读写往返;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 / SQLiteRealm
引擎SQLite 关系表Realm Object Store(类 MongoDB 文档模型)
数据模型表 + 外键 + JOIN嵌套对象、List 字段
查询语言SQLRealm 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() 在 Activity onPause 时可能 强制 flushQueuedWork.waitToFinish()),导致卡顿
  • 多进程 MODE_MULTI_PROCESS 已废弃,不可靠

结论:SP 适合极小配置;新代码应迁移到 DataStore;绝不存大 JSON 或 Token 明文。

6.2 MMKV:mmap 与多进程

腾讯微信团队开源,核心思路:

特性SharedPreferencesMMKV
序列化XML 全量 parseProtobuf 编码
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 DataStorePreferences KeyProto 二进制替代 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 可重建默认数据
  • MigrationSharedPreferencesMigration 从 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 TokenSP 明文EncryptedSharedPreferences / Encrypted DataStore + Android Keystore
数据库裸 SQLite 文件SQLCipher 或 Room + SupportFactory
密钥硬编码在 APKAndroid 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.getSharedPreferencesdb.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 对齐:

  1. 离线看到旧数据,算不算 bug? —— 决定 stale 策略与 UI 提示
  2. 卸载 App 数据是否必须消失? —— 决定内部存储 vs 备份 vs 账号云同步
  3. 哪些字段永不上传? —— 决定本地-only 表设计
  4. Schema 多久会变? —— 决定 Room Migration 投入 vs 文件方案
  5. 多设备同步要不要? —— 本地只是缓存还是 Source of Truth 副本

9.2 海外合规 checklist

  • 本地 PII 是否加密 at rest
  • 是否提供「删除我的数据」并真正删除 DB / 文件 / cache
  • Backup rules 是否排除敏感文件
  • 隐私政策是否披露本地存储内容与用途
  • 跨境:数据是否仅驻留设备、有无意外同步到 Firebase Analytics 等

9.3 性能与 ANR

操作线程工具验证
Room 查询 / 写入IO / DefaultStrictMode detectDiskReads
DataStore editsuspend,内部单线程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 DataStoreFlow 驱动 UI
用户登录 TokenEncrypted DataStore + Keystore禁 SP 明文
首页 Feed 列表Room + Paging + RemoteMediator标准离线缓存
搜索历史(100 条)Room 单表 或 DataStore Proto需模糊查询则 Room
应用配置(远程下发 JSON)文件 cache + Room 元数据版本号校验
聊天草稿Room 或 Proto DataStore按字段复杂度
多进程 Push 配置MMKV注意加密需求
图片 / 视频文件 + Coil 磁盘缓存非 SQL 范畴
一次性导出 CSVfilesDir + SAF 分享用户主动触发

十一、小结

Android 本地持久化不是「选一个库」——而是 Data Layer 的基础设施决策

  1. 按数据形态选型:KV → DataStore/MMKV;结构化 → Room;大文件 → 文件系统;对象文档 → Realm(谨慎评估)
  2. 理解底层:SP 是 XML;Room 是 SQLite + WAL;MMKV 是 mmap + Protobuf;文件要写 atomic 与版本迁移
  3. 线程与安全:IO 离主线程;敏感数据加密;Backup 排除
  4. 架构:Repository 统一入口,Flow 驱动 UI,Migration 可测试
  5. 业务:离线 stale、合规删除、Schema 变更频率——技术实现服务于产品边界,而不是反过来

把持久化当成「填坑的 SharedPreferences」,迟早会在 Migration 失败、合规审计或启动 ANR 上还债;把它当成 Data Layer 的一等公民,才能支撑真正的离线优先与海外上架。

相关文章

Android
26 分钟
Android 架构核心原则:单一数据源(SSOT)与单向数据流(UDF)实战指南
在Android开发中,随着应用复杂度提升,数据混乱、状态不一致、调试困难等问题频发,而单一数据源(Single Source of Truth, SSOT)与单向数据流(Unidirectional Data Flow, UDF)正是解决这些痛点的核心架构原则。
Android
17 分钟
Android Handler 机制全解:从源码看懂消息循环
从 Handler、Looper、MessageQueue、Message 四件套出发,结合 ActivityThread 启动链路与 nativePollOnce 底层实现,完整讲清 Android 主线程消息循环的设计与源码细节。
Android
12 分钟
为什么要引入线程调度框架
在Java和Kotlin里面,优雅地实现异步编程模型
Java / Kotlin
9 分钟
Kotlin Coroutine
以图片处理串行流水线为例,对比 Callback、RxJava 与 Coroutines 三种异步写法