一、问题域与成功标准
1.1 为什么持久化会"拖垮"架构
本地持久化一旦和业务 UI、网络层、账号体系缠在一起,典型症状包括:
同一概念在 DTO / 域模型 / DB 行 / 列表 Cell 模型 之间反复拷贝,字段改名要改十几处。
改了下属性就落库 或列表滑动触发了写 ,线上出现难复现的数据损坏。
迁移脚本散落在各业务,跨版本升级只能靠用户卸载重装 。
同步与本地事务顺序混乱,出现幽灵数据、重复、删不掉、两台设备不一致。
因此,持久化方案的目标不应只写用SQLite,而应写清 边界、语义、失败模式与演进策略。
1.2 可度量的成功标准
| 维度 | 说明 |
|---|---|
| 可维护性 | 改表结构、加索引、加字段有固定入口;强弱业务可拆分迁移。 |
| 正确性 | 读写语义明确(快照 vs 可写草稿);崩溃与杀进程下事务可恢复或可追溯。 |
| 可测试性 | Repository 可在内存/临时 DB 上跑单测;迁移可表驱动测试。 |
| 可观测性 | 慢查询、迁移耗时、同步队列积压可度量。 |
| 合规与安全 | 敏感字段加密、备份策略、隐私清单与最小化采集可对齐。 |
二、持久化介质全景与选型决策
2.1 能力对照(抽象层,不限具体 API)
| 介质 | 典型 API / 框架 | 强项 | 弱项 |
|---|---|---|---|
| 键值 | UserDefaults、NSUbiquitousKeyValueStore、SharedPreferences、DataStore Preferences | 极小数据、配置、开关 | 无结构查询、易滥用成大 JSON |
| 安全存储 | Keychain、EncryptedSharedPreferences、Keystore | 凭证、密钥材料 | 容量与语义不适合业务表 |
| 文件 | JSON/plist、二进制、沙盒目录 | 大对象、导出、资源缓存 | 复杂查询成本高 |
| 嵌入式 DB | SQLite(直接/Room/GRDB/FMDB)、Core Data、Realm、SwiftData(底层仍多为 SQLite) | 条件查询、关系、迁移工具链 | 设计不当会变小后端复杂度 |
2.2 决策树
2.3 反模式清单
把业务列表整包序列化进 UserDefaults --- 版本升级、部分更新、多线程写均痛苦。
在 UI 层直接持有 FMDatabase / SQLiteDatabase / NSManagedObjectContext --- 生命周期与线程模型失控。
无事务的大批量写入 --- 卡顿、WAL 暴涨、中断后半写入状态。
迁移逻辑写在业务启动页 copy-paste --- 无法回归、无法跨版本组合验证。
同步与本地 CRUD 共用同一套随手改模型--- 无法区分"用户意图"与"服务端投影"。
三、架构分层
期望:持久层与业务层隔离、读写隔离、线程隔离、数据表达与数据操作隔离, DataCenter(强业务) + Table(弱业务) + Virtual Record(协议化载体)。
3.1 现代命名映射(便于 Android/iOS 统一讨论)
| 文中概念 | 常见现代实现 |
|---|---|
| DataCenter | FooRepository、FooDataStore、用例(UseCase)组合多个 DAO |
| Table / QueryCommand | UserDao、OrderQueries、GRDB QueryInterface |
| Virtual Record | PersistableRow 协议、toRow()/apply(row:)、DTO + Mapper |
| Database Pool | 单写多读队列、Room 的 invalidation、Core Data stack |
核心不是名字,而是 权限与职责:谁能改库、谁知道 SQL、谁知道"列表页怎么筛"。
3.2 推荐依赖方向(自上而下)
Presentation (SwiftUI/UIKit / Compose/Fragment)
→ 只依赖用例接口或 ViewModel 所需模型
Application / Domain
→ Repository 接口(领域语言:saveDraft、publish、observeInbox)
Infrastructure / Data
→ Repository 实现:组合 DAO、同步网关、缓存策略
→ Local DataSource(SQLite/Room/GRDB) + Remote DataSource(API)
依赖倒置:Domain 定义 MessageRepository,Data 里实现;单元测试替换为 fake。
3.3 "胖 Model / 瘦 Model"与"去 Model 化"在持久层的统一说法
持久层的实用表述:
持久化模型(Storage Model):贴近表结构,稳定、可版本化,字段命名偏 DB。
领域模型(Domain Model):业务不变量、枚举状态机,不直接暴露给 SQL。
展示模型(View State):列表 diff、格式化文案、占位图状态。
去 Model 化在边界上体现为:跨层传输可用 字典/DTO/Row,但在 仓库内部 仍应有 显式映射,而不是"全场 NSDictionary"逃避类型。
四、读写隔离与对象语义(极易被低估)
4.1 两类"读结果"
-
快照(Snapshot)
从 DB 读出后 不可变(Swift struct + let,Kotlin data class copy,或只读接口)。UI 若编辑,先 copy → 编辑 → 显式 commit。
-
草稿(Draft)
明确生命周期:可能 从未持久化 或对应未同步的本地编辑缓冲。
4.2 为什么要隔离"写路径"
- 可在唯一写入口挂:审计日志、同步 enqueue、性能统计、数据校验。
- 避免 ORM 跟踪器在 UI 线程隐式 save。
- 与同步状态机 对齐:哪些写应产生 Outbox 事件,哪些只是本地缓存。
4.3 AOP 切点应落在何处
适合放在 Repository 实现或单一 DatabaseGateway:
- willMutate / didMutate(table, op, ids)
- 事务 begin/commit 包装
- 调试构建下断言:主线程禁止写等
五、并发模型与 SQLite 实务
5.1 SQLite 线程模型(概念层)
- 单连接 + 串行写队列 在移动端往往 性价比最高:逻辑简单,性能通常够用。
- 多连接读写需理解 WAL、busy handler、锁竞争;除非 profiling 证明瓶颈,否则不要先优化成多写。
5.2 跨线程传递规则
- 传递 主键或值类型快照,不传递可变行对象。
- iOS:Core Data 传 NSManagedObjectID 的经验可推广到一切 ORM。
- Kotlin:Room 的 @Transaction 与协程 Dispatcher 要在规范里写死。
5.3 长事务与 UI 卡顿
- 大批量导入:后台队列 + 单事务 + 分批 commit(若框架允许子批)。
- 迁移:独立阶段,UI 显示正在准备数据或后台完成,避免首屏抢锁。
六、数据表达与数据操作分离(超越 Active Record)
6.1 Active Record 的问题在移动端如何体现
一个 User 类既 save() 又承担 avatarURL、isVIP 展示,又夹杂 tableName,结果是:
复用困难:想只在设置页持久化部分字段,却拖进整张表语义。
测试困难:new 一个对象就隐含 DB 副作用。
6.2 推荐拆分
| 类型 | 职责 |
|---|---|
| UserTable / UserDao | 按主键/条件查询、插入、更新、删除;不知道 UI |
| UserRepository | 组合多表、处理同步标记、领域规则 |
| User(域) / UserListItem(UI) | 不含 save(),或仅含生成待写入命令的纯函数 |
6.3 Virtual Record 的当代实现要点
协议化能力可包含:
- func rowPayload(for table: TableId) -> [String: Any] --- 一对多表写入时按表裁剪列。
- func merge(other: Self, policy: MergePolicy) --- 多对一聚合。
- static func from(row: Row) --- 统一构造。
这样 Controller 不堆拼字段,迁移时带走协议实现即可。
七、Schema 设计与性能(移动场景仍值得正经做)
7.1 纵切与横切
纵切:列过多按业务域拆表(用户基础 / 用户扩展),详情页再 join 或多次查在内存拼。
横切:按类型、时间、分表规则拆(如 messages_2026_03),移动端较少见,多见于日志或超大本地库。
7.2 索引与查询规范
为 WHERE / ORDER BY / JOIN ON 的实际路径建索引;用 EXPLAIN QUERY PLAN 验收。
避免在循环内单条插入;用 批量 insert + 事务。
7.3 大对象与附件
DB 存 路径、hash、mime、加密标志;二进制走文件或系统级缓存。
全文检索若必须本地做,评估 FTS5(SQLite)与包体、维护成本;否则上云检索。
7.4 加密
文件级:SQLCipher、Room 加密支持、iOS Data Protection 类。
字段级:仅极敏感列(如证件号)加密,密钥走 Keychain/Keystore。
明确 备份(iCloud/Android Backup)行为,避免以为加密了其实被备份明文。
八、迁移(Migration)工程化
8.1 版本模型
App 版本 与 DB 版本 不必 1:1,但每一个 schema 变更 必须对应 单调递增的 DB version。
迁移步骤应是 纯函数列表:(oldVersion, newVersion) -> [Step]。
8.2 步骤类型
DDL:建表、加列、建索引。
DML 数据修复:填默认值、清洗脏数据。
重建表(SQLite 常见):改约束、改主键时整表复制。
8.3 跨版本组合与测试矩阵
用户可能从 v3 直接升到 v8,必须验证:
3→4→5→...→8 链式执行
每一步 幂等(重复跑不炸)在可能情况下成立,或明确不可重复
自动化:准备 golden fixture(旧版 DB 文件),跑迁移后做校验查询。
8.4 失败策略
可重试:磁盘满、I/O 错误 → 提示、稍后重试。
不可恢复:备份失败且 schema 不一致 → 记录 telemetry,清库 或 只读模式(需产品决策)。
九、数据同步
9.1 单向同步(设备为操作源,服务端确认)
适用:IM 发送、离线表单提交、本地优先写。
字段级建议:
| 字段/机制 | 作用 |
|---|---|
| local_id (UUID) | 客户端生成,映射服务端 id |
| server_id | 回填 |
| sync_status | pending / confirmed / failed |
| dirty | 是否有未上传变更 |
| deleted | 逻辑删除直至确认 |
| dependency_id | 处理包乱序 |
| operation_log 表 | 发送窗口内允许继续编辑时的多操作队列 |
极端情况:确认包返回前用户又改 ------ 要么 UI 锁编辑,要么 新 operation 记录 再发,绝不能默默把 dirty 清掉。
9.2 双向同步(多端协作)
操作对象协议:
- 操作唯一 id
- 实体唯一 id(稳定、可跨端)
- 操作类型(insert/update/delete/语义操作如 archive)
- Payload(字段级 patch 优于整对象覆盖)
- 依赖 id
- 客户端时间戳(仅作提示,不是绝对真理)
- 实体版本 / 向量时钟 / 服务端 revision(强烈建议至少一种)
- 来源设备 id(排错与去重)
本地双队列:
- Inbox(待执行):服务端拉下的操作。
- Outbox(待同步):本地上传。
顺序:通常 先 drain Inbox 再推 Outbox(或按业务定义交错规则),并在规范里写死,避免自己刚上传的又被下行覆盖类 bug。
9.3 冲突解决策略选型
| 策略 | 适用 | 风险 |
|---|---|---|
| LWW(最后写入赢) | 简单配置、非关键文案 | 静默丢编辑 |
| 字段级 merge | 结构化文档、可拆分字段 | 需 schema 支持 |
| 操作变换 / CRDT | 实时协作、富文本 | 实现与测试成本高 |
| 用户选择 | 高价值内容 | UX 负担 |
不要同步 SQL 语句:冲突分析、安全审计、版本兼容都会变成灾难。
9.4 离线队列与重试
指数退避 + 抖动,区分 可重试错误 与 业务 4xx。
幂等键:服务端按 client_op_id 去重。
队列上限与合并:同一实体的多条 patch 在发送前可合并(注意依赖关系)。
十、测试策略(没有这部分,架构文不完整)
10.1 单测
Fake Clock、IdGenerator 注入 Repository。
内存数据库(:memory:)或临时文件 DB。
10.2 迁移测试
资产化 vN.sqlite,迁移到最新,断言行数、关键列、索引存在。
10.3 同步测试
模拟乱序包、重复包、失败重试、中途杀进程。
状态机属性测试(property-based)若有条件可上。
十一、动态部署:手段、边界与推荐组合
11.1 目标拆分
动态常混指三件事,应分开决策:
- 内容动态:文案、运营页、帮助、活动规则。
- 配置动态:开关、实验、参数、路由表。
- 逻辑动态:改变原生代码路径、下发可执行脚本修补方法。
只有 1+2 是默认安全区;3 在 iOS 上基本不可作为正规方案。
11.2 方案谱系
| 方案 | 能力 | 主要限制 |
|---|---|---|
| 纯 H5 / PWA | 迭代最快 | 能力、体验、离线复杂 |
| Hybrid + JSBridge | 内容+部分逻辑,复用 Native 能力 | 性能、复杂交互、调试成本 |
| 服务端驱动 UI(JSON DSL) | 换肤、运营布局、表单 | 事件与原生能力需预注册 |
| 远程配置 / Feature Flag | 灰度、降级、参数 | 不是万能逻辑补丁 |
| 内置热更 bundle(RN/Flutter) | 在合规前提下更新 JS bundle | 不能随意加新的原生插件而不发版 |
| 方法替换 / 下载动态库 | 历史上用于热修 | 审核与签名模型下不可依赖 |
11.3 微服务化 Native
把 Native 能力拆成 稳定、版本化、弱业务 的 service:
- auth.getSession
- pay.startCheckout
- nav.open
- storage.putFile
- device.getSafeArea
H5 或 DSL 编排业务流程,而不是每个页面穿透调用任意 Objective-C/Swift。
11.4 安全与治理
Bridge 白名单 + 参数 schema 校验(防 XSS/注入式调用)。
远程资源 签名与完整性(hash + 公钥验签)。
最小权限:WebView 不开放整文件系统。
回滚:配置带版本号,客户端保留上一版。
11.5 Android 与 iOS 差异(简述)
Android 在历史上对 动态加载 dex 等更敏感,Google Play 同样有政策约束。
iOS 对新解释执行代码 限制更严;Hybrid 与配置驱动 仍是主力。
十二、端到端示例(伪流程)
场景:设置页多表单片段 + 用户详情纵切两表 + 单向同步。
- SettingsViewModel 调 UserRepository.loadProfile() → 返回 快照。
- 用户编辑 → Draft 状态仅在内存,点保存 → repository.saveProfile(draft):
- 单事务写 user_core、user_prefs
- Outbox 追加 UpsertUser 操作(含 local_op_id)
- 同步器上传,成功后 映射 server_id,清 dirty,删 Outbox 行。
- 若失败 → UI 展示重试;允许继续编辑则 新 op 而非覆盖旧 op 状态。
十三、落地路线图
- 第一阶段(规范)
选型矩阵、禁止项、目录结构(data/local、data/sync、domain)。 - 第二阶段(骨架)
Repository + DAO + DTO + 单写队列;迁移框架就位。 - 第三阶段(同步)
Outbox/Inbox、幂等、乱序依赖、冲突策略文档化。 - 第四阶段(动态)
Remote Config + Hybrid 容器 + Bridge 版本化;明确不做 的灰区(热修)。
持续
慢查询与迁移 dashboard;线上数据异常 SOP。
十四、结语
本地持久化的质量,本质是 边界是否清晰、语义是否可证明;动态部署的质量,本质是 把"可变"限制在商店与风险模型允许的层。