Files
2026-09-04 15:22:20 +08:00

28 KiB
Raw Permalink Blame History

试卷练习功能架构设计

本文档是 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 新手四个高频误区

  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)

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.dart import(仅 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 高频坑清单

  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

@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), ...),
)
  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 → 写组件 → 页面桥接。