Files
App.K12.Student.Miniapp_Flu…/docs/exam_architecture.md
T
2026-09-04 15:22:20 +08:00

606 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 试卷练习功能架构设计
> 本文档是 `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:<Notifier类, 状态类型>,构造函数引用传入
final answerProvider =
NotifierProvider<AnswerNotifier, Map<int, String>>(AnswerNotifier.new);
// ② 写 Notifier 类:状态在这里定义、修改
class AnswerNotifier extends Notifier<Map<int, String>> {
@override
Map<int, String> 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, List<ExamQuestion>>(PaperNotifier.new);
class PaperNotifier extends AsyncNotifier<List<ExamQuestion>> {
@override
Future<List<ExamQuestion>> 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<List<...>>
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<ExamPage> {
@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<ExamQuestion>` | 题目(异步加载) |
| `answerProvider` | NotifierProvider | `Map<int, String>` | 答案(同步读写) |
| `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<dynamic>` 才能双端编译。
**为什么选 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<String>`,接口 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<PaperNotifier, List<ExamQuestion>>(...);
class PaperNotifier extends AsyncNotifier<List<ExamQuestion>> {
static const bool mock = true; // 接口 mock 开关
Future<List<ExamQuestion>> build() async { ... }
}
```
- **为什么 AsyncNotifierProvider**:题目从接口加载是异步的,AsyncValue 自带 loading / error / data 三态,页面 `when()` 分支渲染
- **为什么 mock 用 const bool**:对标 Vue3 内联 mock 体验,一行开关切换本地生成 / 真实接口,热重启即生效
- **为什么题目只放内存不落 Isar**:题目是"会话数据"不是"资产",每次进卷拉最新(可能有勘误);100 题 ≈ 1MB,内存无压力
#### answer_provider.dart(答案数据)
```dart
final answerProvider = NotifierProvider<AnswerNotifier, Map<int, String>>(...);
class AnswerNotifier extends Notifier<Map<int, String>> {
void answer(int questionId, String value) {
state = {...state, questionId: value}; // ① 内存主状态
unawaited(AnswerRecordStore.instance.put(...)); // ② 异步落盘(fire-and-forget)
}
void restore(Map<int, String> 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<questionId, String>` 一个结构通吃所有题型,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 → 写组件 → 页面桥接。