28 KiB
试卷练习功能架构设计
本文档是
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 已写好)
// main.dart(本项目实际代码,最后一行)
runApp(const ProviderScope(child: TabApp()));
ProviderScope 是所有 provider 的容器,必须包在最外层。新建项目时这一步不能忘,
否则任何页面 ref.watch 都会报 "ProviderScope is missing"。
0.1.3 上手第二步:定义一个"同步状态"(答案数据,最小完整示例)
// 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 上手第三步:定义一个"异步状态"(题目数据,接口加载场景)
// 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(成功)。页面这样消费:
// 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 区别(最高频考点)
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 新手四个高频误区
- 别用 setState 存全局数据:PageView 会回收滑出的页面,组件销毁数据就丢 → 数据上 provider(答案放 provider 就是这个原因)
- watch 别用在回调里:
onTap: () => ref.watch(x)编译报错 → 用 read - state 要整体替换:
state['k'] = v不会刷新界面 → 用state = {...state, 'k': v} - 别让组件到处 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)
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 步:跑代码生成(每改一次模型字段都要重跑)
dart run build_runner build
生成 answer_record.g.dart(里面是 XxxSchema——数据库的"表结构定义")。改字段后不重跑,编译直接报错。
第 3 步:注册集合(core/storage/database/app_database_io.dart 的 Isar.open)
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)
// 写:必须包在 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.dartimport(仅 native 编译) - 页面层 → 只 import 条件导出入口(如 answer_record_store.dart),这是页面唯一面对的接口
- web 端 →
*_web.dart空实现(写入打印跳过、读取返回空),web 调试自动降级为纯内存
// 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 高频坑清单
- init 必须在 runApp 前 await(main.dart 已写好):否则页面访问集合报 "Missing XxxSchema in Isar.open"
- 写操作必须包 writeTxn:Isar 的事务约束,忘包会抛错
- 改模型字段后必须重跑 build_runner:
dart run build_runner build - 业务页面永远不要 import 模型:web 编译约束(见 0.2.3)
- 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
@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 |
// 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(题型枚举)
enum QuestionType { single(1, '单选题'), multi(2, '多选题'), judge(3, '判断题'), essay(4, '解答题') }
为什么用枚举不用裸 int:枚举自带 label(UI 标签)和 code(存储值),页面不用散落魔法数字;加新题型只改这一个文件。
paper_provider.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(答案数据)
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:
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(纯展示组件)
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 大数据性能
PageView.builder(
controller: _pageController,
itemCount: questions.length, // 100 / 1000 都没关系
allowImplicitScrolling: true, // 预构建相邻页 → 滑动不掉帧
itemBuilder: (context, i) => QuestionCard(key: ValueKey(q.questionId), ...),
)
- builder 惰性构建:只构建视口内 ±1 页,滑出即回收(Vue swiper 需要虚拟列表插件才能做到,Flutter 天生自带)
- allowImplicitScrolling:滑动动画开始前目标页已渲染,快速连滑无白屏
- 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 会销毁页面 |
七、踩坑记录(血泪教训)
- 业务代码别 import Isar 模型:
*.g.dart的 64 位 schema 哈希让 dart2js 编译失败。页面只能通过 store 接口(dynamic)拿数据 - 状态别放组件里:页面销毁就丢。判断标准:数据在页面滑走/关掉后还需要吗?需要就放 provider
- 改 Isar 模型后必须重跑 build_runner:
dart run build_runner build(新版已移除--delete-conflicting-outputs参数) - Riverpod 3.x 的 StateProvider 已移入 legacy:用现代
Notifier/NotifierProvider写法(本项目 PaperSizeNotifier 即范例) - build_runner 重跑后 schema 哈希会变:模型加字段后哈希变化是正常的,不要手改生成代码
八、扩展指南
加一种新题型(如"填空")
question_type.dart:枚举加一项blank(5, '填空题')question_card.dart:switch 加一个 case,写 BlankQuestionViewpaper_provider.dart:MockExamData 的循环里加一种分布- 答案格式遵循 String 约定(填空存答案文本)
上层(provider / store / 交卷)一行不用改——这就是统一 String 格式 + 纯展示组件设计带来的扩展性。
接入真实接口
paper_provider.dart:mock = false,build 里调 DioClient 拉题,ExamQuestion.fromJson解析answer_provider.dart:demoPaperId换成试卷对象传入的 paperId- 交卷处替换模拟提交为真实接口(注意:接口失败不要删本地记录)
九、一句话总结
数据存 Isar(store 收口)、状态住 Riverpod(provider 分层)、组件纯展示(props 进回调出)、页面只桥接(ExamPage 唯一连接点) —— 每一层只做一件事,数据永远单向流动。
这套四层模式是 Flutter 中型应用的通用骨架,后续任何新功能(错题本、收藏、播放记录)都按这个骨架填:建模型 → 写 store → 建 provider → 写组件 → 页面桥接。