flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
# 试卷练习功能架构设计
|
||||
|
||||
> 本文档是 `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 → 写组件 → 页面桥接。
|
||||
Reference in New Issue
Block a user