# 试卷练习功能架构设计 > 本文档是 `features/exam/` 试卷练习功能(PageView.builder 大数据滑动 + 题型分发 + 续答持久化)的架构说明。 > 面向 Flutter 初学者,重点解释"每一层为什么这么设计"。 > **没学过 Riverpod / Isar 的,先读「零、新手前置」教程,再看架构;** > 阅读前提:了解本项目技术栈(Riverpod / Isar / go_router,见 pubspec.yaml 注释)。 --- ## 零、新手前置:Riverpod 与 Isar 使用教程 > 本章是**工具用法入门**(面向 Flutter 新手),看完能读懂本项目所有 provider 和 store 代码; > 后面一~九章是架构设计。建议阅读顺序:先学本章 → 再看架构 → 最后动手改代码。 > 所有示例都是本项目真实代码,可以直接打开对应文件对照。 ### 0.1 Riverpod 状态管理怎么用 #### 0.1.1 它解决什么问题 Flutter 的界面是 `Widget` 树。数据如果存在组件内部,组件一销毁数据就没了。 Riverpod(本项目 3.3.2,flutter_riverpod 包)把"数据"提升到组件树之外的全局容器(provider)里, 界面通过"订阅"拿到数据,**数据一变界面自动重建**——这就是响应式。 对标 Vue3:`provider ≈ Pinia store`,`ref.watch ≈ storeToRefs / computed`,`ref.read(provider.notifier).方法() ≈ store action`。 #### 0.1.2 上手第一步:包根(main.dart 已写好) ```dart // main.dart(本项目实际代码,最后一行) runApp(const ProviderScope(child: TabApp())); ``` `ProviderScope` 是所有 provider 的容器,必须包在最外层。**新建项目时这一步不能忘**, 否则任何页面 `ref.watch` 都会报 "ProviderScope is missing"。 #### 0.1.3 上手第二步:定义一个"同步状态"(答案数据,最小完整示例) ```dart // answer_provider.dart(本项目实际代码) // ① 声明 provider:,构造函数引用传入 final answerProvider = NotifierProvider>(AnswerNotifier.new); // ② 写 Notifier 类:状态在这里定义、修改 class AnswerNotifier extends Notifier> { @override Map build() => {}; // 初始状态:空 Map void answer(int questionId, String value) { state = {...state, questionId: value}; // ③ 修改:必须整体赋新值! } void clear() => state = {}; // 重置 } ``` 新手必须记住的规则: - `build()` 返回初始状态; - **修改状态永远用 `state = 新值`**,不能 `state.xxx = ...`。Dart 的 Map 是引用类型, 直接改内部元素不会触发通知——所以要 `{...state, ...}` 展开成新 Map 再整体赋值; - Notifier 是 Riverpod 3.x 的**推荐写法**(状态 + 操作方法封装在一个类里)。 老教程里的 `StateProvider` 已移入 legacy,不要学。 #### 0.1.4 上手第三步:定义一个"异步状态"(题目数据,接口加载场景) ```dart // paper_provider.dart(本项目实际代码,mock 分支) final paperProvider = AsyncNotifierProvider>(PaperNotifier.new); class PaperNotifier extends AsyncNotifier> { @override Future> build() async { await Future.delayed(const Duration(milliseconds: 600)); // 模拟接口延迟 return MockExamData.generate(100); } } ``` 和同步版本的区别只有一个:`build()` 返回 `Future`。Riverpod 会自动把过程包装成 `AsyncValue`—— 它自带三态:`loading`(加载中)/ `error`(失败)/ `data`(成功)。页面这样消费: ```dart // exam_page.dart(本项目实际代码,简化) final paperAsync = ref.watch(paperProvider); // AsyncValue> return paperAsync.when( loading: () => const CircularProgressIndicator(), // ① 加载中 → 转圈 error: (e, _) => Text('试卷加载失败:$e'), // ② 失败 → 报错文案 data: (questions) => PageView.builder(...), // ③ 成功 → 出卷 ); ``` **怎么选 Async 还是普通 Notifier**:初始化需要"等一下"(接口 / 数据库查询)→ 用 Async;纯同步内存状态 → 用 Notifier。 #### 0.1.5 页面怎么读:watch / read 区别(最高频考点) ```dart class _ExamPageState extends ConsumerState { @override Widget build(BuildContext context) { final answers = ref.watch(answerProvider); // ① watch:订阅,变化就重建 // ... onAnswer: (v) => ref.read(answerProvider.notifier) // ② read:只调方法,不订阅 .answer(q.questionId, v), } } ``` - `ref.watch(provider)`:**订阅**。状态一变,build 重新执行(界面自动更新)。**只能写在 build 方法里**。 - `ref.read(provider)`:**一次性读取**,不订阅。适合事件回调、initState。 - 改状态:`ref.read(provider.notifier).方法()`(见上)。 回调里写 `ref.watch` 会直接报错("watch 不能在 build 之外使用")——新手最常见的报错之一。 #### 0.1.6 本项目 provider 一览(学完自检清单) | provider | 类型 | 状态 | 用途 | |---|---|---|---| | `paperProvider` | AsyncNotifierProvider | `List` | 题目(异步加载) | | `answerProvider` | NotifierProvider | `Map` | 答案(同步读写) | | `paperSizeProvider` | NotifierProvider | `int` | 生成题数(50/100/200) | | `privacyAcceptedNotifier` | NotifierProvider(core/providers/) | `bool` | 隐私同意状态 | #### 0.1.7 新手四个高频误区 1. **别用 setState 存全局数据**:PageView 会回收滑出的页面,组件销毁数据就丢 → 数据上 provider(答案放 provider 就是这个原因) 2. **watch 别用在回调里**:`onTap: () => ref.watch(x)` 编译报错 → 用 read 3. **state 要整体替换**:`state['k'] = v` 不会刷新界面 → 用 `state = {...state, 'k': v}` 4. **别让组件到处 watch**:组件里 watch 越多越难复用。本项目约定:只在 ExamPage 连接 provider,QuestionCard 收 props / 抛回调(受控组件模式) --- ### 0.2 Isar 本地数据库怎么用 #### 0.2.1 它解决什么问题 Isar 是 Dart 原生的本地 NoSQL 数据库(对标 Vue 的 localStorage,但强类型、毫秒级查询、支持索引和事务)。 存"**跨会话**"的数据——进程杀掉后还要用的:题库、错题、播放记录、答题进度。 本项目存储分三层(各管各的): | 层 | 工具 | 存什么 | |---|---|---| | ① | flutter_secure_storage | Token 等敏感凭据(加密) | | ② | shared_preferences | 用户信息 / 开关等小配置(明文快读) | | ③ | **Isar(AppDatabase)** | 题库 / 错题 / 播放记录 / 答题进度(大批量结构化数据) | **为什么用 isar_community 而不是 isar**:官方 isar 3.1.8 只发在已宕机的私有源(pub.isar-community.dev),装不上; `isar_community`(本项目 3.3.2)是社区在 pub.dev 的维护版,API 与 isar 完全一致。 #### 0.2.2 四个固定步骤:建一个 Isar 功能 以本项目 AnswerRecord(答题进度)为例,完整走一遍: **第 1 步:建模型**(core/storage/models/answer_record.dart) ```dart import 'package:isar_community/isar.dart'; part 'answer_record.g.dart'; // 代码生成文件(第 2 步跑出来) @collection // 注解:告诉生成器"这是个集合" class AnswerRecord { Id id = Isar.autoIncrement; // ⚠ 必须有主键(自动递增) @Index() // 索引:按 paperId 查走索引(毫秒级) int? paperId; int? questionId; String? answer; DateTime? updatedAt; } ``` 三个规则:必须有 `@collection` 注解、必须有 `Id` 主键、必须 `part 'xxx.g.dart'`。 **第 2 步:跑代码生成**(每改一次模型字段都要重跑) ```bash dart run build_runner build ``` 生成 `answer_record.g.dart`(里面是 `XxxSchema`——数据库的"表结构定义")。改字段后不重跑,编译直接报错。 **第 3 步:注册集合**(core/storage/database/app_database_io.dart 的 Isar.open) ```dart isar = await Isar.open( [ QuestionSchema, WrongQuestionSchema, PlayRecordSchema, RemoteVersionEntitySchema, AnswerRecordSchema, // ← 新集合加到这里 ], directory: dir.path, name: 'app_db', inspector: true, // debug 模式启用 Isar Inspector 调试页面(见 0.2.5) ); ``` **第 4 步:写读写代码**(core/storage/stores/answer_record_store_io.dart) ```dart // 写:必须包在 writeTxn 事务里(Isar 约束) await db.isar.writeTxn(() async { await db.answerRecords.put(AnswerRecord()..paperId = 1001); }); // 查:where() 走索引,xxxEqualTo 由 @Index() 字段自动生成 final list = await db.answerRecords .where() .paperIdEqualTo(1001) .findAll(); // 删:条件批量删 await db.answerRecords.where().paperIdEqualTo(1001).deleteAll(); ``` **核心 API 速查**: | 操作 | 代码 | |---|---| | 增 / 改(同 id 自动覆盖) | `writeTxn(() => collection.put(record))` | | 按 id 查 | `collection.get(id)` | | 条件查(走索引) | `collection.where().paperIdEqualTo(x).findAll()` | | 查全部 | `collection.where().findAll()` | | 数量 | `collection.count()` | | 条件批量删 | `collection.where().xxxEqualTo(x).deleteAll()` | #### 0.2.3 web 兼容:为什么必须"条件导出"三件套(本项目最大坑) Isar 生成的 `*.g.dart` 里有一个 64 位 schema 哈希常量(如 -6819722535046815095), JavaScript 最多只能精确表示 2^53 的整数 → **只要业务代码 import 了模型,web 编译必挂** (报 "The integer literal ... can't be represented exactly in JavaScript")。 即使运行时用 kIsWeb 跳过,**编译期**也过不了——因为静态 import 链已经把 .g.dart 拉进编译图了。 解法(本项目约定): - 模型 / 真数据库 → 只被 `*_io.dart` import(仅 native 编译) - 页面层 → 只 import 条件导出入口(如 answer_record_store.dart),这是页面唯一面对的接口 - web 端 → `*_web.dart` 空实现(写入打印跳过、读取返回空),web 调试自动降级为纯内存 ```dart // answer_record_store.dart —— 页面的唯一入口 // if (dart.library.io) 是【编译期】分发:native 编 io 版,web 编 web 版,运行期零开销 export 'answer_record_store_web.dart' // web 端:空实现 if (dart.library.io) 'answer_record_store_io.dart'; // 真机:真 Isar ``` #### 0.2.4 高频坑清单 1. **init 必须在 runApp 前 await**(main.dart 已写好):否则页面访问集合报 "Missing XxxSchema in Isar.open" 2. **写操作必须包 writeTxn**:Isar 的事务约束,忘包会抛错 3. **改模型字段后必须重跑 build_runner**:`dart run build_runner build` 4. **业务页面永远不要 import 模型**:web 编译约束(见 0.2.3) 5. **io 实现里 import app_database_io.dart**(具体实现),不要 import app_database.dart(条件导出入口)—— 分析器会检查所有条件分支,web 分支下入口解析成空实现,io 文件里调 db.isar 会报 undefined getter #### 0.2.5 调试利器:Isar Inspector `app_database_io.dart` 已开启 `inspector: true`。debug 模式跑真机 / 模拟器时, 浏览器打开 `http://localhost:8080` 就能可视化浏览、编辑数据库里的所有集合—— 查"答案到底存没存进去"用它最快,不用打日志。 --- ## 一、概述 ### 1.1 功能背景 原 Vue3 + uniapp 版 App 用 swiper 组件实现"左右滑动切换题目",题目数量大时出现性能瓶颈(swiper 会一次性把所有题目挂载到 DOM)。Flutter 版需要: - 大数组(100+ 题)滑动不卡 - 支持单选 / 多选 / 判断 / 解答四种题型 - 答题中途退出,下次进来能续答(答案持久化) - 交卷后自动判分(客观题) ### 1.2 核心思想 **四层架构 + 单向数据流**:UI 层永远不直接碰数据库,数据永远单向流动。 ``` 数据库(Isar) → store → provider(状态) → UI(组件) → 用户操作 → provider → store → 数据库 ``` 这条规则是整个架构的命脉,理解它就能理解所有文件的分工。 --- ## 二、架构总览 ``` ┌──────────────────────────────────────────────────────────┐ │ ① Demo 层 pages/demo/exam_demo.dart │ │ 入口:选择题目数量(50/100/200) → 跳转试卷页 │ ├──────────────────────────────────────────────────────────┤ │ ② UI 层 features/exam/(只管"怎么显示") │ │ exam_page.dart 试卷页:唯一连接状态的地方 │ │ question_card.dart 题目卡片:纯展示组件 │ │ answer_sheet_dialog.dart 答题卡:题号网格 + 跳题 │ ├──────────────────────────────────────────────────────────┤ │ ③ 状态层 features/exam/(只管"状态放哪、怎么变") │ │ exam_question.dart 题目接口模型(纯 Dart) │ │ question_type.dart 题型枚举 │ │ paper_provider.dart 题目数据(异步加载) │ │ answer_provider.dart 答案数据(同步读写) │ ├──────────────────────────────────────────────────────────┤ │ ④ 数据层 core/storage/(只管"怎么存") │ │ models/answer_record.dart Isar 模型(续答持久化) │ │ answer_record_store*.dart 三件套(条件导出) │ └──────────────────────────────────────────────────────────┘ ``` --- ## 三、逐层详解 ### 3.1 数据层(core/storage/)——"数据怎么存" #### models/answer_record.dart ```dart @collection class AnswerRecord { Id id = Isar.autoIncrement; @Index() int? paperId; // 试卷 id(续答恢复/交卷清理的维度) int? questionId; // 题目 id(对应题目模型的 questionId) String? answer; // 用户答案(统一 String,见 5.1) DateTime? updatedAt; // 作答时间(用于定位"最后作答的题") } ``` **为什么需要它**:答案的主状态在 Riverpod 内存里,进程一杀就丢。本模型按"试卷"维度把每题答案落盘,实现续答。 **为什么 paperId 加 @Index()**:进卷恢复(`where(paperId).equalTo()`)和交卷清理都按它查,走索引毫秒级。 #### answer_record_store.dart 三件套(条件导出) | 文件 | 作用 | |---|---| | `answer_record_store.dart` | 条件导出入口,页面唯一面对的接口 | | `answer_record_store_io.dart` | 真机实现:真 Isar 读写 | | `answer_record_store_web.dart` | web 空实现:打印跳过,不碰 isar | ```dart // answer_record_store.dart —— 页面的唯一入口 export 'answer_record_store_web.dart' // web 端:空实现 if (dart.library.io) 'answer_record_store_io.dart'; // 真机:真 Isar ``` **为什么必须条件导出(本项目踩过的坑)**: Isar 模型生成的 `*.g.dart` 含 64 位 schema 哈希常量,JavaScript 无法精确表示 → **业务代码只要 import Isar 模型,web 编译必挂**。web 版空实现不 import isar,编译通过;真机编译 io 版,功能完整。 **为什么接口用 dynamic 而不是模型类型**:模型 import 了 isar,web 端无法 import 模型,接口层返回 `List` 才能双端编译。 **为什么选 Isar 而不是 shared_preferences**: - 项目存储分层约定(见 app_database_io.dart 注释):secure_storage 存凭据 / shared_preferences 存小配置 / Isar 存业务数据 - 答案记录按 paperId 增删查的结构化需求,shared_preferences 全量重写 JSON 会越写越卡 - 交卷批量清理:`where(paperId).deleteAll()` 一句话搞定 ### 3.2 状态层(features/exam/)——"状态放哪、怎么变" #### exam_question.dart(纯 Dart 接口模型) **为什么不能用 Isar 的 Question 模型**:web 编译约束(见 3.1)。页面层需要强类型题目对象,但 Isar 模型进不了 web 编译图,所以定义纯 Dart 模型,字段与 Isar Question 对应(options 直接存 `List`,接口 JSON 本来就是数组)。 #### question_type.dart(题型枚举) ```dart enum QuestionType { single(1, '单选题'), multi(2, '多选题'), judge(3, '判断题'), essay(4, '解答题') } ``` **为什么用枚举不用裸 int**:枚举自带 label(UI 标签)和 code(存储值),页面不用散落魔法数字;加新题型只改这一个文件。 #### paper_provider.dart(题目数据) ```dart final paperProvider = AsyncNotifierProvider>(...); class PaperNotifier extends AsyncNotifier> { static const bool mock = true; // 接口 mock 开关 Future> build() async { ... } } ``` - **为什么 AsyncNotifierProvider**:题目从接口加载是异步的,AsyncValue 自带 loading / error / data 三态,页面 `when()` 分支渲染 - **为什么 mock 用 const bool**:对标 Vue3 内联 mock 体验,一行开关切换本地生成 / 真实接口,热重启即生效 - **为什么题目只放内存不落 Isar**:题目是"会话数据"不是"资产",每次进卷拉最新(可能有勘误);100 题 ≈ 1MB,内存无压力 #### answer_provider.dart(答案数据) ```dart final answerProvider = NotifierProvider>(...); class AnswerNotifier extends Notifier> { void answer(int questionId, String value) { state = {...state, questionId: value}; // ① 内存主状态 unawaited(AnswerRecordStore.instance.put(...)); // ② 异步落盘(fire-and-forget) } void restore(Map saved) { ... } // 续答恢复 void clear() => state = {}; // 交卷清理 } ``` **为什么答案必须放 provider 而不是组件 setState**:PageView.builder 懒加载,滑出的页面 Element 会被回收,答案存在组件里滑回来就丢了。provider 全局存活。 **为什么题目和答案分开两个 provider**:生命周期不同(题目只读一次 / 答案高频可写)。状态粒度越细,`ref.watch` 重建范围越小。 **为什么 key 用 questionId 而不是本地自增 id**:续答恢复时题目重新拉取也能对上。 ### 3.3 UI 层(features/exam/)——"怎么显示" #### exam_page.dart(试卷页,唯一桥接层) 整个功能只有这一个文件连接 Riverpod: ```dart final paperAsync = ref.watch(paperProvider); // 读题目(三态) final answers = ref.watch(answerProvider); // 读答案(响应式) ... QuestionCard( key: ValueKey(q.questionId), // 按题号隔离,防页面复用串题 question: q, // props 向下 answer: answers[q.questionId], onAnswer: (v) => ref.read(answerProvider.notifier).answer(q.questionId, v), // 事件向上 ) ``` 职责清单: - PageView.builder 组装(大数据滑动,见 5.2) - 续答恢复:`_restoreProgress()` 读 Isar → `restore()` 注入 → `_tryJumpAfterRestore()` 定位到最后作答的题 - 交卷:确认弹窗 → `deleteByPaper` 清理 → `clear()` 清内存 → 判分 - 顶部进度条、底部导航(上一题/答题卡/下一题) **为什么设计成唯一桥接层**:状态流单一,出 bug 只检查一个文件;QuestionCard 不依赖 provider,可复用、可单测。 #### question_card.dart(纯展示组件) ```dart class QuestionCard extends StatelessWidget { const QuestionCard({ required this.question, // 数据来自父级 required this.answer, // 数据来自父级 required this.onAnswer, // 事件抛给父级 }); // 内部按 question.type switch 分发: // single/multi → ChoiceQuestionView(共用选项布局,toggle 逻辑区分) // judge → JudgeQuestionView(对/错两按钮) // essay → EssayQuestionView(TextField + 300ms 防抖) } ``` 对应 Vue3 的 **props 向下 + emit 向上**(受控组件)。好处:可复用(错题重做、试卷回顾)、可单测、状态流单一。 **为什么解答题要 300ms 防抖**:onChanged 每敲一个字都会触发 provider 更新 + Isar 写库,防抖把高频输入合并为一次提交,减少写盘。 #### answer_sheet_dialog.dart(答题卡) 题号网格(5 列)+ 状态色(已答蓝 / 当前绿 / 未答灰)+ 点击跳题(onJump 回调上抛,由 ExamPage 执行 jumpToPage)。 **为什么不直接碰 PageController**:跳转动作通过回调上抛,保持组件解耦,可复用于错题重做等场景。 ### 3.4 Demo 层(pages/demo/) - `exam_demo.dart`:说明卡 + SegmentedButton 选题目数量(50/100/200)+ 开始按钮 - 注册入口在 `features/task/task_page.dart` 的 demo 列表末尾(项目组件封装与 Demo 验证模式) --- ## 四、核心数据流 ### 4.1 进卷(两个异步并行,互不依赖) ``` 用户点"开始练习" ├─ paperProvider.build() 加载题目(mock 600ms 延迟)→ UI 显示 loading └─ ExamPage.initState → _restoreProgress() └─ AnswerRecordStore.findByPaper(1001) ← 查 Isar └─ 有记录? ├─ answerProvider.restore(saved) ← 注入内存 └─ _tryJumpAfterRestore() ← 定位到最后作答的题 └─ jumpToPage(index) (首帧后执行,等 PageView attach) ``` **为什么恢复和加载分开**:两个独立数据源(Isar / 接口),互不阻塞。题目加载失败也能恢复答案,反之也能做题。 **为什么定位要处理"谁先完成"**:restore(Isar 查询)和题目加载(600ms)完成顺序不定。双保险:restore 完成后立即尝试(`_tryJumpAfterRestore`),build 的 data 分支再兜底触发一次,`_jumpToQuestion` 置 null 保证只定位一次。 **为什么用 updatedAt 最新的一条定位**:重复作答会覆盖更新时间,天然表示"最后操作位置"(若希望始终定位最大题号,改为取 questionId 最大即可)。 ### 4.2 答题(一次点击,内存和磁盘各走一条路) ``` 用户点选项 "B" → ChoiceQuestionView._onTap → onAnswer("B") ← 组件抛事件 → ExamPage 回调 → answerProvider.answer(1001, "B") ① 内存:state = {...state, 1001: "B"} → ref.watch 的页面自动重建(答题卡变蓝、进度条 +1) ② 磁盘:AnswerRecordStore.put(...) ← unawaited,不阻塞 UI ``` ### 4.3 退出 什么都不用做。答案在第 4.2 步已实时落盘(增量写),这就是"每次作答落盘"相对"交卷时统一保存"的优势:杀进程也不丢。 ### 4.4 交卷(清理 + 判分) ``` 点"交卷" → 确认弹窗(已答/未答统计) → deleteByPaper(1001) ← 按 paperId 批量删 Isar 记录(防下次恢复旧答案) → answerProvider.clear() ← 清内存 → 判分:客观题比对 user == answer;解答题跳过(需人工批改) ``` **为什么交卷必须删记录**:否则下次进卷会恢复已交卷的答案。 --- ## 五、关键设计决策(为什么这么弄) ### 5.1 答案统一 String 格式 | 题型 | 存储格式 | |---|---| | 单选 | `"A"`(一个字母) | | 多选 | `"A,C"`(字母升序逗号拼接) | | 判断 | `"对"` / `"错"` | | 解答 | 纯文本 | **为什么统一**:`Map` 一个结构通吃所有题型,provider / Isar / 交卷序列化不分叉;判分一行比对;加新题型不改上层。编码/解码工作收敛在 QuestionCard 内部。 ### 5.2 PageView.builder 大数据性能 ```dart PageView.builder( controller: _pageController, itemCount: questions.length, // 100 / 1000 都没关系 allowImplicitScrolling: true, // 预构建相邻页 → 滑动不掉帧 itemBuilder: (context, i) => QuestionCard(key: ValueKey(q.questionId), ...), ) ``` 1. **builder 惰性构建**:只构建视口内 ±1 页,滑出即回收(Vue swiper 需要虚拟列表插件才能做到,Flutter 天生自带) 2. **allowImplicitScrolling**:滑动动画开始前目标页已渲染,快速连滑无白屏 3. **ValueKey(questionId)**:PageView 复用同一位置 Element,不带 key 换题时会显示旧内容 ### 5.3 内存 vs 磁盘的分工 | 数据 | 位置 | 生命周期 | |---|---|---| | 题目列表 | Riverpod 内存 | 进卷拉取,退出即弃 | | 答案主状态 | Riverpod 内存 | 进卷注入,交卷清空 | | 答案持久化 | Isar AnswerRecord | 作答即写,交卷即删 | | 题库/错题本 | Isar(既有集合) | 跨会话资产 | --- ## 六、Vue3 → Flutter 概念对照 | Vue3 | Flutter | 本项目体现 | |---|---|---| | swiper 组件 | `PageView.builder` | 左右滑题,懒加载 | | Pinia store | Riverpod provider | paperProvider / answerProvider | | storeToRefs / computed | `ref.watch(provider)` | 页面响应式读取 | | store action | `ref.read(provider.notifier).方法()` | answer() / restore() / clear() | | 组件 props / emit | 构造参数 / 回调 | QuestionCard 的 answer / onAnswer | | localStorage | Isar(或 shared_preferences) | AnswerRecord 续答落盘 | | Promise + setTimeout mock | `const bool mock` + Future.delayed | PaperNotifier 的 mock 开关 | | 组件内部 data | 不放组件,放 provider | PageView 会销毁页面 | --- ## 七、踩坑记录(血泪教训) 1. **业务代码别 import Isar 模型**:`*.g.dart` 的 64 位 schema 哈希让 dart2js 编译失败。页面只能通过 store 接口(dynamic)拿数据 2. **状态别放组件里**:页面销毁就丢。判断标准:数据在页面滑走/关掉后还需要吗?需要就放 provider 3. **改 Isar 模型后必须重跑 build_runner**:`dart run build_runner build`(新版已移除 `--delete-conflicting-outputs` 参数) 4. **Riverpod 3.x 的 StateProvider 已移入 legacy**:用现代 `Notifier` / `NotifierProvider` 写法(本项目 PaperSizeNotifier 即范例) 5. **build_runner 重跑后 schema 哈希会变**:模型加字段后哈希变化是正常的,不要手改生成代码 --- ## 八、扩展指南 ### 加一种新题型(如"填空") 1. `question_type.dart`:枚举加一项 `blank(5, '填空题')` 2. `question_card.dart`:switch 加一个 case,写 BlankQuestionView 3. `paper_provider.dart`:MockExamData 的循环里加一种分布 4. 答案格式遵循 String 约定(填空存答案文本) 上层(provider / store / 交卷)**一行不用改**——这就是统一 String 格式 + 纯展示组件设计带来的扩展性。 ### 接入真实接口 1. `paper_provider.dart`:`mock = false`,build 里调 DioClient 拉题,`ExamQuestion.fromJson` 解析 2. `answer_provider.dart`:`demoPaperId` 换成试卷对象传入的 paperId 3. 交卷处替换模拟提交为真实接口(注意:接口失败不要删本地记录) --- ## 九、一句话总结 > **数据存 Isar(store 收口)、状态住 Riverpod(provider 分层)、组件纯展示(props 进回调出)、页面只桥接(ExamPage 唯一连接点)** —— 每一层只做一件事,数据永远单向流动。 这套四层模式是 Flutter 中型应用的通用骨架,后续任何新功能(错题本、收藏、播放记录)都按这个骨架填:建模型 → 写 store → 建 provider → 写组件 → 页面桥接。