--- title: 数据层 updated: 2026-06-07 status: active tags: [isar, offline-first, sync, repository-pattern] --- # 数据层 — Isar 本地存储 + SyncEngine 离线同步 > 从 [[index]] 导航。关联: [[handwriting-engine]] [[frontend]] [[erp-diary]] ## 1. 设计决策 ### Q: 为什么离线优先? 小学生使用场景中网络不稳定(校园 WiFi 信号差、家庭网络波动)。所有功能必须离线可用,联网后自动同步。 ### Q: 为什么选 Isar 不用 SQLite? Isar 提供 FTS 全文搜索、内置加密、零配置 Flutter 原生支持、类型安全的查询 API。对 Flutter 项目比 SQLite+drift 更轻量。 ### Q: 为什么用版本号冲突检测? 离线编辑 → 联网同步场景下,同一条数据可能在多端修改。version 字段做乐观锁,Phase 1 使用"本地优先"策略(本地版本覆盖远端),Phase 2 提供 UI 手动解决。 ### Q: 为什么 Repository 抽象接口? `JournalRepository` 抽象接口 → `IsarJournalRepository`(本地)+ `RemoteJournalRepository`(远程)。BLoC 只依赖抽象,切换实现不影响业务逻辑。 ## 2. 关键文件 + 数据流 ### 核心文件 | 文件 | 行数 | 职责 | |------|------|------| | `data/local/isar_database.dart` | 72 | 单例管理,3 Schema 注册 | | `data/repositories/isar_journal_repository.dart` | 333 | CRUD + 乐观锁 + 软删除 | | `data/repositories/remote_journal_repository.dart` | 114 | Dio HTTP API 代理 | | `data/repositories/journal_repository.dart` | 184 | 抽象接口 + InMemory 测试实现 | | `data/services/sync_engine.dart` | 338 | WiFi 增量同步 + Isar 队列持久化 | | `data/local/collections/` | 3+3 文件 | Isar Collection 定义 + 生成 Schema | ### 数据流 ``` UI 操作 │ ▼ EditorBloc.onSave (2s debounce) │ ├─→ IsarJournalRepository.createJournal() [首次] ├─→ IsarJournalRepository.updateJournal() [后续] │ └─ 乐观锁: version 匹配 → +1 → 写入 │ └─ 不匹配 → throw StateError('版本冲突') │ └─→ SyncEngine.enqueue() [待同步操作入队] │ WiFi 可用时 ▼ trySync() → RemoteJournalRepository → API → PostgreSQL │ 成功 ▼ persistPendingQueue() [更新 Isar 队列] ``` ### 集成契约 | 方向 | 组件 | 接口 | 触发时机 | |------|------|------|---------| | 被调用 ← | EditorBloc | `onSave(EditorState)` | 笔画/元素变更 2s 后 | | 调用 → | IsarJournalRepository | `createJournal/updateJournal` | 首次/后续保存 | | 调用 → | SyncEngine | `enqueue/trySync` | 数据变更/网络恢复 | | 启动时 → | SyncEngine | `restorePendingQueue()` | app.dart 创建时 | ## 3. 代码逻辑 ### 不变量 ⚡ **Isar 扩展方法需显式 import** — `import 'package:isar/isar.dart'` 不会随传递 import 传播,必须在使用 `findAll()`/`findFirst()` 的文件中显式导入 ⚡ **笔画序列化固定 ID** — `${journalId}_strokes` 保证每次覆盖而非重复创建 ⚡ **SyncEngine 双写** — 操作同时存在于内存 Queue 和 Isar PendingOperationCollection ⚡ **乐观锁** — updateJournal 比对 version,不匹配抛 StateError ⚡ **软删除级联** — 删除日记时关联元素的 isDeleted 也设为 true ### 3 个 Isar Collection | Collection | Isar 主键 | 业务 ID | 用途 | |------------|----------|---------|------| | JournalEntryCollection | autoIncrement | id (indexed) | 日记条目 | | JournalElementCollection | autoIncrement | id (indexed) | 日记元素 | | PendingOperationCollection | autoIncrement | id (indexed) | 同步操作队列 | ### 初始化链 ```dart // main.dart WidgetsFlutterBinding.ensureInitialized(); await IsarDatabase.init(); // 注册 3 个 Schema // app.dart final syncEngine = SyncEngine(apiClient: apiClient); syncEngine.restorePendingQueue(); // fire-and-forget 恢复队列 ``` ## 4. 活跃问题 + 陷阱 | 问题 | 级别 | 状态 | 说明 | |------|------|------|------| | authorId 硬编码 'local' | HIGH | 待修 | EditorPage 未接入 AuthBloc 获取真实用户 | | SyncEngine 仅 WiFi | MEDIUM | Phase 2 | 蜂窝数据同步未实现 | | 版本冲突静默覆盖 | MEDIUM | Phase 2 | "本地优先"策略,需 UI 手动解决 | | ~~编辑器未加载已有数据~~ | ~~MEDIUM~~ | ✅ 已修复 | _loadExistingJournal 读取日记 + 元素 + 笔画 | ### 历史教训 - Isar `findAll()` 编译报"未定义" — 原因是扩展方法不随 import 传播,需显式 `import 'package:isar/isar.dart'` - Isar 3.x 查询链必须通过 `.filter()` 过渡才能调用 `.findAll()` ## 5. 变更记录 | 日期 | 变更 | |------|------| | 2026-06-07 | 编辑器加载已有数据已修复、打开已有日记默认查看模式 | | 2026-06-01 | Isar 集成完成:3 Collection + Repository + SyncEngine 持久化 (2481c8f) | | 2026-06-01 | 初始创建 — 数据层架构、Isar 踩坑记录 |