主题
技术决策记录
本文档记录项目中的重大技术决策,说明选择的背景、理由和影响。
采用精简 ADR 格式:按时间顺序记录,无编号,聚焦"为什么"。
决策1:采用 Clean Architecture
日期:2025-09 状态:已接受 ✅
背景
项目初期需要选择一个架构模式,考虑到:
- 这是一个长期维护的个人项目
- 需要良好的可测试性
- 未来可能扩展到更多平台
- 需要清晰的代码组织结构
决策
采用 Clean Architecture(整洁架构),分为三层:
- Domain Layer:纯业务逻辑,无框架依赖
- Data Layer:数据获取和持久化
- Presentation Layer:UI 和用户交互
理由
优势:
- 依赖倒置:外层依赖内层,内层不依赖外层,符合 SOLID 原则
- 可测试性:每层可独立测试,Domain 层完全不依赖 Flutter
- 可维护性:层次清晰,职责分明,易于定位问题
- 可扩展性:易于替换数据源(SQLite → Hive)、UI 框架(Flutter → Web)
对比其他方案:
- MVC:职责不够清晰,Controller 容易臃肿
- MVVM:比 Clean Architecture 简单,但扩展性较差
- 没有架构:短期快速,但长期维护困难
代价与应对
代价:
- ⚠️ 初始代码量较多(每个功能需要 Entity、Repository、DataSource)
- ⚠️ 学习曲线较陡(需要理解依赖倒置原则)
- ⚠️ 文件数量多(一个功能可能有 10+ 个文件)
应对措施:
- 使用代码生成工具(Riverpod Generator、Freezed)减少样板代码
- 编写详细的架构文档和示例代码
- 使用 IDE 模板快速创建新功能模块
实施结果
成功指标:
- ✅ Domain 层单元测试覆盖率 > 80%
- ✅ 新功能开发遵循统一架构模式
- ✅ 代码审查时架构违规较少
经验教训:
- 对于个人项目,Clean Architecture 的投入是值得的
- 严格的分层带来了长期维护的便利
- 代码生成工具大幅减少了样板代码的负担
决策2:选择 Riverpod 作为状态管理方案
日期:2025-09 状态:已接受 ✅
背景
Flutter 有多种状态管理方案可选:
- Provider(官方推荐)
- Riverpod(Provider 的改进版)
- Bloc(事件驱动)
- GetX(轻量级)
- MobX(响应式)
需要选择一个适合 Clean Architecture 的状态管理方案。
决策
选择 Riverpod + riverpod_generator。
理由
对比 Provider:
- ✅ 编译时安全:不会忘记提供 Provider,避免运行时错误
- ✅ 支持 Provider 容器测试:可以在测试中覆盖 Provider
- ✅ 更好的性能优化:细粒度的依赖追踪
- ✅ 自动依赖管理:Provider 之间的依赖自动处理
- ✅ 无需 BuildContext:可以在任何地方访问 Provider
对比 Bloc:
- ✅ 代码更简洁(无需定义 Event、State 类)
- ✅ 学习曲线更低
- ✅ 更适合个人项目(Bloc 更适合大团队)
对比 GetX:
- ✅ 类型安全(GetX 使用字符串标识)
- ✅ 更符合 Flutter 官方推荐风格
- ✅ 社区支持更好
代价与应对
代价:
- ⚠️ 需要使用代码生成(riverpod_generator)
- ⚠️ 迁移成本(如果之前使用 Provider)
- ⚠️ 学习新的 API(与 Provider 有差异)
应对措施:
- 集成 build_runner 到开发流程
- 编写 Riverpod 使用指南和最佳实践
- 统一 Provider 命名规范(xxxProvider)
实施结果
成功指标:
- ✅ 所有状态管理使用 Riverpod
- ✅ 使用 riverpod_generator 减少样板代码
- ✅ Provider 测试覆盖率 > 70%
关键代码模式:
dart
@riverpod
class VocabularyFiltering extends _$VocabularyFiltering {
@override
FutureOr<FilteringResultEntity> build() {
return FilteringResultEntity.initial();
}
Future<void> filterVocabularies(List<String> words) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(vocabularyFilteringRepositoryProvider);
return await repository.filterVocabularies(...);
});
}
}决策3:使用 SQLite 作为本地存储方案
日期:2025-09 状态:已接受 ✅
背景
应用需要本地存储以下数据:
- 用户词汇笔记本(可能数千条记录)
- CEFR 词库(8,614 个单词)
- 学习记录和统计数据
- 词汇来源和关联关系
需要选择合适的本地存储方案。
决策
选择 SQLite(通过 sqflite 包)作为主要本地存储方案。
理由
对比 Hive:
- ✅ 支持复杂查询:JOIN、GROUP BY、聚合函数
- ✅ 支持事务:保证数据一致性
- ✅ 支持索引:查询性能优化
- ✅ 更适合关系型数据:词汇、来源、关联关系等
对比 SharedPreferences:
- ✅ 支持结构化数据(SharedPreferences 只适合简单键值对)
- ✅ 支持批量操作和事务
- ✅ 数据量不受限制
对比 Isar:
- ✅ 更成熟稳定(SQLite 存在 30+ 年)
- ✅ Flutter 官方推荐
- ✅ 工具生态更丰富
代价与应对
代价:
- ⚠️ 需要编写 SQL 语句(学习成本)
- ⚠️ 数据库迁移复杂(需要版本管理)
- ⚠️ 性能依赖索引设计
应对措施:
- 封装数据库操作到 DataSource 层
- 建立完善的数据库迁移机制(v1 → v13)
- 为高频查询字段建立索引
- 使用事务保证数据一致性
实施结果
数据库规模:
- Schema 版本:v27
- 表数量:9 张
- 预置词库:CEFR-J 8,614 个单词
- 数据库大小:~2-3 MB
- 迁移历史:13 次版本升级
性能指标:
- 查询响应:< 100ms(有索引)
- 批量插入:1000 条/秒
- 数据库启动:< 50ms
关键设计:
sql
-- 核心表
- user_settings(用户设置)
- vocabulary_items(词汇表,8,614 条预置数据)
- notebook_entries(笔记本条目)
- word_mastery_info(SM-2 算法数据)
- learning_records(学习记录)
- reading_sources(阅读来源)
- word_source_relations(词汇-来源关联)
- translation_cache(翻译缓存)
- ocr_cache(OCR 缓存)
- daily_statistics(每日统计)经验教训:
- SQLite 的关系型特性非常适合我们的数据模型
- 索引设计对查询性能影响巨大
- 数据库迁移需要谨慎设计,保证向后兼容
决策4:选择 OpenCV 作为图像处理方案
日期:2025-12-26 状态:已接受 ✅
背景
应用需要图像处理功能来提升 OCR 准确率:
- 自动文档边缘检测:识别拍摄的书页边界
- 透视矫正:将斜拍的文档变为正视图
- 图像增强:去噪、二值化、提升对比度
- 背景移除:去除文档背景干扰
候选方案:
- OpenCV(opencv_dart):强大的计算机视觉库
- image_cropper:Flutter 原生裁剪 UI 组件
- 混合方案:OpenCV + image_cropper
决策
选择 OpenCV(opencv_dart)作为主要图像处理方案,保留 image_cropper 作为可选的手动调整工具。
理由
OpenCV 的核心优势:
- ✅ 自动边缘检测:Canny 边缘检测 + findContours 轮廓查找
- ✅ 透视矫正:getPerspectiveTransform 四点透视变换
- ✅ 图像增强:高斯模糊、Otsu 二值化、形态学操作
- ✅ 背景移除:GrabCut 算法自动分离前景和背景
- ✅ 完全自动化:无需用户手动操作,快速处理
- ✅ 可控性强:可在应用层优化算法参数
image_cropper 的局限:
- ❌ 仅提供手动裁剪 UI,无自动检测功能
- ❌ 无透视矫正能力
- ❌ 无图像增强功能
- ⚠️ 完全手动,处理速度慢(用户操作需 5-30 秒)
技术对比:
| 功能 | OpenCV | image_cropper |
|---|---|---|
| 自动边缘检测 | ✅ | ❌ |
| 透视矫正 | ✅ | ❌ |
| 图像增强 | ✅ | ❌ |
| 手动裁剪 UI | ⚠️ 需自行实现 | ✅ 原生 UI |
| 处理速度 | 2-3 秒(自动) | 6-30 秒(手动) |
| 准确率 | 85-90% | 100%(用户决定) |
| 适用场景 | 批量处理、快速识别 | 单张精确处理 |
代价与应对
代价:
- ⚠️ 库体积较大(~15MB)
- ⚠️ Mat 对象内存管理复杂
- ⚠️ 算法参数需要调优
- ⚠️ 边缘检测可能失败(复杂背景、低对比度)
应对措施:
- 内存管理:实现 MatScope 自动管理 Mat 对象生命周期
- 算法优化:
- 图像预缩放到 1200px(减少处理时间 60%)
- 高斯模糊降噪(减少轮廓数量 80%)
- 限制处理轮廓数量(避免超时)
- 降级方案:自动检测失败时,提供 image_cropper 手动裁剪入口
- 用户反馈:预览检测结果,允许用户调整
实施结果
已实现功能:
- ✅ 文档边缘检测和透视矫正
- ✅ 图像增强(去噪、二值化)
- ✅ 背景移除(GrabCut 算法)
- ✅ Mat 对象自动内存管理(MatScope)
- ✅ 性能优化(预缩放、降噪、限制轮廓数)
性能指标:
- 边缘检测:< 1000ms(优化前 ~2000ms)
- 透视矫正:< 100ms
- 图像增强:< 400ms
- 背景移除:< 2000ms(1200px 图像)
内存管理:
dart
// MatScope 自动管理 Mat 对象
MatScope.run(() {
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
final edges = cv.Canny(gray, 50, 150);
final contours = cv.findContours(edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
// ... 处理逻辑
// 所有 Mat 对象在作用域结束时自动释放
});混合方案(未来):
- 优先使用 OpenCV 自动处理(80% 场景)
- 失败时降级到 image_cropper 手动裁剪(20% 场景)
经验教训
成功经验:
- OpenCV 的自动化能力大幅提升用户体验
- MatScope 模式有效解决了内存管理问题
- 算法参数优化带来显著性能提升
遇到的问题:
- 边缘检测在复杂背景下准确率较低(已通过预处理优化)
- 大图片处理耗时长(已通过预缩放解决)
- Mat 对象泄漏导致崩溃(已通过 MatScope 解决)
未来改进:
- 集成机器学习模型提升边缘检测准确率
- 实现自动/手动混合模式
- 优化算法参数(根据实际使用数据调整)
最后更新:2026-02-04 更新频率:每次重大技术决策时新增 文档状态:v1.0
决策:NotebookRepository 接口隔离重构
日期:2026-02-04 状态:已接受 ✅
背景
NotebookRepository 接口膨胀到 35 个方法(221 行),违反接口隔离原则(ISP):
- 需要完整功能的调用方被迫依赖所有方法
- 接口职责不清晰,难以维护
- 测试时需要 mock 大量不相关的方法
决策
将 NotebookRepository 拆分为 5 个子接口:
| 接口 | 职责 | 方法数 |
|---|---|---|
INotebookEntryWriter | 条目写入(CRUD) | 8 |
INotebookEntryReader | 条目查询 | 10 |
ILearningRecordRepository | 学习记录管理 | 7 |
INotebookDataExchange | 数据导入导出 | 3 |
IReviewSessionRepository | 复习会话查询 | 4 |
主接口 NotebookRepository 聚合所有子接口:
dart
abstract class NotebookRepository
implements
INotebookEntryWriter,
INotebookEntryReader,
ILearningRecordRepository,
INotebookDataExchange,
IReviewSessionRepository {}理由
优势:
- 接口隔离:调用方只依赖需要的接口
- 单一职责:每个接口职责明确
- 易于测试:只需 mock 相关接口
- 渐进式采用:现有代码无需修改
对比其他方案:
- 不拆分:接口继续膨胀,可维护性下降
- 完全拆分:需要修改所有调用方,改动太大
代价与应对
代价:
- ⚠️ 增加 5 个接口文件
- ⚠️ 需要同步维护接口定义
应对措施:
- 主接口通过 export 导出子接口
- 实现类不变,仍是单个
NotebookRepositoryImpl
决策:NotebookRepositoryImpl 应用 RepositoryMixin
日期:2026-02-04 状态:已接受 ✅
背景
NotebookRepositoryImpl 有 896 行代码,存在大量重复的 try-catch 模式:
- 几乎每个方法都有相同的错误处理样板代码
- 违反 DRY 原则
- 增加代码维护成本
决策
将 NotebookRepositoryImpl 混入 RepositoryMixin,使用统一的错误处理方法:
dart
class NotebookRepositoryImpl with RepositoryMixin implements NotebookRepository {
// 使用 safeCall、safeCallList、safeCallVoid 等方法
}简化的方法(12 个):
getTotalEntryCount、getMasteredEntryCountclearAll、deleteEntry、deleteEntriesBatchgetLearningRecords、recordLearninggetDueReviewsByGroup、getGroupSummaries、getDateSummaries、getDueReviewsByDateimportFromJson
理由
优势:
- 减少重复代码:36 行减少(896 → 860)
- 统一错误处理:所有方法使用相同的错误处理模式
- 易于维护:修改错误处理只需改一处
- 提高可读性:方法主体更专注于业务逻辑
未简化的方法:
- 包含复杂业务逻辑的方法(如
addNotebookEntry、reviewWordWithQuality) - 需要特殊错误处理的方法(如
getEntryById需要 NotFound 判断)
代价与应对
代价:
- ⚠️ 部分复杂方法无法简化
- ⚠️ 需要熟悉 RepositoryMixin API
应对措施:
- 复杂方法保持原有 try-catch 模式
- RepositoryMixin 已有详细文档说明
决策:MapParser 辅助类用于 Map 解析
日期:2026-02-04 状态:已接受 ✅
背景
项目中存在大量重复的 Map 解析代码,特别是在 fromDatabase() 和 fromJoinedMap() 方法中:
- DateTime.parse() 重复出现 20+ 次
- 类型转换和默认值处理模式重复
- 空值检查逻辑冗余
决策
创建 lib/core/utils/map_parser.dart 辅助类:
dart
class MapParser {
static String getString(Map<String, dynamic> map, String key, {String defaultValue = ''});
static DateTime getDateTime(Map<String, dynamic> map, String key, {DateTime? defaultValue});
static int getInt(Map<String, dynamic> map, String key, {int defaultValue = 0});
static double getDouble(Map<String, dynamic> map, String key, {double defaultValue = 0.0});
// ... 更多方法
}理由
优势:
- 减少重复代码:统一的 Map 解析方法
- 类型安全:处理各种类型转换场景
- 空值处理:内置默认值支持
- 易于使用:静态方法,无需实例化
为什么不重构现有代码:
- 大部分 Model 由 Freezed 生成,不应手动修改
- 现有代码已稳定运行
- 渐进式采用:新代码优先使用
代价与应对
代价:
- ⚠️ 现有代码未使用新辅助类
- ⚠️ 需要团队了解新工具
应对措施:
- 新代码优先使用 MapParser
- 重构时逐步替换旧代码
- 文档说明使用场景
决策:Skill 与文档职责分离
日期:2026-02-21 状态:已接受 ✅
背景
项目有 7 个自动化 Skill(.claude/skills/),其中 ui-compliance-check 和 code-review 存在严重的内容重复问题:
问题发现:
ui-compliance-check/SKILL.md:1126 行,其中约 60% 与docs/ui-guidelines/重复code-review/SKILL.md:558 行,其中约 20% 与docs/development.md重复- 维护时需要同时更新 2 处,容易遗漏导致不一致
重复内容示例:
- 硬编码颜色修复表格(SKILL.md + material3-design-system.md)
- ElevatedButton 废弃说明(SKILL.md + button-style-guide.md)
- 圆角标准值(SKILL.md + README.md + button-style-guide.md + dialog-style-guide.md)
- Null Safety 代码示例(SKILL.md + development.md)
决策
职责分离原则:
| 角色 | 职责 | 内容 |
|---|---|---|
| docs/ | 权威知识库 | 完整的设计原则、代码示例、修复建议 |
| Skill | 自动化检测 | 检测规则、执行命令、报告格式、文档链接 |
Skill 精简规则:
- 每个检查规则只保留:检测模式 + 标准值 + 文档链接
- 移除所有重复的代码示例和修复建议表格
- 输出报告提供文档链接,而非复制内容
架构示意:
docs/(权威文档)
├── 完整的设计原则
├── 详细的代码示例
└── 修复建议和最佳实践
↓ 引用(不复制)
.claude/skills/(自动化检测)
├── 检测规则(正则/命令)
├── 执行流程
└── 报告格式 + 文档链接理由
优势:
- 单点维护:规范只在文档中维护,Skill 自动引用
- 减少重复:代码量减少 68%(1684 → 539 行)
- 一致性保障:不会出现 SKILL.md 与文档不一致的情况
- 职责清晰:文档负责"是什么",Skill 负责"检测什么"
对比其他方案:
- 不分离:继续维护 2 处,容易遗漏,不一致风险高
- 删除 Skill 中的规则详情:Skill 失去独立执行能力
- 删除文档中的规则详情:开发者学习时缺少参考资料
代价与应对
代价:
- ⚠️ Skill 执行时需要查阅文档获取详细修复建议
- ⚠️ 文档链接可能失效(如果文档结构变化)
应对措施:
- Skill 输出报告中包含直接的文档链接
- 使用锚点链接(如
#颜色系统规范)定位到具体章节 - 定期运行
/doc-consistency-check检查断链
实施结果
精简效果:
| Skill | 优化前 | 优化后 | 减少 |
|---|---|---|---|
| ui-compliance-check | 1126 行 | 263 行 | -78% |
| code-review | 558 行 | 276 行 | -50% |
| 合计 | 1684 行 | 539 行 | -68% |
版本变更:
ui-compliance-check:v2.0.0 → v3.0.0code-review:v2.0.0 → v3.0.0
经验教训
成功经验:
- 职责分离使维护更简单
- 文档链接比复制内容更可维护
- 精简后 Skill 更聚焦于"检测"职责
适用范围:
- 此原则适用于所有 Skill,后续新增 Skill 应遵循此模式
- 其他 Skill(如
doc-sync-check、clean-arch-check)重复程度低,暂不需要精简
决策13:代码审核体系支持全量检查模式
日期:2026-02-21 状态:已接受 ✅
背景
现有的代码审核 Skill(code-review、ui-compliance-check、clean-arch-check)只支持增量检查(基于 git diff),存在以下限制:
- 无法检查历史遗留问题
- 无法做全量合规性审计
- 重构后无法验证整体一致性
决策
为现有检查类 Skill 添加 --full 参数,支持全量代码检查模式:
bash
# 增量检查(默认)
/code-review
/ui-compliance-check
/clean-arch-check
# 全量检查(审计模式)
/code-review --full
/ui-compliance-check --full
/clean-arch-check --full同时统一问题分级标准:
| 级别 | 颜色 | UI Skill | 架构 Skill | 含义 |
|---|---|---|---|---|
| 🔴 | 红色 | P0 | R1 | 严重问题 |
| 🟠 | 橙色 | P1 | R2 | 重要问题 |
| 🟡 | 黄色 | P2 | R3 | 建议修复 |
| 🟢 | 绿色 | P3 | R4 | 可选优化 |
| 🔵 | 蓝色 | P4 | R5 | 信息提示 |
理由
优势:
- 审计能力:支持全量代码库的合规性审计
- 重构验证:大规模重构后可验证整体一致性
- 历史问题发现:能发现历史遗留的规范问题
- 一致性:统一分级标准使报告更易理解
对比其他方案:
- 创建独立的全量检查 Skill:会造成代码重复,维护成本增加
- 只支持增量检查:无法满足审计需求,限制了使用场景
代价与应对
代价:
- ⚠️ 全量检查耗时较长(大型项目可能需要几分钟)
- ⚠️ 全量检查结果可能很多,需要分批处理
应对措施:
- 默认使用增量模式,全量模式需显式指定
--full - 全量模式输出清晰的统计信息,便于分批修复
- 问题按优先级分类,可先处理高优先级问题
实施结果
版本变更:
code-review:v3.0.0 → v4.0.0ui-compliance-check:v3.0.0 → v4.0.0clean-arch-check:v1.1.0 → v2.0.0
覆盖矩阵(更新后):
| 代码对象 | 代码范围 | Skill 支持 |
|---|---|---|
| 普通代码 | 增量 | ✅ code-review |
| 普通代码 | 全量 | ✅ code-review --full |
| UI代码 | 增量 | ✅ ui-compliance-check |
| UI代码 | 全量 | ✅ ui-compliance-check --full |
| 架构代码 | 增量 | ✅ clean-arch-check |
| 架构代码 | 全量 | ✅ clean-arch-check --full |
经验教训
设计原则:
- 增量检查是日常开发的默认选择(快速)
- 全量检查用于特定场景(审计、重构验证)
- 两种模式共用检测规则,只是检查范围不同
决策14:复习按钮从 3-Button 改为 2-Button + 序列推导
日期:2026-03-27 状态:已接受 ✅
背景
当前复习系统使用 3 个按钮(Forgot / Recalled / Perfect),映射 SM-2 Quality 为 1/4/5。实际使用中发现:
- 用户难以区分 Recalled 和 Perfect("回忆起来但犹豫" vs "零犹豫")
- 决策疲劳:3 个选项增加认知负担
- 行业趋势:Anki、FSRS、Duolingo 等均支持或转向二元输入
决策
采用 Plan B:2-Button + 前向序列推导,用 Hard/Easy 两个按钮 + 上一次评分历史推导出 4 级质量分。
质量映射:
| 上次按钮 | 本次按钮 | Quality | 含义 |
|---|---|---|---|
| Hard | Hard | 1 | 持续困难,需要重置 |
| Easy | Hard | 2 | 退步,降低间隔但不重置 |
| Hard | Easy | 4 | 进步中,适度加速 |
| Easy | Easy | 5 | 稳定掌握,最大加速 |
首次复习(无历史)默认按 Hard→X 处理(Hard=1, Easy=4)。
核心算法:
dart
int getQuality({required bool isEasy, required int? lastQuality}) {
final lastWasEasy = lastQuality != null && lastQuality >= 3;
if (isEasy) {
return lastWasEasy ? 5 : 4; // Easy→Easy=5, Hard→Easy=4
} else {
return lastWasEasy ? 2 : 1; // Easy→Hard=2, Hard→Hard=1
}
}Mastery 降级细化:
- q=1(Hard→Hard):降 2 级(与原 Forgot 一致)
- q=2(Easy→Hard):降 1 级(新增,比原设计更精细)
数据库变更:
notebook_entries新增last_review_quality INTEGER列
理由
选择 Plan B 而非 Plan A(回溯修正)的原因:
- 避免回溯复杂度:Plan A 需要根据本次按钮重新计算上次的质量并修正 EF/interval,存在时间间隔混淆问题
- 前向推导更简洁:只需存储上次质量,本次直接算出 4 级分数
- SM-2 兼容性好:质量 {1,2,4,5} 覆盖 SM-2 的关键区间,q≥3 晋级 / q<3 重置的核心逻辑不变
选择 {1,2,4,5} 而非 {1,2,3,4} 的原因:
- q=4 是 SM-2 的 EF 不变点(ΔEF ≈ 0),如果最大值只有 4,EF 永远无法增长
- q=5 允许 EF 增长(ΔEF = +0.1),稳定掌握的词间隔会加速拉长
- 跳过 q=3 是刻意的:q=3 在 SM-2 中效果平庸(ΔEF = -0.14),2-Button 不需要这个中间值
竞品验证:
| 产品 | 输入方式 | 质量推导策略 |
|---|---|---|
| Anki Pass/Fail 插件 | 2 按钮 | Pass/Fail 3 版本用响应时间细化 |
| FSRS | 官方支持 Again/Good 二元 | 完整复习历史优化难度参数 |
| Duolingo HLR | 纯二元 correct/incorrect | Half-Life Regression 模型 |
| SuperMemo SM-17/18 | 多按钮但核心是历史推导 | 从整个复习历史推导难度 |
行业趋势明确支持二元输入 + 历史推导方向。
代价与应对
代价:
- ⚠️ 需新增
last_review_quality数据库列(Schema v36) - ⚠️ 首次复习无历史,退化为 2 级质量(q=1 或 q=4)
- ⚠️ 现有用户 3-Button 评分历史无法直接映射到新 last_quality
应对措施:
- 数据库迁移:新增列默认 NULL,首次复习按无历史处理
- 现有数据兼容:last_review_quality 为 NULL 时视为首次复习
- RB(Tauri)和 RVH(Flutter)同步更新,保持双端一致
影响范围
需要修改的文件:
lib/core/algorithms/sm2_algorithm.dart— 新增getQuality()方法lib/features/vocabulary_notebook/data/datasources/mastery_tracking_datasource.dart— 降级逻辑细化notebook_entries数据库表 — 新增列- 复习页面 UI — 3 按钮 → 2 按钮
- RB 端
src-tauri/src/commands/srs.rs— 同步修改
决策15:lemmatizer 转词典优先架构
日期:2026-04-28 状态:已接受 ✅
背景
vocab-pk Phase 3 跨端跑分实测原 lemmatizer 误砍 27+ 个常见英文名词:sliver→sliv、splinter→splint、lawyer→lawy、marketing→market、courier→coury、glacier→glac 等。原架构是 4 层启发式:
- Layer 0 EXCEPTIONS(手工 ~300 词例外集)
- Layer 1 不规则词表(WordNet 5,637 条)
- Layer 2 后缀剥离(11 条 SUFFIX_RULES)
- Layer 3 fallback
误砍根因:Layer 2 的 -er/-or/-ier/-ar 后缀规则过于激进,靠 Layer 0 手工列例外救回是封闭世界假设 — AGID 词典里 -ier 结尾的英语单词约 1,200 个(marketing/glacier/cashier/...),手工维护例外表永远列不全。
决策
放弃打补丁修例外表,转「词典优先」4 层架构:
- Layer 1: SURFACE_TO_BASE 屈折查表 →
"dictionary" - Layer 2: BASE_FORMS 自映射 →
"base" - Layer 3: SUFFIX_RULES 提议 stem + BASE_FORMS 裁决 →
"suffix-validated" - Layer 4: fallback →
"fallback"
数据源:AGID 2016.01.19(Kevin Atkinson 整理的英语屈折词典,公共领域)+ WordNet 5,637 不规则形人工策展覆盖。资产:
assets/nlp/base_forms.json(1.1 MB / 101,646 条)assets/nlp/surface_to_base.json(3.2 MB / 140,371 条)
公共契约保留:normalize(w) := nfc(lemmatize(w.trim().toLowerCase())) 签名 sync 不变,8 个 lib/ 调用点零侵入。
理由
核心变化:原则从「lemma 输出双端等量错也行」转向「lemma 正确优先,跨端一致性由双端共享同一份正确数据保证」。
为什么不修例外表:
| 维度 | 修例外表 | 词典优先 |
|---|---|---|
| 准确率 | ~75-80% | ~95-97% |
| 维护成本 | 每次发现误砍手工加一条 | 一次切换,长期受益 |
| 边界 | 封闭(永远不全) | 开放(覆盖 AGID 全部) |
| 跨端不变式 | "等量错"脆弱 | "byte-equal 数据"明确 |
为什么选 AGID + WordNet:
| 数据源 | 规模 | 许可 | 屈折覆盖 | 不规则形 |
|---|---|---|---|---|
| AGID 2016 | 100K+ | 公共领域 | ✅ 完整 | 部分 |
| WordNet 3.1 | 155K | WordNet 许可 | 部分 | ✅ 完整 |
| AGID + WordNet 合并 | 100K+ | 双兼容 | ✅ | ✅ |
WordNet 不规则形(went→go, children→child, better→good)覆盖 AGID 自动展开的薄弱处。
为什么 Layer 3 改成"提议 + 裁决":
Layer 3 复用现有 _proposeStem(11 条 SUFFIX_RULES),但只在提议的 stem 在 BASE_FORMS 里时才接受。这给新词(AGID 2016 之后出现的词,如 tweeted)一个 fallback 通道:tweeted → -ed 提议 tweet → tweet ∈ BASE → 接受。语义从 Layer 2 主动剥离器降级为 Layer 3 的 stem 提议器,行为不变但调用链变保守。
代价与应对
代价:
- ⚠️ APK 包体增加 ~4.4 MB(2 个 JSON 资产)
- ⚠️ 启动期增加 ~30-50ms(Future.wait 并行加载,可被其他 init 任务遮蔽)
- ⚠️ 已入库错 lemma(splinter→splint 等历史脏数据)需独立 SQL 修复任务清理
- ⚠️ AGID 2016 后停维护,新词(podcasting / vlogging / tweeting 等)部分进 Layer 4 fallback
- ⚠️ AGID 数据有少量不理想映射(
trying→trie/during→dure/means→mean),属同形异类多义词需消歧,待 T-B 解决
应对措施:
- 启动期 Future.wait 并行加载,不阻塞 runApp
- 新词覆盖通过定期重跑 build_dict 合并 Wiktionary dump 解决(T-C 季度任务)
- 多义消歧通过
Map<String, List<String>>+ UI 选择解决(T-B 独立任务) - 跨端不变式:CLAUDE.md §跨端 Sync 协议红线 #5e — 资产 SHA256 双端 byte-equal,资产更新 = 双端原子提交。任何一端单方面改资产会立刻破坏 vocabulary.word PK 一致性 → sync 静默丢词
影响范围
必须同步的双端:
- RB (Reading Browser, Tauri/Rust) — commit
9a3e302 - RVH (Reading Vocab Helper, Flutter/Dart) — 本批 commit
- 必须同周发版,否则 vocabulary.word PK 双端不一致
修改/新增:
lib/core/nlp/lemmatizer.dart— 4 层重写(679 → 134 行)lib/core/nlp/lemmatizer_flutter_loader.dart— 新增(rootBundle 加载)lib/main.dart— 启动期并行 preloadbin/phase0_normalize.dart— 双端 byte-equal CSV 跑分入口assets/nlp/base_forms.json+assets/nlp/surface_to_base.json— 新增资产pubspec.yaml— 资产声明- 测试:6 个新 Bug B 单测 + 公共 setUp helper + 90 词回归清单
删除:
lib/core/nlp/data/irregular_forms.dart— 5,651 行 dead code(AGID + WordNet 已覆盖)lemmatizer.dart的_exceptions手工词集 — 约 300 词tools/phase0_normalize/— 迁移到bin/phase0_normalize.dart
关联:
- 实施计划:docs/plans/lemmatizer-dictionary-refactor-rvh.md
- 跨端不变式红线:CLAUDE.md §跨端 Sync 协议红线 #5e
- 验收:双端 phase0_normalize 跑 90 词回归清单 → CSV diff 为空 ✅;52 个 lemmatizer 单测全过 ✅;Bug B 27 词全部 layer="base" ✅;端到端 OCR 实测 30 词无回归 ✅
决策16:CEFR 评估改用 LLM 重评 + 正交字段建模
日期:2026-05-12 / 2026-05-13 状态:已接受 ✅
背景
预装词库(Oxford 5000 + CEFR-J)权威覆盖 A1-B2 + 部分 C1 共 7140 词。剩余 C1/C2 由 wordlist_builder.dart 用 Google web 频率反推:
dart
final inferred = rank < 10000 ? 'C1' : 'C2';频率反推对 C1/C2 估算严重偏离实际难度:
- 低频但简单的词被高估(
aardvark→ C2) - 高频但抽象的词被低估
- 5193 个 freq 反推词中存在大量明显错评
决策
1. LLM 二次评估:用 DeepSeek 对所有 cefrInferred=true 词重新评估 CEFR,输出按"语言学习难度"而非频率分级。
2. CEFR 正交字段建模(v48/v49/v50 三步加列):
primary_cefr_level(TEXT) — 严格 A1-C2 6 值cefr_inferred(BOOLEAN) — 是否为推断值cefr_source(TEXT) — 'oxford' / 'cefr_j' / 'llm' / 'freq' / 'fallback'word_tags(TEXT, JSON array) — 词类型标签(proper_noun / abbreviation / symbol)
3. UI 层透传 "约 X" 标识:cefr_inferred=true 时显示 "约 C2" + 灰化 + tooltip "基于词频/AI 估算"。
理由
为什么 LLM 重评(vs 接受 freq 反推 / vs 找权威数据集):
- EVP(English Vocabulary Profile)商用付费,无法用
- Octanove (CC BY-SA 4.0) SA 传染性,闭源商业化风险
- LLM 重评是当前最佳折中:DeepSeek $0.34 / 5193 词,准确率从 60-65% 提升到 80-85%(基于人工抽样估算)
为什么正交字段建模(vs 把"inferred"塞进 enum):
- 「值」和「可信度」是两个正交维度
- 不要污染 enum:
A1, A2, ..., C2, APPROX_C2会让 SQL/筛选/统计全爆炸 - 字段语义纯粹:cefr_source 只描述"难度等级怎么来的",不描述"是否参考词"
- UI 字符串拼接 ≠ 数据建模:DB 永远存
'C2' + inferred=true,UI 渲染 "约 C2"
为什么加 word_tags 而非合并到 cefr_source:
- 词类型(proper_noun / abbreviation / symbol)跟 CEFR 难度正交
- 多 tag 共存场景(NASA = proper_noun ∧ abbreviation)单字段 enum 无法表达
- JSON array 方便未来扩展 'archaic' / 'medical' 等类型
实际数据效果
LLM 重评 5193 词:
- 90.2% CEFR 改变(C2→B2 / C2→B1 / C1→B2 最常见)
- CEFR 分布从「头重脚轻(C2 占 40%)」修正到「中部最厚(B1+B2 占 53.7%)」
- 956 词识别为参考词(proper_noun 765 / abbreviation 199 / symbol 8)
预装库总数:4953 → 12291(含 LLM 重评 + multi-CEFR 数据修正)。
代价与应对
代价:
- LLM 评估有 ~5-10% 错评率(用户报告
abbreviation → B1等案例) - Schema 增加 3 列(cefr_inferred / cefr_source / word_tags)跨端同步成本
- 维护 pipeline 增加 Step 5(evaluate_cefr)依赖 LLM API
应对:
- 已立项「演进 5:双模型交叉验证」用 Claude Sonnet 二轮 review,预期错评率 ~10% → ~3%
- 跨端 schema 一致性原则(plan 原则 4)+ schema 对照文档显式追溯
- evaluator 加 checkpoint + resume + SIGINT graceful shutdown 保证可重跑
关联
- 实施计划:plans/cefr-llm-evaluation-and-ui-passthrough.md
- Schema 对照:database/cross-end-schema-compatibility.md
- 关键设计原则(plan 原则 1-5):值⊥可信度 / 字段语义纯粹 / UI 拼接 ≠ 数据建模 / 三端 schema 一致 / 主表 vs 参考词表边界
- 实施 commits:bbedeab (v48/v49) / 3722fab (v50 word_tags) / 34248b7 (evaluate_cefr 全量) / 313f501 (预装库 v7) / 87797fa (UI "约 X" + 单 path 重构) / c90f5b1 (wordlist loader CSV bug + multi-CEFR 恢复) / 8a471a6 (Supabase v51 + FK RESTRICT) / affca84 (Edge Function pos.cefr 一致性)
- 验收:5193 词全评估 0 失败;abatement / hong / paramount / arm / above 等典型词数据一致性 ✅;设备 v51 部署 12291 词导入 2.8s ✅
决策17:vocabulary → user 表 FK CASCADE → RESTRICT
日期:2026-05-13 状态:已接受 ✅
背景
notebook_entries.word REFERENCES vocabulary(word) ON DELETE CASCADE 在两类场景下成为 footgun:
- 红线 #6b 实证:sync pull 路径用
INSERT OR REPLACE触发 DELETE+INSERT,CASCADE 删子表 notebook_entries - vocabulary 重建讨论实证(2026-05-13):直接 DROP/TRUNCATE vocabulary 会 CASCADE 清掉 user_notebook_entries (276 行) + user_excluded_words (15 行)
vocabulary 是「字典层数据」,业务上不应该被业务删除。CASCADE 把"父表行删除"的破坏力放大到子表,违反 least surprise。
决策
vocabulary → user 表的 FK 改 ON DELETE RESTRICT:
- 本地 SQLite
notebook_entries.word → vocabulary.word - Supabase
user_notebook_entries.word → vocabulary.word - Supabase
user_excluded_words.word → vocabulary.word
保留 CASCADE 的关系(业务语义合理):
reading_sources.note_id → reading_notes.id(删 note 时清相关 sources)word_sources.notebook_entry_id → notebook_entries.id(删 entry 时清词源)word_sources.reading_source_id → reading_sources.id(删 source 时清词源)
理由
判断原则:
- CASCADE 适用:业务上"父行被删 ⇒ 子行必然失去意义"(如 note→sources 链)
- RESTRICT 适用:父行是"字典/参考数据",业务上不应被随便删(如 vocabulary)
RESTRICT 的副作用:未来真正需要清 vocabulary 行(如过期 backfilled 词)时要先显式清子表引用——这是特性而非 bug,强制开发者意识到子表影响。
代价与应对
代价:
- 跨端 schema 一致性 — RVH + RB + Supabase 三端要同步改
- 真正需要清 vocabulary 行时增加显式步骤
应对:
- v51 一次性彻底改三端 schema(commit
8a471a6) - 加 schema 对照文档(cross-end-schema-compatibility.md)让"三端必须同步改"显式可追溯
- RB 端同步改动需用户在 RB 项目下次会话单独执行(schema 对照文档已列出 checklist)
关联
- 红线 #6b 同源:CLAUDE.md §跨端 Sync 协议红线
- 实施 commit:
8a471a6 - 验收:Supabase 端 SQL
SELECT delete_rule FROM information_schema.referential_constraints WHERE constraint_name LIKE '%_word_fkey'→ 全 RESTRICT ✅
修订 2026-05-21:Supabase 端两条 word_fkey 整体 DROP
背景:v51 把 Supabase vocabulary 从 94063 全量词典清成「共享缓冲池」(仅靠 Edge Function 兜底回填)。12,291 预装词只在双端本地 reading_vocab.db 字节资产里,不在 Supabase vocabulary。RB 端干净测试流程暴露:用户 push notebook/excluded 行时撞 PostgREST 23503 Key is not present in table vocabulary —— 缓冲池里没有这个词。
决策:DROP Supabase 端两条 FK:
user_notebook_entries.word → vocabulary.word→ DROPuser_excluded_words.word → vocabulary.word→ DROP
保留:RVH/RB 本地 SQLite 的 notebook_entries.word → vocabulary.word FK(RESTRICT 不变),设备内一致性仍由本地 FK 把关。
理由:缓冲池模型下 Supabase FK 无业务价值——
- 双端本地 FK 已经守门,设备内不会出现 dangling word
- Supabase vocabulary 是「按需回填的共享缓存」,非权威字典
- 跨端
wordPK 一致性由红线 #5e(双端 byte-equal lemmatizer 资产)保证,不依赖 Supabase FK
实施:
- Supabase Dashboard 已执行 DROP(RB 端 2026-05-21 发起)
- 落地文件:
supabase/migrations/20260521_drop_user_word_fkeys.sql(幂等记录;该目录 2026-08-28 已删,见 git 历史) - RB 跨端日志:
~/reading-browser/docs/cross-end/02-rvh-v51-sync-log.md
决策18:autoSync 的宿主放在 app 根(Navigator 之上),不放页面
日期:2026-08-27 状态:已接受 ✅
背景
autoSyncProvider(60s 轮询 + 登录后首次同步)原本是 Provider.autoDispose, 全仓只有两个 watcher:review_overview_page.dart 与 auth_section.dart(在 ProfilePage 里)。 autoDispose 把一个后台服务的存活期绑在了页面挂载上。
实际存活条件比看上去更刁钻:不是「停在 Review 页」,而是 「Review 页或 ProfilePage 在 Navigator 栈的任意一层」 —— MaterialPageRoute 默认 maintainState: true,从 Review push 进生词本时 Review 仍挂着,定时器照旧活着; 三个 tab 之间的 pushReplacement 才真的拆掉它。
⇒ 同一个生词本页,从 Review 进去同步就跑、从 Me 进去就不跑。冷启动落在 ScanPage, 不主动点进 Review 的话周期性同步一次都不跑;用户在生词本删了词,可以长时间不上行。 2026-08-27 真机验证红线 #6g 时先后观测到「删词 20 秒后上行」与「等 30 分钟纹丝不动」, 两条互相矛盾的现象正是这两条路径的差别。
不是有意的省电策略:review_overview_page.dart 原注释写的就是 「Start auto-sync when signed in (app-wide trigger)」—— 意图一直是 app 级,落点放错了。
决策
autoSyncProvider由Provider.autoDispose改普通Provider;- 唯一宿主 =
main.dart::_AppServicesHost,挂在MaterialApp.builder; - 生命周期由 provider 内部的
AppLifecycleListener管:前台 60s 轮询(频率不变), 切后台停表,回前台恢复并立刻同步一次(10s 防抖); - 前置:
SyncRepositoryImpl.syncNow加防重入守卫(合流语义); stopwordsMergeProvider是同款缺陷(同样只被那两个页面 watch),一并挪进同一个宿主 —— 宿主因此叫_AppServicesHost而不是_AutoSyncHost。
理由
为什么宿主必须在 MaterialApp.builder(= Navigator 之上):home: 和任何页面都会被 tab 切换的 pushReplacement 换掉 —— 那正是本缺陷本身。builder 的子树跨路由切换一直活着。
为什么必须等 Supabase init 完成才 watch(startupInitializedProvider): _MyAppState.build 在 runApp 时就跑,远早于 init。提前 watch 会经 syncRepositoryProvider → authStateProvider 让 AuthRepositoryImpl 在 Supabase.instance 就绪前被构造,并永久缓存成 error 状态 (_heavyInitAndResolve 里早有这条告诫)。本次第一版就踩了,靠重启前 P0 自查捞回来。
为什么防重入是硬前提:旧代码只有一个定时器,两轮物理上撞不上。加了恢复触发后 「定时器这一拍」与「回前台」会真并发,而 syncNow 内部机制都不是为并发设计的 —— watermark 推进(红线 #5d)两轮各写一次 _setLastSyncAt,会把水位往回盖、 或把另一轮尚未落地的行的水位提前盖掉(水位不回头 ⇒ 永久丢行); cloze reconcile(红线 #5g)两轮各自削同一个池子,可能双方都软删、削到 cap 以下。
防重入的合流语义对登出 flush 不成立:signOut 的 flush 之后紧接着 clearLearningDataForUser 清本地,合流到一轮可能已跑过 push 阶段的 sync ⇒ 用户最后的改动永久丢失。方向值得记住 —— 没有守卫时 flush 必然自己跑完整一轮、 反而不会漏,所以这是防重入引入的风险。故 syncNow 带 {bool ensureFresh = false},true 走排队分支,仅该路径使用。 推广判据:凡「之后会销毁本地状态」的 flush 型调用,都不能合流到 in-flight 轮次。
挪 stopwordsMergeProvider 时必须先加空串守卫:currentUserIdProvider 在「未登录」 和「auth 尚未 resolve」时返回的是空串不是 null。挂在页面上时碰巧安全(那两个 widget 只在已登录时渲染);挪到 app 根后宿主在 init 完成那一刻就 watch,authState 很可能 还在 loading ⇒ 会插进 211 行 user_id = '' 的孤儿,且读侧看不见、push 推不走、 clearLearningDataForUser 也删不掉(它匹配 = ? 或 IS NULL,不匹配 '')。 这是挪动新开的暴露面,不是顺手加的防御性代码。
📌 可复用的判据:要不要挂进
_AppServicesHost,看的不是「有没有用autoDispose」,而是「它是不是后台服务 / 登录态 hook」 —— 页面级派生状态用autoDispose挂在页面上是对的。而凡是往 app 根挪的,都要重新检查它对 「未登录 / auth 未 resolve」这两个此前够不到的状态是否安全。
被否掉的两个方案
- 纯事件驱动(删掉轮询,改「写操作后 debounce + 前台恢复」):触发点要撒进各写路径, 漏一个就是一个静默不上行的洞;且拉不到对端的变更(pull 只在恢复时发生)。
- 维持现状 + UI 提示「有 N 条待同步」:把问题推给用户,跨端场景依然别扭。
同时没有加写操作触发 —— 60s 兜底已足够,而每多一个触发点就多一份「漏挂/重复触发」 的维护面。日后若真需要,防重入守卫已经就位。
代价与应对
代价:前台任何页面都在跑 60s 轮询,耗电/流量高于「只在 Review 页跑」。 应对:切后台 0 请求(实测背景两拍一次都没跑);前台特征与「修复前停在 Review 页时」完全一致。
已知代价(不修):手动同步按钮若恰好按在某轮 sync 的执行窗口内(60s 里的 1–2s), 拿到的是那一轮的结果,用户刚刚的改动等下一拍。概率极低且下一拍必然补上, 故不做「排队再跑一轮」。
关联
- 立项与裁决过程:backlog.md「✅ P2 — autoSync 只在 Review 页活着」
- as-built:CHANGELOG.md(v63 条)
- 跨端知会:docs/cross-end/39 §6.4
- 回归:
test/features/sync/auto_sync_trigger_test.dart(9 例,八处注入缺陷实证) stopwordsMergeProvider的立项与裁决:backlog.md「✅ P3」- ⏳ 遗留:
ensureFresh的登出路径未上真机,见 backlog.md「🟢 P3」