flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
# build_runner 代码生成指南
## 一、是什么
`build_runner` 是 Dart 官方的**代码生成调度器**。它本身不生成任何代码,而是统一调度各个**生成器插件**,根据模型上的注解自动生成 `.g.dart` 文件。
类比:`build_runner` = 工厂流水线调度员,各个生成器插件 = 流水线上的工人。
## 二、当前项目的生成器
| 生成器插件 | 注解 | 生成内容 | 状态 |
|---|---|---|---|
| `isar_community_generator` | `@collection` | Isar 数据库 Schema(增删改查) | ✅ 已配置 |
| `json_serializable` | `@JsonSerializable()` | `fromJson` / `toJson` 方法 | 🔲 待添加 |
一次 `build_runner build` 会**同时调度所有已配置的生成器**,全部 `.g.dart` 一次性生成。
## 三、涉及的模型文件
### Isar 模型(`@collection`)
| 文件 | 说明 |
|---|---|
| `lib/core/storage/models/question.dart` | 题目 |
| `lib/core/storage/models/wrong_question.dart` | 错题 |
| `lib/core/storage/models/answer_record.dart` | 答题记录 |
| `lib/core/storage/models/play_record.dart` | 播放记录 |
| `lib/core/version/app_version_entity.dart` | 版本信息实体 |
### json_serializable 模型(`@JsonSerializable`)
> 待引入后,纯接口 DTO 模型(如 `UserPointRecord`、`OssStsConfig` 等)将使用注解自动生成序列化代码,替代手写 `fromJson`。
## 四、使用命令
```bash
# 生成一次(推荐日常使用)
dart run build_runner build
# 清理旧产物后重新生成(遇到生成代码冲突时使用)
dart run build_runner build --delete-conflicting-outputs
# 监听模式:每次保存文件自动生成(开发阶段推荐)
dart run build_runner watch
```
## 五、什么时候需要跑
| 操作 | 是否需要跑 |
|---|---|
| Isar 模型加了/删了/改了字段 | ✅ 必须,否则编译报错 |
| `@JsonSerializable` 模型改了字段 | ✅ 必须,否则序列化字段对不上 |
| 只改了页面 UI / 业务逻辑 | ❌ 不需要 |
| 只改了 `pubspec.yaml` 依赖 | ❌ 不需要(跑 `flutter pub get` 即可) |
## 六、生成产物说明
- 生成文件命名:`xxx.g.dart`,和模型文件同目录
- 生成文件**必须提交到 Git**(`.gitignore` 里不要排除 `*.g.dart`)
- **不要手动编辑** `.g.dart` 文件,改了也会被下次生成覆盖
## 七、常见问题
### Q: 编译报错 `MissingStubError` 或 `is not a subtype of`
A: 模型字段改了但没跑 build_runner,重新生成即可。
### Q: 报 `AlreadyExistsException` 或 conflicting outputs
A: 加 `--delete-conflicting-outputs` 参数清理后重新生成。
### Q: 生成很慢怎么办
A: 开发阶段用 `watch` 模式,增量生成比每次全量 `build` 快很多。
+114
View File
@@ -0,0 +1,114 @@
# docs/demos —— Demo 学习文档索引
> 本目录为 `lib/pages/demo/` 下 **29 个独立功能模块**逐一配套的学习文档,
> 面向 Flutter 初学者:讲清**实现用法和思想**(为什么这么写),并标注踩坑记录。
**配套文档**:项目整体架构(含 Riverpod / Isar 新手教程)见 [../exam_architecture.md](../exam_architecture.md),
本目录是"每个功能怎么实现"的细节版,两者配合阅读效果最佳。
---
## 一、分类索引(29 个)
### 🧱 基础布局(13)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| align_demo.dart | 对齐与约束:一行代码把一个组件摆到任意位置 | [align_demo.md](align_demo.md) |
| wrap_demo.dart | 流式布局:小标签自动换行,放不下就换一排 | [wrap_demo.md](wrap_demo.md) |
| stack_demo.dart | 层叠布局:角标、遮罩、悬浮按钮全靠它 | [stack_demo.md](stack_demo.md) |
| grid_demo.dart | 网格布局:GridView 三列宫格(九宫格) | [grid_demo.md](grid_demo.md) |
| list_demo.dart | 长列表:ListView.builder 懒加载 + 下拉刷新 | [list_demo.md](list_demo.md) |
| sliver_demo.dart | 可折叠顶栏:SliverAppBar 收起展开 + 吸顶 | [sliver_demo.md](sliver_demo.md) |
| responsive_demo.dart | 响应式:一个页面同时适配手机/平板/桌面 | [responsive_demo.md](responsive_demo.md) |
| text_demo.dart | 文本组件:样式、行高、溢出省略号 | [text_demo.md](text_demo.md) |
| input_demo.dart | 输入框:TextField 全家桶 + 键盘类型 | [input_demo.md](input_demo.md) |
| form_demo.dart | 表单校验:GlobalKey + validator 规则 | [form_demo.md](form_demo.md) |
| theme_demo.dart | 主题:多品牌换肤、全局样式统一管理 | [theme_demo.md](theme_demo.md) |
| seed_palette_demo.dart | 种子色:ColorScheme.fromSeed 一键生成配色 | [seed_palette_demo.md](seed_palette_demo.md) |
| animation_demo.dart | 动画:隐式/显式/路由转场/Hero/CustomPaint | [animation_demo.md](animation_demo.md) |
### 🌐 网络(3)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| dio_demo.dart | 请求封装:DioClient + ApiException 统一错误 | [dio_demo.md](dio_demo.md) |
| dio_pro_demo.dart | 进阶:拦截器/重试/CancelToken/Web 兼容 | [dio_pro_demo.md](dio_pro_demo.md) |
| oss_upload_demo.dart | 阿里云 OSS 直传:STS 临时凭证 + V4 签名 | [oss_upload_demo.md](oss_upload_demo.md) |
### 💾 存储(2)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| isar_watch_demo.dart | 本地库 + 响应式:Isar 增删改查 + watch 自动刷新 | [isar_watch_demo.md](isar_watch_demo.md) |
| wrong_flow_demo.dart | 完整业务链路:接口 + 本地 + watch 三层协作 | [wrong_flow_demo.md](wrong_flow_demo.md) |
### 🧭 路由(4)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| router_demo.dart | 应用入口:路由表注册 + 首页组装 | [router_demo.md](router_demo.md) |
| router_home_page.dart | 三种跳转姿势:push 传参 / await 返回值 / pushNamed | [router_home_page.md](router_home_page.md) |
| detail_page.dart | 参数接收:构造传参 vs 命名路由 arguments | [detail_page.md](detail_page.md) |
| picker_page.dart | 选择器页:pop 带值返回(选性别) | [picker_page.md](picker_page.md) |
### 🧩 组件封装(6)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| note_picker_demo.dart | 多图选择页:加号格 + 缩略图 + 删除角标 | [note_picker_demo.md](note_picker_demo.md) |
| image_demo.dart | 图片四来源 + BoxFit + 错误兜底 | [image_demo.md](image_demo.md) |
| audio_player_demo.dart | 音频控制条组件(HTTP Range 验收) | [audio_player_demo.md](audio_player_demo.md) |
| rich_text_demo.dart | HTML 富文本渲染 + 字号/深色注入 | [rich_text_demo.md](rich_text_demo.md) |
| smooth_sheet_demo.dart | 常驻多停靠底部面板(非模态) | [smooth_sheet_demo.md](smooth_sheet_demo.md) |
| share_demo.dart | 分享五场景:文本/图片/海报截图/存相册 | [share_demo.md](share_demo.md) |
### 🎓 考试(1)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| exam_demo.dart | 试卷练习入口:大数据滑动验证(关联主架构) | [exam_demo.md](exam_demo.md) |
---
## 二、推荐学习路径(按顺序)
```
阶段 1 布局基础(约 1 周)
align → wrap → stack → grid → list → sliver → responsive
阶段 2 文本与输入
text → input → form
阶段 3 主题与动画
seed_palette → theme → animation
阶段 4 路由导航
router → router_home_page → detail_page → picker_page
阶段 5 数据层
isar_watch → wrong_flow
阶段 6 网络层
dio → dio_pro → oss_upload
阶段 7 组件封装(进阶)
image → audio_player → rich_text → smooth_sheet → note_picker → share
阶段 8 综合实战
exam_demo → ../exam_architecture.md(完整架构)
```
> 建议:前 4 个阶段每天 1~2 个 demo,把每份文档"关键代码剖析"手敲一遍;
> 阶段 5 之后开始读 [../exam_architecture.md](../exam_architecture.md)(Riverpod/Isar 前置知识)。
---
## 三、文档公约
每份文档统一六章结构,方便快速定位:
| 章节 | 内容 |
|---|---|
| 一、这个 Demo 解决什么问题 | 业务场景 + 对照 Vue 类比 |
| 二、怎么运行 / 演示内容 | runApp 命令 + 演示点 |
| 三、核心知识点(新手向) | 速查表 + 代码块 + 为什么这么写 |
| 四、关键代码剖析 | 核心代码逐行解释 |
| 五、踩坑记录 | 编号列表,每条 = 一个真实教训 |
| 六、扩展思考 | 关联其他文档 + 真实项目落地建议 |
文档之间用相对链接互引(如 `[list_demo.md](list_demo.md)`),GitHub / IDE 均可直接跳转。
+130
View File
@@ -0,0 +1,130 @@
# 【Demo】Align 对齐布局 + Riverpod 全局用户信息
> 源文件:`lib/pages/demo/align_demo.dart` | 分类:基础布局
> 一句话:把子组件放到父容器任意位置,顺便演示本项目最小化的 Riverpod 状态读取。
---
## 一、这个 Demo 解决什么问题
新手写 UI 时常遇到一个需求:**"把某个卡片放到盒子的左下角 / 右上角 / 居中"**。
`Align` 就是 Flutter 里的"定位器"——给定一个区域,把子组件按 `alignment` 摆到指定位置。
本 Demo 额外演示了 **Riverpod 全局用户信息**的最小用法:
- 页面改成 `ConsumerWidget`,用 `ref.watch(currentUserProvider)` 读全局用户;
- 登录后任何页面都能拿到用户(用户名 / 班级 / 积分),且数据变化时卡片**自动刷新**。
> 对照 Vue3:`Align` 类似 `position: absolute` + 容器相对定位;
> `ref.watch` 类似 Pinia 的响应式 state —— 一处修改,处处生效。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const ProviderScope(
child: MaterialApp(home: AlignDemoPage()),
));
```
页面主体是一个灰色 300×300 盒子,左下角悬浮一张蓝色"用户信息卡"。
登录后卡片内容会跟着变(未登录显示"未登录")。
---
## 三、核心知识点(新手向)
### 1. Flutter 布局的两条铁律
- **父组件给子组件传"约束"(Constraints),子组件在约束范围内决定自己的"大小"**;
- 所有布局问题(溢出、撑不开)都可以回到这条规则上找原因。
### 2. Align 的工作方式
1. Align 会把自身区域**扩大到父组件允许的最大范围**(默认撑满);
2. 然后按 `alignment` 把子组件放到指定位置;
3. 子组件如果不写宽高,Align 取它的**固有尺寸**;写了就按写的来。
```dart
Align(
alignment: Alignment.bottomLeft, // 子组件放到父盒子左下角
child: Container(...), // 用户信息卡片
)
```
### 3. Alignment 坐标系
`Alignment(x, y)` 的 x、y 范围都是 **-1.0 ~ 1.0**:
| 值 | 位置 |
|---|---|
| (-1, -1) | 左上 |
| (0, 0) | 居中 |
| (1, 1) | 右下 |
| `Alignment.centerLeft` | 左中 |
常用常量:`topLeft / topCenter / topRight / centerLeft / center / centerRight / bottomLeft / bottomCenter / bottomRight`。
### 4. Riverpod 最小用法(本项目统一模式)
```dart
class AlignDemoPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(currentUserProvider).value; // 订阅全局用户
// user 为 null = 未登录;登录了就是 CurrentUser 对象
}
}
```
- **`ref.watch` 必须在 `build` 里调用**(声明式依赖,状态变了自动重建);
- **`ref.read` 只能放回调里**(一次性读取,不建立依赖)。
---
## 四、关键代码剖析
```dart
body: Container(
width: 300.w,
height: 300.h,
color: Colors.grey[300],
child: Align(
alignment: Alignment.bottomLeft,
child: Container(
width: 240.w, // 写了宽高 → 由子组件自己决定
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8.w),
),
child: Column(
mainAxisSize: MainAxisSize.min, // 卡片高度跟随内容
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('用户:${user?.userName ?? '未登录'}'),
Text('班级:${user?.className ?? '-'}'),
Text('积分:${user?.userPoints ?? 0}'),
],
),
),
),
),
```
要点:
- `mainAxisSize: MainAxisSize.min` —— Column 不占满父级,高度跟随内容(否则卡片会撑满整个 Align 区域);
- `user?.xxx ?? 默认值` —— 空安全写法:用户为空时显示占位。
---
## 五、踩坑记录
1. **用了 `ref` 的组件必须继承 `ConsumerWidget` / `ConsumerStatefulWidget`**,普通 `StatelessWidget` 拿不到 `ref`(编译报错)。
2. **`ref.watch` 放 build 里,`ref.read` 放回调里**,反了会导致不刷新或性能问题。
3. **加了 `.w/.h/.sp` 的 widget 不能再用 `const`** —— 尺寸是运行期算的,`const` 会编译报错(`const_eval_extension_method`)。
4. `Align` 的 `alignment` 只对**没被 `Positioned` 包裹**的子组件生效(`Positioned` 是 Stack 的专属,见 [stack_demo.md](stack_demo.md))。
---
## 六、扩展思考
- **`Center` 就是 `Align(alignment: Alignment.center)` 的别名**,记住 Align 就记住了 Center;
- 同族定位组件:`Padding`(内边距)、`FractionallySizedBox`(按比例尺寸)、`SizedBox`(固定尺寸);
- 想"按百分比偏移"用 `Alignment(x, y)`;想"按像素偏移"把 Align 换成 `Padding` 或包一层 `Transform.translate`;
- Riverpod 进阶:`ref.watch(provider).value` 是 `AsyncValue` 的取值方式,三态(loading/error/data)用法见 [exam_demo.md](exam_demo.md) 与 `docs/exam_architecture.md` 的"零、新手前置"章节。
+177
View File
@@ -0,0 +1,177 @@
# 【Demo】动画体系(隐式 / 显式 / Hero / CustomPaint)
> 源文件:`lib/pages/demo/animation_demo.dart` | 分类:基础布局
> 一句话:对 CSS 学 Flutter 动画 —— 隐式=transition,显式=keyframes,加上 Hero 转场和 Canvas 自绘。
---
## 一、这个 Demo 解决什么问题
新手面对 Flutter 动画容易懵:组件太多、概念太多。本页把动画体系按"对 CSS"的方式梳理成 4 块:
| 动画类型 | 对标 CSS | 一句话 |
|---|---|---|
| ① 隐式动画 | transition | 属性变了自己补过渡帧,零 controller |
| ② 显式动画 | keyframes | controller 手动驱动,精确控制时序/曲线 |
| ③ Hero 转场 | 无(原生独有) | 页面跳转时元素"飞过去" |
| ④ CustomPaint | canvas | 用 Canvas 画任意图形 |
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: AnimationDemoPage()));
```
1. 点方块:宽/圆角/颜色三属性同时变(AnimatedContainer);
2. 答题互动:选对"弹跳放大",选错"水平抖动"(TweenSequence 分段动画);
3. 点封面:共享元素飞到详情页(Hero);
4. 拖滑块:进度环实时重绘(CustomPainter drawArc)。
---
## 三、核心知识点(新手向)
### 1. 隐式 vs 显式(判断口诀)
- 只需要"A 变成 B 的过渡" → **隐式**(写一行 `duration` 完事);
- 需要"按我的节奏动起来"(回弹、抖动、连续) → **显式 controller**。
```dart
// 隐式:属性变了自动补过渡(duration 必写!漏了就瞬变)
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _boxExpanded ? 200.w : 120.w,
decoration: BoxDecoration(
color: _boxExpanded ? Color(0xFF3B82F6) : Color(0xFFFF9800),
borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r),
),
)
```
### 2. 显式动画五件套
```dart
// ① 页面必须 with SingleTickerProviderStateMixin
class _State extends State<Page> with SingleTickerProviderStateMixin {
// ② controller:驱动一切(vsync 必传,duration 定义时长)
late final AnimationController _answerCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
// ③ TweenSequence:分段动画(先放大再回弹 = 两个 Tween 按权重拼)
late final Animation<double> _correctScale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.25).chain(CurveTween(curve: Curves.easeOut)),
weight: 40,
),
TweenSequenceItem(
tween: Tween(begin: 1.25, end: 1.0).chain(CurveTween(curve: Curves.bounceOut)),
weight: 60,
),
]).animate(_answerCtrl);
// ④ 触发:每次从 0 重新播放
_answerCtrl.forward(from: 0);
// ⑤ 红线:必须 dispose
@override
void dispose() {
_answerCtrl.dispose();
super.dispose();
}
}
```
### 3. AnimatedBuilder 的 child 缓存
```dart
AnimatedBuilder(
animation: _correctScale,
child: body, // 不变的子树用 child 传进来缓存,避免 builder 反复重建
builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child),
)
```
### 4. Hero 共享元素
```dart
// 页面 A
Hero(tag: 'hero-demo-cover', child: Container(width: 120.w, height: 72.h, ...));
// 页面 B(同一个 tag!)
Hero(tag: 'hero-demo-cover', child: Container(width: 280.w, height: 168.h, ...));
```
两个页面里 tag 相同的 Hero,转场时自动共享动画(封面从小图"飞"成大图)。
### 5. CustomPainter 自绘进度环
```dart
class _ProgressRingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final rect = (Offset.zero & size).deflate(stroke / 2); // 内缩一个线宽
// 背景环(画满 360°)
canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint);
// 前景环:起点 -90° = 12 点方向,顺时针画 progress 比例
canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159 * progress, false, fgPaint);
}
@override
bool shouldRepaint(_ProgressRingPainter oldDelegate) {
// 参数变了才重绘(滑块拖动时)
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
```
要点:`drawArc` 的角度单位是弧度,`-π/2` 才是 12 点方向(画布 0° 在 3 点方向)。
---
## 四、关键代码剖析
### 抖动动画(4 段 Tween 拼出"抖三下")
```dart
late final Animation<double> _wrongShake = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0).chain(CurveTween(curve: Curves.easeOut)), weight: 20),
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: 8.0, end: -6.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: -6.0, end: 0.0), weight: 20),
]).animate(_answerCtrl);
```
### 对错反馈的应用(按选择状态包 Transform)
```dart
if (isRight) {
body = AnimatedBuilder(
animation: _correctScale,
child: body,
builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child),
);
}
if (isWrong) {
body = AnimatedBuilder(
animation: _wrongShake,
child: body,
builder: (context, child) =>
Transform.translate(offset: Offset(_wrongShake.value, 0), child: child),
);
}
```
---
## 五、踩坑记录
1. **AnimationController 必须 dispose**,且页面必须 `with SingleTickerProviderStateMixin`(否则报 Ticker 错误);
2. **隐式动画一定要写 `duration`**,漏了就瞬变没有过渡;
3. **AnimatedBuilder 的 builder 里别放不变子树**,用 `child` 参数缓存(否则每帧重建,性能浪费);
4. **shouldRepaint 要正确比较参数**:不重绘或一直重绘都是错;
5. 多个 controller 用 `TickerProviderStateMixin`(多 Ticker),单个用 Single 版;
6. 同屏多个 FloatingActionButton 要 `heroTag: null`(见 list_demo),Hero tag 全局必须唯一,重了会断言崩溃。
---
## 六、扩展思考
- 本项目 exam 答题页的"选对弹跳/选错抖动"就是本页思路的真实落地;
- 显式动画进阶:`AnimationController.repeat()` 无限循环(loading 旋转)、`addStatusListener` 监听完成回调;
- 隐式动画家族:AnimatedOpacity / AnimatedSwitcher(切换动画)/ AnimatedPositioned / AnimatedAlign —— 都是"属性变了自动补帧";
- 性能纪律:动画组件尽量用 Transform(不触发 layout),别动 width/height 频繁重建。
+118
View File
@@ -0,0 +1,118 @@
# 【Demo】音频播放(AudioPlayerCard 控制条组件)
> 源文件:`lib/pages/demo/audio_player_demo.dart` | 分类:组件封装
> 一句话:传一个音频地址,得到一个完整控制条 —— 播放/暂停、进度拖动、倍速、循环。
---
## 一、这个 Demo 解决什么问题
课程详情页里常有"音频小节"。本页演示 `AudioPlayerCard`(`lib/core/components/audio_player_card.dart`)的用法:
```dart
const AudioPlayerCard(
url: _audioUrl, // 网络源(也可以传 asset 本地源)
title: '网络测试音频(约 6 分钟)',
autoPlay: true, // 进页自动播放
)
```
核心思想:**AudioPlayerCard 只是"控制条",不是整页** —— 它设计成可嵌进任意列表/详情页(列表项、课程小节卡片都能放)。
> 对照 Vue:相当于"封装好的 `<audio-player url="...">` 组件",内部用 just_audio 插件实现。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: AudioPlayerDemoPage()));
```
- 进页自动播放(网络流约 6 分钟);
- 体验:播放/暂停、拖动进度(网络流拖动后出现"缓冲中 X%")、倍速切换、循环;
- 切后台自动暂停(组件内部处理,页面无需操心)。
> 代码里注释掉的本地源 `assets/audio/sample-30s.mp3`:30 秒本地音频,seek 秒响应(本地文件跳播无需重新缓冲)。
---
## 三、核心知识点(新手向)
### 1. 网络音频能否"拖动进度",取决于服务器是否支持 HTTP Range
音频拖到中间播放,播放器会向服务器请求**字节范围**(Range 请求)。支持则秒跳,不支持则跳不了:
```
curl -s -D - -o NUL -H "Range: bytes=0-1023" <音频url>
```
| 响应头 | 含义 |
|---|---|
| `HTTP/1.1 206 Partial Content` | ✅ 支持,可拖动 |
| `HTTP/1.1 200 OK` | ❌ 不支持,seek 会被重置回起点 |
> 本项目踩过的坑:samplelib.com 实测**不支持 Range**(返回 200),点进度条后位置被重置回起点;
> 而且第二次打开有系统缓存 → 看似正常(会误导排查方向!)。现用 SoundHelix(curl 实测 206)。
> **接真实课程音频前,务必先 curl 验收服务器的 Range 支持。**
### 2. 网络流 seek 后的"缓冲中 X%"
进度条上的**浅色区域 = 已缓冲部分**;网络流 seek 后会有一次"缓冲中 X%"——这是 ExoPlayer 的正常机制,不是卡死。
本地文件没有这个问题,所以本地 demo 拖动"秒响应"。
### 3. 切后台自动暂停
组件内部监听应用生命周期(AppLifecycleListener),切后台自动暂停——用户听一半切走,回来继续,不用业务页管。
---
## 四、关键代码剖析
```dart
class AudioPlayerDemoPage extends StatelessWidget {
const AudioPlayerDemoPage({super.key});
// 【临时调试】网络源:观察 seek 后进度条的"缓冲区域 + 缓冲中 X%"
static const String _audioUrl =
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('音频播放'), centerTitle: true),
body: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
const AudioPlayerCard( // ← 一行引入,全部能力由组件提供
url: _audioUrl,
title: '网络测试音频(约 6 分钟)',
autoPlay: true,
),
...
],
),
),
);
}
}
```
页面本身是 `StatelessWidget`——**音频状态全部在组件内部**(状态封装是组件化的精髓)。
---
## 五、踩坑记录
1. **服务器不支持 HTTP Range 时 seek 会被重置**,且第二次打开有系统缓存会"假装正常"——验收必须用 curl 看 `206`;
2. AudioPlayerCard 是**控制条组件,不是整页**——别拿它当页面用,它设计来嵌进列表/详情;
3. 网络流 seek 后有"缓冲中 X%"是正常机制(ExoPlayer),浅色区域 = 已缓冲;
4. 本地 asset 源与网络 url 源二选一传参(`asset:` 或 `url:`),别两个都传;
5. 切后台自动暂停是组件内部处理的,业务页不要重复实现。
---
## 六、扩展思考
- 组件内部用 just_audio 插件:想了解播放器原理(音源切换、事件流)直接读 `lib/core/components/audio_player_card.dart`;
- 富文本里的 `<audio>` 标签会自动替换成 AudioPlayerCard,见 [rich_text_demo.md](rich_text_demo.md);
- 进阶"列表连续播放":在 AudioPlayerCard 上加 `setAudioSources` + 上一首/下一首(just_audio 原生支持);
- 真实项目:把 `_audioUrl` 换成课程接口返回的音频地址即可,组件层不用动。
+86
View File
@@ -0,0 +1,86 @@
# 【Demo】详情页:参数接收的两种通道
> 源文件:`lib/pages/demo/detail_page.dart` | 分类:路由
> 一句话:构造参数与 ModalRoute arguments 双通道 —— 谁来了用谁,缺谁兜底。
---
## 一、这个 Demo 解决什么问题
同一个详情页,可能被两种方式打开:
- **方式 1**:`push` 构造传参(`DetailPage(name: '张三', age: 18)`);
- **方式 3**:`pushNamed` 命名路由 + arguments(`{'name': '李四', 'age': 25}`)。
页面要**同时兼容两种入口**:优先用 arguments 里的值,没有就用构造参数兜底。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp());
```
首页分别用方式 1 / 方式 3 跳进来,观察两种取参路径的效果。
---
## 三、核心知识点(新手向)
### 1. 构造参数(类型安全)
```dart
class DetailPage extends StatelessWidget {
const DetailPage({super.key, this.name = '默认', this.age = 0});
final String name;
final int age;
...
}
```
- 带默认值 → 不传参也能打开,不会崩;
- 类型安全:传错类型编译期报错。
### 2. 命名路由 arguments(运行期 Map)
```dart
final args = ModalRoute.of(context)?.settings.arguments;
final argMap = args is Map<String, dynamic> ? args : null;
```
- `ModalRoute.of(context)` 拿到当前路由 → `.settings.arguments` 取出 pushNamed 传的参数;
- arguments 是 dynamic,**必须判空强转**:
- 不是 Map(或没传)→ `argMap` 为 null;
- `argMap?['name']` 取不到时用 `?? name` 回退构造参数。
### 3. 双通道合并取值(本页精髓)
```dart
Text('姓名:${argMap?['name'] ?? name}'),
Text('年龄:${argMap?['age'] ?? age}'),
```
"arguments 优先,构造参数兜底"——两种入口都兼容,缺参不崩。
---
## 四、关键代码剖析
```dart
// 手动 pop(AppBar 的返回箭头也是 pop,效果一样)
OutlinedButton(
onPressed: () => Navigator.pop(context),
child: const Text('返回上一页'),
)
```
---
## 五、踩坑记录
1. `arguments` 是 dynamic,**直接用会崩**(`args['name']` 对非 Map 类型运行期报错)——先 `is Map` 判断;
2. `ModalRoute.of(context)?.settings.arguments` 有 `?`,路由可能为 null;
3. 构造参数给默认值,保证"零参数也能打开页面"(debug 直达页面时很有用);
4. 命名路由注册在 [router_demo.md](router_demo.md) 的 routes 表里。
---
## 六、扩展思考
- 选择器页 pop 带值见 [picker_page.md](picker_page.md);
- 参数多了建议封装成强类型对象(`class DetailArgs`),避免 Map 满天飞;
- 真实项目(go_router)用 `GoRoute` 的 `arguments` 或 `extra` 传对象,原理一致。
+129
View File
@@ -0,0 +1,129 @@
# 【Demo】Dio 网络请求(封装 + 三态 UI)
> 源文件:`lib/pages/demo/dio_demo.dart` | 分类:网络
> 一句话:GET/POST/报错一次跑通,学"统一请求入口 + 加载/成功/失败三态 UI"的页面范式。
---
## 一、这个 Demo 解决什么问题
新手第一次接接口时的完整范式:
1. 请求期间要显示 loading、按钮要禁用(防重复点击);
2. 错误要统一翻译成中文提示(封装层做,页面不碰底层细节);
3. **await 之后 setState 前必须判 mounted**(用户可能已切走页面)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: DioDemoPage()));
```
4 个按钮:
- GET 单条:`PostApi.fetchPost(1)`;
- GET 列表:`PostApi.fetchPosts(userId: 1)`(jsonplaceholder mock);
- POST 新建:`PostApi.createPost(...)`;
- 故意报错:请求不存在的路径,看统一错误提示。
请求/响应日志在控制台打印(`DioClient` 拦截器里 debugPrint 完整 URL)。
---
## 三、核心知识点(新手向)
### 1. 项目的网络分层(重点!)
```
页面(本 Demo)→ PostApi(业务接口,只定义"有哪些接口")
→ DioClient(封装层:baseUrl/超时/拦截器/错误翻译)
→ Dio(底层库)
```
- `PostApi` 只关心业务:`fetchPost(1)`、`fetchPosts(userId: 1)`;
- `DioClient` 统一处理:日志、超时、把 `DioException` 翻译成中文 `ApiException`;
- 页面 **只 catch `ApiException`**,不用关心 Dio 的细节类型。
### 2. 统一请求入口 _run(页面范式)
```dart
Future<void> _run(Future<void> Function() action) async {
setState(() {
_loading = true;
_error = null;
});
try {
await action();
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _error = '请求失败:${e.message}');
} catch (e) {
if (!mounted) return;
setState(() => _error = '未知异常:$e');
} finally {
if (mounted) setState(() => _loading = false); // 无论成败都复位
}
}
```
- **`finally` 里复位 `_loading`**:成功/失败都要恢复,忘记写就会永远转圈;
- 每个具体请求只是往 `_run` 里传一个闭包,**loading 逻辑只写一遍**。
### 3. 三态 UI
```dart
// 加载中:转圈
if (_loading) Center(child: CircularProgressIndicator());
// 失败:红底错误卡
if (_error != null)
Container(
color: const Color(0xFFFDECEA),
child: Text(_error!, style: const TextStyle(color: Color(0xFFD32F2F))),
);
// 成功:结果卡(SelectableText 可复制)
Container(
child: SelectableText(_resultBody.isEmpty ? '(暂无数据)' : _resultBody),
)
```
### 4. 按钮防重
```dart
FilledButton.icon(
onPressed: _loading ? null : _fetchOne, // loading 时禁用
...
)
```
---
## 四、关键代码剖析
```dart
/// GET 单条
Future<void> _fetchOne() async {
await _run(() async {
final post = await PostApi.fetchPost(1);
if (!mounted) return;
setState(() {
_resultTitle = 'GET /posts/1 成功';
_resultBody = 'id: ${post.id}\nuserId: ${post.userId}\ntitle: ${post.title}';
});
});
}
```
---
## 五、踩坑记录
1. **await 之后 setState 前必须 `if (!mounted) return`** —— 异步期间页面可能已销毁,直接 setState 会报错;
2. **catch 顺序**:先 `on ApiException`(业务错误),再 `catch (e)`(兜底未知异常);
3. 别在页面里直接 catch `DioException` —— 封装层的翻译就白做了;
4. loading 复位放 `finally`,不是 try 里 —— 否则异常路径永远转圈;
5. 真实项目 URL 用相对路径(baseUrl 在 DioClient 统一配),别在页面写死域名。
---
## 六、扩展思考
- 生产级能力(取消/进度/重试)见 [dio_pro_demo.md](dio_pro_demo.md);
- 上传文件(FormData + onSendProgress)见 [oss_upload_demo.md](oss_upload_demo.md);
- 页面更进阶的写法:用 Riverpod `AsyncNotifierProvider` 管理请求状态,AsyncValue 自带 loading/error/data 三态(见 exam 架构文档);
- 错误重试策略:`DioClient` 已内置指数退避重试(只对网络层错误),开关见 dio_pro_demo。
+137
View File
@@ -0,0 +1,137 @@
# 【Demo】Dio 生产级能力(进度 / 取消 / 重试)
> 源文件:`lib/pages/demo/dio_pro_demo.dart` | 分类:网络
> 一句话:下载进度条、页面销毁自动取消请求、网络错误自动重试 —— 生产环境三件套。
---
## 一、这个 Demo 解决什么问题
[dio_demo.md](dio_demo.md) 讲了基础请求,本页升级到生产级场景:
1. **下载进度**:`onReceiveProgress` 实时百分比 + 进度条;
2. **请求取消**:用户手动取消 + **页面销毁自动取消**(防泄漏);
3. **失败重试**:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: DioProDemoPage()));
```
- "开始下载":下载约 2MB 的视频文件,进度条动起来;
- "取消下载":手动中断;
- "重试演示":请求不存在的域名 → DNS 解析失败 → 自动重试 3 次(指数退避)→ 抛"网络异常";
- 顶部开关控制是否启用重试,方便对比。
---
## 三、核心知识点(新手向)
### 1. 为什么需要 CancelToken
页面销毁但请求还在跑 → Future 没人接,响应回调还可能在 setState(对已销毁的 State setState 会报错/泄漏)。
做法:
```dart
CancelToken? _token;
Future<void> _startDownload() async {
final token = CancelToken(); // 每个请求新建一个(取消后不可复用!)
_token = token;
try {
await DioClient.instance.get(
_bigUrl,
cancelToken: token,
onReceiveProgress: (received, total) {
if (!mounted || token.isCancelled) return;
setState(() => _progress = total <= 0 ? 0 : received / total);
},
retries: _retryEnabled ? 2 : 0,
);
} on ApiException catch (e) {
// 取消也会走到这里("请求已取消"),统一复位 UI
if (!mounted) return;
setState(() { _log += '✖ ${e.message}\n'; _downloading = false; });
}
}
@override
void dispose() {
_token?.cancel('页面已销毁'); // 页面销毁 → 自动取消在途请求(核心防泄漏点)
super.dispose();
}
```
### 2. 进度回调的三个坑
```dart
onReceiveProgress: (received, total) {
if (!mounted || token.isCancelled) return; // ① 回调频率高,必判 mounted
setState(() => _progress = total <= 0 ? 0 : received / total); // ② total<=0 除零兜底
},
```
- `total <= 0`:响应没有 Content-Length 时 total 为 -1,直接除会算出负数进度;
- 进度回调每秒触发很多次,每次 setState 前判 mounted 是性能与安全双重保险。
### 3. 重试为什么只对"网络层错误"生效
| 错误类型 | 可重试? | 原因 |
|---|---|---|
| connectionError(断网/域名解析失败)、超时、闪断 | ✅ | 网络恢复后大概率成功 |
| 主动取消(用户意图) | ❌ | 用户不要了还重试 = 反人性 |
| badResponse(4xx/5xx) | ❌ | 重试大概率同样失败,POST 还会**重复提交数据** |
这是生产级的取舍,见 `dio_client.dart` 的 `_canRetry`。
### 4. Web 调试的 CORS 坑
- `application/json` 是**非简单头**,跨域 GET 会先发 **OPTIONS 预检**,静态服务器不支持就 405 被拦;
- 下载媒体/图片时用 headers 覆盖成 **text/plain 简单头**,浏览器不再预检直接放行(真机无此问题)。
```dart
headers: const {'Content-Type': 'text/plain'}, // Web 调试必加!
```
---
## 四、关键代码剖析
```dart
// 手动取消
void _cancelDownload() {
_token?.cancel('用户点击取消');
setState(() => _log += '⏹ 已发起取消,等待请求中断...\n');
}
```
取消后请求抛 `DioExceptionType.cancel` → 封装层翻译成 `ApiException('请求已取消')` → 走 onError 分支。
```dart
// 重试演示:Stopwatch 统计总耗时
final stopwatch = Stopwatch()..start();
try {
await DioClient.instance.get('https://no-such-domain-abcxyz.com/api/test',
retries: _retryEnabled ? 3 : 0);
} on ApiException catch (e) {
stopwatch.stop();
setState(() => _log = '✖ ${e.message}\n⏱ 共耗时 ${stopwatch.elapsed.inMilliseconds}ms\n'
'(重试开关:${_retryEnabled ? '开' : '关'})');
}
```
---
## 五、踩坑记录
1. **`onReceiveProgress` 里 setState 前必须判 mounted**(回调频率高,页面销毁后必崩);
2. **`total <= 0` 时进度除零**,要兜底成 0;
3. **CancelToken 一旦取消就不能复用**,每次请求要新建;
4. **取消走 onError 分支**(DioExceptionType.cancel),不是"不走回调"——UI 复位逻辑要放对地方;
5. Web 跨域下载:Content-Type 改 text/plain 绕过预检;
6. 上传进度同理:`onSendProgress: (sent, total)`,笔记图片上传的下一步就接它。
---
## 六、扩展思考
- 重试策略进阶:`RetryInterceptor` 的 retryableExtra / 重试次数按接口配置;
- 下载文件落盘:`dio` 的 `download()` + 进度回调 + `path_provider` 存沙盒;
- 上传场景:[oss_upload_demo.md](oss_upload_demo.md) 的 OSS 直传 PUT 就带 onSendProgress;
- 页面级取消还可以用 `DisposeBag` 模式统一管理多个 token(本项目单 token 够用)。
+109
View File
@@ -0,0 +1,109 @@
# 【Demo】试卷练习入口(大数据滑动验证页)
> 源文件:`lib/pages/demo/exam_demo.dart` | 分类:考试(exam)
> 一句话:试卷练习的入口页 —— 选题目数量(50/100/200)→ 进入真实考试页,验证 PageView 大数据滑动不卡。
---
## 一、这个 Demo 解决什么问题
真实考试页(`lib/features/exam/exam_page.dart`)是**本项目的核心功能**,完整架构见
[exam_architecture.md](../exam_architecture.md)(建议先读)。本页是它的**演示入口**,回答三个问题:
1. **100 道题滑动卡不卡?** —— PageView.builder 懒加载:只构建视口 ±1 页,滑出的页面自动回收;
2. **四种题型怎么共存?** —— 按 `question.type` switch 分发到单选/多选/判断/解答卡片;
3. **答一半退出,进度还在吗?** —— Riverpod(内存主状态)+ Isar(AnswerRecord 备份层),重进自动恢复。
> 对照 Vue:对标 swiper 大数据瓶颈——Vue 会把所有 DOM 挂上,Flutter 的 PageView.builder 只构建看得见的 ±1 页。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ExamDemoPage()));
```
1. **SegmentedButton 选数量**(50/100/200 题)→ 点"开始练习";
2. 快速左右滑动感受流畅度(`allowImplicitScrolling` 预构建相邻页);
3. 答几题 → 退出 → 重新进入 → SnackBar 提示"已恢复上次答题进度(X 题),定位到第 Y 题";
4. 交卷 → 自动判分(客观题)+ 按 paperId 清理本地进度。
---
## 三、核心知识点(新手向)
### 1. 数量选择 = Riverpod 全局状态
```dart
class ExamDemoPage extends ConsumerWidget { // ① 用 ConsumerWidget 才能 ref
...
final size = ref.watch(paperSizeProvider); // ② watch:变化自动重建
SegmentedButton<int>(
segments: [for (final s in _sizes) ButtonSegment(value: s, label: Text('$s 题'))],
selected: {size},
onSelectionChanged: (selection) {
ref.read(paperSizeProvider.notifier).setSize(selection.first); // ③ 回调里 read
},
)
```
- **build 里 `ref.watch`**(响应式重建),**回调里 `ref.read`**(只读一次不订阅)——Riverpod 的黄金规则;
- `paperSizeProvider` 是全局的:入口页选的 50 题,考试页读到的就是 50 题(跨页共享状态)。
### 2. 进入考试页 = 普通跳转
```dart
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ExamPage()),
);
```
考试页通过同一个 `paperSizeProvider` 拿题量——**传参交给状态管理,路由只负责跳转**(对比 [router_home_page.md](router_home_page.md) 的构造传参)。
### 3. 三个架构要点(页面里直接写了卡片)
| 要点 | 说明 |
|---|---|
| PageView.builder 懒加载 | 只构建视口 ±1 页,滑出即回收;`allowImplicitScrolling` 预构建相邻页 → 100 题滑动不掉帧 |
| 题型分发(QuestionCard) | 按 `question.type` switch 到单选/多选/判断/解答视图;组件**纯展示不碰 provider**,ExamPage 唯一桥接 Riverpod |
| 续答持久化(Riverpod + Isar) | 答案主状态在内存,每次作答异步写 AnswerRecord(Isar);退出重进自动恢复,交卷后按 paperId 清理 |
---
## 四、关键代码剖析
```dart
static const List<int> _sizes = [50, 100, 200];
// 入口页数据流:
// SegmentedButton 选择 → paperSizeProvider(全局)
// → ExamPage 同读该 provider 生成 N 道题 → PageView.builder 懒加载渲染
FilledButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ExamPage()),
);
},
icon: const Icon(Icons.play_arrow),
label: Text('开始练习($size 题)'), // 直接展示当前全局题量
)
```
`_ArchPoint` 是纯展示卡片(图标 + 标题 + 描述),无状态无逻辑——学习型页面的"知识卡片"惯例。
---
## 五、踩坑记录
1. **ConsumerWidget 才能用 ref**:普通 StatelessWidget 里没有 `ref` 参数(要自己 `Consumer` 包一层);
2. `ref.read` 只用在**回调**里,build 里用 `ref.watch`——反了会导致不响应或多余重建;
3. 本页是入口,**真正的考试逻辑在 ExamPage**(`lib/features/exam/exam_page.dart`),别在本页找答案判分逻辑;
4. Web 调试时 Isar 为空实现 → 续答自动降级为纯内存(不落盘),这是设计好的,不是 bug;
5. 想快速体验"恢复进度",答几题后**直接退出页面**(系统返回),而不是杀进程——内存态还在。
---
## 六、扩展思考
- **完整架构必读**:[exam_architecture.md](../exam_architecture.md)(零、新手前置 → 一到九章,含 Riverpod/Isar 教程);
- 题干是 HTML 富文本,渲染方式见 [rich_text_demo.md](rich_text_demo.md);
- 题目/答案的本地持久化模式(Store + watch)见 [isar_watch_demo.md](isar_watch_demo.md);
- 答题记录的"接口 + 本地"双通道思路见 [wrong_flow_demo.md](wrong_flow_demo.md)。
+186
View File
@@ -0,0 +1,186 @@
# 【Demo】Form 表单校验(7 个知识点)
> 源文件:`lib/pages/demo/form_demo.dart` | 分类:基础布局
> 一句话:注册表单校验的完整范式 —— validator、时机、跨字段对比、协议勾选、两段式校验。
---
## 一、这个 Demo 解决什么问题
做注册/登录表单时新手最头疼的几件事,本页一次讲全:
必填/长度/正则/密码强度校验、确认密码对比、协议勾选校验、
**validator 不能 await** 时怎么做异步查重、防重复提交、重置表单。
### 和 Vue 的对照(关键差异)
| Vue | Flutter |
|---|---|
| v-model 双向绑定 + rules 规则对象 | TextEditingController + 每个字段自带 `validator` 函数 |
| `validate()`(async-validator) | `formKey.currentState.validate()` 统一触发 |
**Flutter 的 validator 是同步函数,返回 `String?`**:
- 返回 `null` → 校验通过;
- 返回字符串 → 错误文本(自动显示在输入框下方红字)。
没有"规则对象",每个字段的规则就写在它自己的 validator 里,**就近维护**。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: FormDemoPage()));
```
5 个输入框(用户名/手机号/邮箱/密码/确认密码)+ 协议勾选 + 提交/重置:
- 不填就提交 → 红字提示;填错即时提示(onUserInteraction);
- 提交通过 → 模拟 1 秒异步查重 → "注册成功";
- 重置 → 清空所有输入和校验状态。
---
## 三、核心知识点(新手向)
### 1. Form 的骨架
```dart
final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); // Form 的唯一钥匙
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // 校验时机
child: Column(children: [ TextFormField(validator: ...), ... ]),
)
// 提交 / 重置都靠这把钥匙
_formKey.currentState!.validate();
_formKey.currentState?.reset();
```
### 2. 三种校验时机(AutovalidateMode)
| 模式 | 行为 | 建议 |
|---|---|---|
| `disabled`(默认) | 只在点提交 validate() 时校验 | 简单表单 |
| `always` | build 时立刻校验 | **慎用**:空表单满屏红字 |
| `onUserInteraction` | 用户碰过的字段才即时校验 | **推荐** |
### 3. validator 写法范式
```dart
// 必填 + 长度
String? _validateName(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入用户名';
if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符';
return null;
}
// 正则全匹配:^...$ 必须写!不然 11 位之后还能续数字
String? _validatePhone(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入手机号';
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(s)) return '手机号格式不正确';
return null;
}
// 跨字段对比:validator 里读另一个 controller
String? _validatePwd2(String? v) {
if (v == null || v.isEmpty) return '请再次输入密码';
if (v != _pwdC.text) return '两次密码不一致';
return null;
}
```
### 4. 两段式校验(重点!validator 不能 await)
```dart
// ① 同步校验:所有 validator 逐个执行
if (!_formKey.currentState!.validate()) {
_toast('有字段未通过校验,请检查红字提示');
return;
}
// ② 同步通过 → 再单独做异步业务校验(查重/验证码)
setState(() => _submitting = true);
await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求
if (!mounted) return; // 异步后必须判 mounted 才能用 context
```
### 5. 协议勾选:FormField\<bool> 校验"非输入型"字段
```dart
FormField<bool>(
initialValue: _agreed,
validator: (v) => (v ?? false) ? null : '请先勾选同意协议',
builder: (field) {
return Column(children: [
InkWell(
onTap: () {
field.didChange(!(field.value ?? false)); // 更新 FormField 自身
setState(() => _agreed = field.value ?? false); // 同步外部镜像
},
child: ...,
),
// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染!
if (field.hasError)
Text(field.errorText!, style: const TextStyle(color: Colors.red)),
]);
},
)
```
### 6. 防重复提交 + 重置
```dart
// 提交中按钮禁用(onPressed: null 自动置灰 + 换 loading 转圈)
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting ? CircularProgressIndicator() : const Text('提交'),
)
// 重置:reset() 只清校验错误和 FormField 状态,不碰 controller.text!
void _reset() {
_formKey.currentState?.reset();
_nameC.clear(); // 清空输入要手动 clear()
...
}
```
---
## 四、关键代码剖析
通用输入框抽取(本页把 label/hint/图标/校验规则全部参数化,避免 5 个输入框复制粘贴):
```dart
Widget _buildField({
required String label,
required TextEditingController controller,
required String? Function(String?) validator,
TextInputType? keyboardType,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
),
);
}
```
---
## 五、踩坑记录
1. **`formState.reset()` 不碰 `controller.text`** —— 清空输入必须手动 `clear()`;
2. **validator 是同步函数,不能 await** —— 异步校验(查重/验证码)放 validate() 通过之后单独做;
3. **正则要 `^...$` 全匹配**,否则 `RegExp(r'1[3-9]\d{9}')` 能匹配"123456789012345";
4. 所有 controller 都要 dispose(本页 5 个);
5. 加了 `.w/.h/.sp` 的 widget 不能 `const`;
6. 异步(await)之后用 context 必须先 `if (!mounted) return`。
---
## 六、扩展思考
- 校验规则多了以后,可以把 validator 抽成独立函数文件(如 `lib/core/validators/`),表单只负责组装;
- 真实注册流程:本页"模拟查重"处换成 `DioClient` 请求(见 [dio_demo.md](dio_demo.md));
- 密码强度可以升级为正则组合:`(?=.*[A-Za-z])(?=.*\d)` 就是"必须同时含字母和数字"的零宽断言写法;
- 表单 + 状态管理:把 controller 和提交逻辑挪进 Riverpod `NotifierProvider`,页面只剩纯 UI(见架构文档的 ExamPage 分层思想)。
+92
View File
@@ -0,0 +1,92 @@
# 【Demo】GridView 网格布局(三种构造)
> 源文件:`lib/pages/demo/grid_demo.dart` | 分类:基础布局
> 一句话:九宫格/相册/功能宫格都靠它,三种构造方式一次看懂。
---
## 一、这个 Demo 解决什么问题
"功能宫格(4×2 图标)"、"相册缩略图(3 列)"、"课程卡片墙"这类**多列等宽网格**布局。
GridView 就是 Flutter 的网格容器,支持**懒加载**,大量数据不卡。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: GridDemoPage()));
```
一屏并排演示三种构造:
1. `GridView.count`:固定 3 列,写死 children;
2. `GridView.builder`:4 列,`itemCount: 100` 懒加载;
3. `GridView.extent`:每项最多 80 宽,**屏幕越宽列数自动变多**。
---
## 三、核心知识点(新手向)
### 1. 三个常用构造怎么选
| 构造 | 特点 | 适用 |
|---|---|---|
| `GridView.count` | 固定列数,children 一次性全创建 | 少量固定网格(< 30) |
| `GridView.builder` | 懒加载,`itemCount` + `itemBuilder` | **大量数据首选**(100+ 条) |
| `GridView.extent` | 按"每项最大宽度"自适应列数 | 响应式场景(Pad 变列数) |
### 2. 核心参数
```dart
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, // 列数
mainAxisSpacing: 8.w, // 主轴(上下行)间距
crossAxisSpacing: 8.w, // 交叉轴(左右列)间距
childAspectRatio: 1, // 每项 宽:高 比例,默认 1 = 正方形
),
itemCount: 100,
itemBuilder: (context, index) => _GridItem(index: index),
)
```
### 3. 重点记忆:GridView 的本质
**GridView = CustomScrollView + 1 个 SliverGrid**(框架内部自动包的简化套餐)。
- 页面只有网格 → `GridView.builder` 直接用;
- 页面要混排(折叠 AppBar + 网格 + 列表)→ 用 `CustomScrollView + slivers`(见 [sliver_demo.md](sliver_demo.md))。
---
## 四、关键代码剖析
```dart
// Demo3:extent 自适应列数
GridView.extent(
maxCrossAxisExtent: 80.w, // 每项最多 80 宽 → 屏幕宽时自动多列
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
childAspectRatio: 1,
children: [for (int i = 0; i < 12; i++) _GridItem(index: i)],
)
```
> 响应式网格更精细的做法见 [responsive_demo.md](responsive_demo.md):
> 用 `LayoutBuilder` 拿真实宽度,`(width / 280).floor().clamp(1, 4)` 算列数。
---
## 五、踩坑记录
1. **GridView 放 Column 里必须 SizedBox 定高或 Expanded**,否则报
`Vertical viewport was given unbounded height`(和 ListView 同一个坑)。
2. **`childAspectRatio` 太小(如 0.5)时,格子内文字会被挤压溢出** —— 调大比例或缩小字号。
3. 本页外层 `SingleChildScrollView` 只是"展示台"(为了并排演示 3 种构造),
真实网格页**别学这个外层** —— 网格自己就能滚。
4. 想"网格 + 下拉刷新",用 `RefreshIndicator` 包 GridView(写法同 [list_demo.md](list_demo.md) 的列表刷新)。
---
## 六、扩展思考
- **`SliverGridDelegateWithMaxCrossAxisExtent`**:同 extent 效果的另一种 delegate 写法;
- 相册九宫格真实案例见 [note_picker_demo.md](note_picker_demo.md):
GridView + "加号格" + 右上角删除角标(Stack 组合),多一格用 `itemCount: _images.length + 1`;
- 性能提示:builder 系列(builder/extent.builder)是懒加载,`count` 不是 —— 大列表务必 builder。
+212
View File
@@ -0,0 +1,212 @@
# 【Demo】Image 图片组件(四种来源 + BoxFit + 缓存 + 错误兜底)
> 源文件:`lib/pages/demo/image_demo.dart` | 分类:组件封装
> 一句话:网络图(带缓存)/ 本地资源图 / 圆角图 / 圆形头像 -- 图片加载的四个必会姿势。
---
## 一、这个 Demo 解决什么问题
图片是 App 最高频的组件,但新手常踩:加载失败白屏、图片变形、圆角/圆形不会切、网络图每次重下载不缓存。
本页用四个区块讲清:
| 区块 | 用法 | 知识点 |
|---|---|---|
| ① 网络图片(带缓存) | `CachedNetworkImage` | `BoxFit.cover` + `progressIndicatorBuilder` 下载进度 + `errorWidget` 兜底 |
| ② 本地资源图 | `Image.asset` | 必须在 pubspec.yaml 注册 assets |
| ③ 圆角网络图 | `ClipRRect` 包 `CachedNetworkImage` | 图片本身没变,只是画出来带圆角 |
| ④ 圆形头像 | `CircleAvatar` + `CachedNetworkImageProvider` | `backgroundImage` 走缓存 + 失败时显示首字母 |
> 对照 Vue:`<img src>` 的 Flutter 版本,但多了"裁切模式"、"磁盘缓存"和"错误兜底"这三个必须处理的点。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ImageDemoPage()));
```
- ① 首次加载显示下载进度条;再次进入本页(或同 URL 出现在列表页)秒出 -- 走本地缓存,不重新下载;
- ② 本地 logo 图等比完整显示;
- ③ 圆角大图(radius 60 接近胶囊),加载中转圈;
- ④ 圆形头像:加载失败时显示"头"字兜底。
- 断网/改错地址 -> 所有网络图都显示"图片加载失败"占位图(不白屏)。
---
## 三、核心知识点(新手向)
### 1. 图片来源的几种方式
```dart
CachedNetworkImage(imageUrl: url) // 网络图片 + 本地缓存(生产推荐)
CachedNetworkImageProvider(url) // 同上,但返回 ImageProvider(头像场景)
Image.network(url) // 网络图片(无缓存,每次重下载,仅作对比)
Image.asset('assets/...') // 本地资源(先注册 pubspec.yaml)
Image.file(File(...)) // 本地磁盘文件(需 path_provider,本页不演示)
Image.memory(bytes) // 内存字节流(base64 等,见 share_demo 截图预览)
```
### 2. 为什么用 CachedNetworkImage 而不是 Image.network
`Image.network` 每次都重新发 HTTP 请求下载图片,没缓存、列表页性能差、流量浪费。
`cached_network_image`(baseflow 维护,6.9k likes)底层用 `flutter_cache_manager` 把图片写入
本地缓存目录,二次加载直接读本地文件,对标 Web 端浏览器 HTTP 缓存。它还提供三段式回调,
比 `Image.network` 的 `loadingBuilder`(依赖 `ImageChunkEvent`,常常拿不到进度)更可靠:
```dart
CachedNetworkImage(
imageUrl: _netUrl,
fit: BoxFit.cover,
// 加载中占位 / 下载进度(两者二选一,progressIndicatorBuilder 更直观)
progressIndicatorBuilder: (context, url, downloadProgress) =>
CircularProgressIndicator(value: downloadProgress.progress),
// 失败兜底:断网/404/格式错 -> 占位图,不白屏
errorWidget: (context, url, error) => _buildErrorBox(),
)
```
> 注意:`placeholder` 和 `progressIndicatorBuilder` **二选一**,后者会覆盖前者。
> `downloadProgress.progress` 是 `double?`(总大小未知时为 null),需判空再赋给 `value`。
### 2.1. 新增依赖后必须 Hot Restart(原生端常见“假死 loading”根因)
现象:你改了 `pubspec.yaml`、新增插件后,只点 Hot Reload,Android/iOS 模拟器里可能出现
`CachedNetworkImage` 一直处于 loading(不进 errorWidget、不出图);但完整重启后恢复正常。
原因:原生插件注册发生在启动阶段(GeneratedPluginRegistrant),Hot Reload 不会重新执行注册流程。
解决:停止运行 → 重新 `flutter run` / Hot Restart 一次。
### 3. 头像场景用 CachedNetworkImageProvider
```dart
CircleAvatar(
backgroundImage: CachedNetworkImageProvider(_netUrl), // 同样走缓存
onBackgroundImageError: (error, stackTrace) {}, // 失败不崩
child: Text('头'), // 背景加载失败时显示首字母
)
```
`CachedNetworkImage` 是 Widget,适合直接渲染;`CachedNetworkImageProvider` 是 ImageProvider,
适合塞给 `CircleAvatar.backgroundImage`、`BoxDecoration.image` 等需要 ImageProvider 的场景。
### 4. BoxFit 裁切模式速记
| 值 | 行为 | 用途 |
|---|---|---|
| `cover` | 等比缩放填满并**裁掉多余** | 头像/封面(最常用) |
| `contain` | 等比缩放**完整显示**(有留白) | logo、二维码 |
| `fill` | **拉伸填满**(会变形) | 少用 |
| `fitWidth` / `fitHeight` | 按宽/高撑满 | 特殊场景 |
### 5. 错误兜底:不白屏
```dart
CachedNetworkImage(
imageUrl: _netUrl,
width: 300.w,
height: 180.h,
fit: BoxFit.cover,
errorWidget: (context, url, error) =>
_buildErrorBox(width: 300.w, height: 180.h), // 断网/404/格式错 -> 占位图
)
```
Flutter 3.x 网络图**务必给 `errorWidget`**,否则加载失败抛异常白屏。
占位图 `_buildErrorBox` 接收和原图一样的宽高 -> 布局不跳变(加载中->失败,UI 不抖动)。
### 6. 圆角 ≠ 图片属性,是"裁剪"组件
```dart
ClipRRect(
borderRadius: BorderRadius.circular(60.r), // 裁剪器
child: CachedNetworkImage(...), // 图片本身没变
)
```
---
## 四、关键代码剖析
```dart
class ImageDemoPage extends StatelessWidget {
const ImageDemoPage({super.key});
static const String _netUrl =
'https://hx.23544.com/StaticResource/2026-07-29/ranking3Position1785292048369.png';
/// 加载中占位:progress 为 null 转圈,非 null 显示百分比进度条
Widget _buildLoadingBox({
required double width,
required double height,
required double? progress,
}) {
return Container(
width: width,
height: height,
color: const Color(0xFFF0F2F5),
child: Center(
child: progress == null
? SizedBox(
width: 24.w,
height: 24.w,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24.w,
height: 24.w,
child: CircularProgressIndicator(
value: progress, strokeWidth: 2),
),
SizedBox(height: 8.h),
Text('${(progress * 100).toInt()}%',
style: TextStyle(fontSize: 12.sp, color: Color(0xFF888888))),
],
),
),
);
}
/// 统一的加载失败占位图(接收和原图一样的宽高 -> 布局不跳变)
Widget _buildErrorBox({required double width, required double height}) {
return Container(
width: width,
height: height,
color: const Color(0xFFF0F2F5),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image_outlined, size: 40.w, color: Color(0xFFB0B4BC)),
SizedBox(height: 8.h),
Text('图片加载失败', style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888))),
],
),
),
);
}
}
```
- `progressIndicatorBuilder` 用 `downloadProgress.progress` 驱动进度条,二次加载命中缓存时直接跳过该回调(秒出);
- 占位图接收和原图一样的宽高 -> 加载中 -> 成功/失败,UI 不抖动。
---
## 五、踩坑记录
1. **网络图必须 errorWidget/errorBuilder**:加载失败(断网/404/格式错)不给兜底就白屏;
`CachedNetworkImage` 用 `errorWidget`,`Image.network` 用 `errorBuilder`;
2. **新增插件后必须 Hot Restart**:只 Hot Reload 可能导致原生插件未重新注册,表现为一直 loading;
3. **Image.asset 必须先注册**:`pubspec.yaml` 的 `assets:` 列表里没有的路径直接运行期报错;
4. Android **release 包要 INTERNET 权限**(`AndroidManifest.xml` 已加)--debug 默认有,release 没有会黑图;
5. 图片"变形"先查 `fit`:忘了写默认是 `fill` 拉伸,封面图要用 `cover`;
6. 圆角用 `ClipRRect` 包,**不是**给图片加 borderRadius(Image 没有这个属性,Container 才有);
7. `placeholder` 与 `progressIndicatorBuilder` **二选一**:同时写后者生效,前者被忽略;
`downloadProgress.progress` 是 `double?`,总大小未知时为 null,直接塞给 `CircularProgressIndicator.value` 前要判空;
8. **缓存命中后不再回调进度**:二次加载直接读本地缓存,`progressIndicatorBuilder`/`placeholder` 都不会触发,
表现为"秒出" -- 这正是缓存生效的标志,不是没生效;
9. `Image.file` 在 Web 编译不过(`dart:io`),跨端项目用 `Image.memory` 替代(见 [note_picker_demo.md](note_picker_demo.md));
10. `cached_network_image` 在 **Web 端缓存能力有限**(仅内存缓存,刷新即失效),原生端(Android/iOS)走磁盘缓存。
---
## 六、扩展思考
- 多图选择页的缩略图渲染(原生/Web 双端)见 [note_picker_demo.md](note_picker_demo.md);
- 截图字节流用 `Image.memory` 预览见 [share_demo.md](share_demo.md);
- 富文本里的 `<img>` 渲染见 [rich_text_demo.md](rich_text_demo.md);
- 图片墙布局用 [grid_demo.md](grid_demo.md) 的 GridView;网络图片预览(双指缩放)用 `photo_view`。
+109
View File
@@ -0,0 +1,109 @@
# 【Demo】TextField 输入框(登录表单)
> 源文件:`lib/pages/demo/input_demo.dart` | 分类:基础布局
> 一句话:输入框 + 密码"小眼睛"切换 —— 表单最基础的零件。
---
## 一、这个 Demo 解决什么问题
做一个最简单的登录表单:手机号输入框 + 密码输入框(可切换明文/密文)。
核心是搞懂 **TextEditingController** 的用法——Flutter 里输入框不叫"双向绑定",
而是"控制器持有值,代码里随时读写"。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: InputDemoPage()));
```
- 手机号框:弹数字键盘(`keyboardType: TextInputType.phone`);
- 密码框:右侧眼睛图标切换密文/明文;
- 点"登录":控制台打印两个输入框的内容。
---
## 三、核心知识点(新手向)
### 1. 对照 Vue 的 v-model
| Vue | Flutter |
|---|---|
| `v-model="phone"` | `TextEditingController` + `controller.text` |
| 框架自动同步 | 手动 `controller.text` 读写 |
Flutter 没有隐式的双向绑定:`controller.text` 改了就同步到输入框,
输入框内容变了 `controller.text` 也会读到 —— 但**没有"响应式"**:
想根据输入内容实时刷新 UI(如"输入满 11 位才能点登录"),要自己 `addListener` 或 `setState`。
### 2. TextEditingController 生命周期(红线)
```dart
final TextEditingController _userCtrl = TextEditingController();
@override
void dispose() {
_userCtrl.dispose(); // 必须在 dispose 释放,防内存泄漏
super.dispose();
}
```
> 凡是用 `Controller` 命名的东西(Text/Scroll/Animation/Tab),基本都要 dispose。
### 3. 密码眼睛切换
```dart
bool _showPwd = false;
TextField(
controller: _pwdCtrl,
obscureText: !_showPwd, // 密文/明文由状态决定
decoration: InputDecoration(
labelText: "密码",
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showPwd ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _showPwd = !_showPwd),
),
),
)
```
### 4. InputDecoration 常用参数
- `labelText`:浮动标签(聚焦时缩小浮到边框上);
- `hintText`:灰色占位提示;
- `prefixIcon`:左侧图标;`suffixIcon`:右侧(按钮/图标);
- `border: OutlineInputBorder()`:带边框的圆角样式(默认是下划线)。
---
## 四、关键代码剖析
```dart
// 读取输入内容(点登录时)
String user = _userCtrl.text;
String pwd = _pwdCtrl.text;
if (kDebugMode) {
print("账号:$user,密码:$pwd"); // kDebugMode:只在 debug 包打印
}
```
要点:
- `kDebugMode`(来自 `package:flutter/foundation.dart`)保证打印不会出现在 release 包;
- 真实项目里读取后要走校验(见 [form_demo.md](form_demo.md))和登录接口(见 [dio_demo.md](dio_demo.md))。
---
## 五、踩坑记录
1. **Scaffold 没有 `padding` 参数!** 外间距要写在 body 的 `Padding` 上(新手必踩)。
2. `controller` 忘了 dispose → 内存泄漏(Flutter 分析器会提示)。
3. `suffixIcon` 用 `IconButton` 而不要用普通 `Icon` 包 `GestureDetector` —— IconButton 自带点击区域和涟漪反馈。
4. 想"输入即校验"别自己写监听,直接上 Form(见 form_demo)。
---
## 六、扩展思考
- 进阶校验:`TextFormField` + `validator`([form_demo.md](form_demo.md))—— 本项目表单校验全家桶;
- 真实登录页还要:防重复提交、加载动画、错误提示、密码安全存储(本项目用 flutter_secure_storage,见架构文档存储层);
- 输入法控制:`textInputAction: TextInputAction.next` 让回车键变成"下一项",最后一项变"完成"。
+119
View File
@@ -0,0 +1,119 @@
# 【Demo】Isar watch 响应式(错题本)
> 源文件:`lib/pages/demo/isar_watch_demo.dart` | 分类:存储
> 一句话:主动查库是"浅水区",`watch()` 订阅数据库是"深水区" —— 数据一变列表自动刷新。
---
## 一、这个 Demo 解决什么问题
本地数据库(Isar)的基本用法是 `findAll()` **主动查库**:数据变了要自己再查/刷新。
但页面要的是"数据库一变,UI 自动更新"——这就是 **watch 响应式**:
`watch()` 订阅数据库,写入/删除事件**自动推送**,配合 `StreamBuilder` 自动重建列表。
> 对照前端:watch ≈ 前端框架的"数据源订阅"——UI 直接订阅数据库,
> 写入和展示彻底解耦,不用手动通知刷新。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: IsarWatchDemoPage()));
```
- 上方:输入题干 → "加入错题"(写入 Isar);
- 下方:`watchAll()` 订阅的列表 **无需任何刷新** 自动更新(`fireImmediately` 首帧先出当前数据);
- 对比区:一个"手动查询"按钮模拟浅水区做法(要自己点才更新);
- 点列表项 → 删除 → watch 同样自动收到删除事件。
> ⚠ Isar 仅 native 可用:Web 端本页显示降级提示,请用 Android/iOS/桌面端看效果。
---
## 三、核心知识点(新手向)
### 1. watch 的用法
```dart
StreamBuilder<List<dynamic>>(
// 响应式核心:数据库变化自动推送;fireImmediately 首帧先出一份当前数据
stream: WrongQuestionStore.instance.watchAll(),
builder: (context, snapshot) {
if (snapshot.hasError) return const Text('监听出错');
final items = snapshot.data ?? const <dynamic>[];
// items 变化即重建列表
},
)
```
- `watchAll()` 返回 `Stream<List>`,页面必须用 `StreamBuilder` 消费(StreamBuilder 自动管理订阅/取消);
- **`fireImmediately: false` 时首帧是空的** —— 通常要开 `true`,首帧先出一份当前数据。
### 2. 为什么经 store 而不是直接用 AppDatabase
Isar 集合成员只在 native 编译,**web 直接引用会编译失败**。
`WrongQuestionStore` 做了**条件导出**(io 真实现 / web 空实现),
页面永远只面向 store,web 端用 `kIsWeb` 降级提示。
(原理详见 `docs/exam_architecture.md` 的 Isar 教程章节。)
### 3. 页面不 import 模型,用 dynamic 访问字段
```dart
// item 是 WrongQuestion 对象(页面不 import 模型,用 dynamic 访问)
final title = item.title as String? ?? '无题干';
final wrongAt = item.wrongAt as DateTime?;
```
这样页面层与 Isar 模型层彻底解耦,web 编译也不受影响。
### 4. 写入必须 writeTxn
```dart
await WrongQuestionStore.instance.add(
questionId: DateTime.now().millisecondsSinceEpoch % 100000,
title: title,
wrongAnswer: '选错了',
);
```
写操作必须 `writeTxn` 包住(store 内部已封装),页面不用管。
---
## 四、关键代码剖析
```dart
// 添加错题(写入 Isar → watch 自动推送 → 列表自动更新)
Future<void> _addWrong() async {
final title = _titleC.text.trim();
if (title.isEmpty) { /* SnackBar 提示 */ return; }
await WrongQuestionStore.instance.add(...);
_titleC.clear(); // 清空输入框(controller 直改,无需 setState)
}
// 删除(watch 也会收到删除事件,列表自动减少)
Future<void> _delete(dynamic item) async {
final id = item.id as int;
await WrongQuestionStore.instance.remove(id);
}
// 手动查询(对比:非响应式,必须自己点才更新)
Future<void> _manualQuery() async {
final all = await WrongQuestionStore.instance.findAll();
if (!mounted) return;
setState(() => _manualCount = all.length);
}
```
---
## 五、踩坑记录
1. **watch 返回的是 Stream,必须配 StreamBuilder**(或手动 cancel),否则订阅泄漏;
2. **`fireImmediately: false` 时首帧为空** —— 列表会闪一下"空",通常要开 `true`;
3. **写操作必须 writeTxn 包住**(store 已处理,绕过 store 裸写会报错);
4. **Web 端无法跑 Isar**(条件导出空实现),只能真机/桌面看效果;
5. `dynamic` 访问字段要 `as` 强转或判空,别直接 `.title`(分析器不报错,运行期可能崩)。
---
## 六、扩展思考
- 完整链路(接口 + Isar + watch)见 [wrong_flow_demo.md](wrong_flow_demo.md);
- 条件导出 + 三件套(io/web)是本项目 web 兼容的核心手段,见架构文档"零、新手前置";
- Isar 还有 `watchLazy()`(只通知不传数据)、`watchObject(id)`(单对象订阅);
- 进阶:`filter + sortBy + offset/limit` 组合查询,配合 watch 做"分页错题本"。
+144
View File
@@ -0,0 +1,144 @@
# 【Demo】ListView 列表(懒加载 / 刷新 / 触底加载 / 回顶)
> 源文件:`lib/pages/demo/list_demo.dart` | 分类:基础布局
> 一句话:列表是 App 的骨架 —— 本页把列表的"基础、分隔、下拉刷新、上拉加载、回到顶部"一次讲透。
---
## 一、这个 Demo 解决什么问题
列表占了移动 App 80% 的页面形态。本 Demo 演示列表开发的全套基础:
1. `ListView(children)` vs `ListView.builder` 的区别(重点!);
2. `ListView.separated` 自动分隔线;
3. `RefreshIndicator` 下拉刷新;
4. `ScrollController` 监听触底 → 上拉加载更多;
5. 滚动超过 200px 显示"回到顶部"悬浮按钮。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ListDemoPage()));
```
- Demo1:一次性渲染的 ListView(公告 + ListTile + for 循环混装);
- Demo2:可下拉刷新、可触底加载更多、带回到顶部按钮的完整列表。
---
## 三、核心知识点(新手向)
### 1. 最核心的区别(面试常问)
| 写法 | 行为 | 适用 |
|---|---|---|
| `ListView(children: [...])` | **一次性创建并渲染全部** item | 少量(<30)或异类组件混装 |
| `ListView.builder` | **懒加载**,只渲染可见项,滚到才创建 | 大量数据(聊天记录、长列表) |
```dart
ListView.builder(
itemCount: _items.length, // 数据总条数(决定滚动范围)
itemBuilder: (context, index) => ListTile(...), // 按下标生成每一行
)
```
### 2. 同族 API
- `ListView.builder`:纯列表,没有分隔线;
- `ListView.separated`:自动插分隔线,多一个 `separatorBuilder`(比在 item 里手动加 Divider 规范)。
### 3. 下拉刷新三件套
```dart
RefreshIndicator(
onRefresh: _onRefresh, // 必须返回 Future(转圈直到 Future 完成)
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), // 内容不满一屏也能下拉!
...
),
)
```
### 4. 触底加载的原理
`ScrollController.addListener` 监听滚动位置:
```dart
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadMore(); // 距底部 200px 时预加载
}
```
- `pos.pixels`:当前滚了多少;
- `pos.maxScrollExtent`:总共能滚多少;
- `hasClients`:控制器是否已挂载到滚动组件上(没挂载就访问会崩)。
### 5. Dart 3 记录类型(顺带学会)
```dart
List<(int, String)> _items = []; // (编号, 来源标签) 元组
final (num, tag) = _items[index]; // 解构
final newStart = _items.first.$1 + 100; // $1 取第一个字段
```
---
## 四、关键代码剖析
### 防重入的 _loadMore
```dart
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore) return; // 防重入:滚动事件会高频触发
_isLoadingMore = true;
setState(() {}); // 先刷新,显示底部 loading
await Future.delayed(const Duration(milliseconds: 800)); // 模拟网络
if (!mounted) return; // 异步后必须判 mounted
setState(() {
_items = [..._items, ...List.generate(10, (i) => (start + i, '加载更多'))];
_page++;
_hasMore = _page < 5; // 模拟:到第 5 页后没有更多
});
_isLoadingMore = false;
}
```
### 回到顶部按钮(Stack + AnimatedOpacity + IgnorePointer)
```dart
Positioned(
right: 8,
bottom: 8,
child: AnimatedOpacity(
opacity: _showBackToTop ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
// opacity: 0 的按钮还能被点到!必须 IgnorePointer 禁用点击
ignoring: !_showBackToTop,
child: FloatingActionButton.small(
heroTag: null, // 避免多个 FAB 的 Hero 动画冲突
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward, color: Colors.white),
),
),
),
)
```
`_scrollToTop` = `_scrollController.animateTo(0, duration: 300ms, curve: Curves.easeOut)`。
> 显隐判断用 `if (shouldShow != _showBackToTop)` 而不是直接 setState —— 滚动事件每像素都触发,
> 只有状态真的变了才重建,避免性能浪费。
---
## 五、踩坑记录
1. **ListView 放进 Column/Row 必须给固定高度(SizedBox)或 Expanded**,否则报
`Vertical viewport was given unbounded height` —— 列表是"无限高"的,放在"无限高"的 Column 里矛盾。
2. **RefreshIndicator 必须配 `AlwaysScrollableScrollPhysics()`**,否则内容不满一屏时拉不动。
3. **`ScrollController` 必须在 dispose 释放**(与 TextEditingController 同理,防内存泄漏)。
4. **异步后 setState 前必须 `if (!mounted) return`**——await 期间用户可能已切走页面。
5. 触底判断用 `maxScrollExtent - 200` 提前量,不然在底部"刚好"才加载会有白屏等待感。
---
## 六、扩展思考
- **性能红线**:长列表用 builder,不要用 children + for;1000 条数据一次性渲染会掉帧;
- 数据源接 Riverpod 后,`_items` 换成 `ref.watch(xxxProvider)`,刷新逻辑交给 provider(见 exam 系列);
- 分页的通用模式:`page` 页码 + `hasMore` 标记 + `_isLoadingMore` 防重入,三件套缺一不可;
- 想整页只滚一次(AppBar 折叠 + 列表混排)→ 升级到 [sliver_demo.md](sliver_demo.md) 的 CustomScrollView。
+140
View File
@@ -0,0 +1,140 @@
# 【Demo】笔记图片选择页(多图选择 + 缩略图网格 + 删除角标)
> 源文件:`lib/pages/demo/note_picker_demo.dart` | 分类:组件封装
> 一句话:学生上传笔记前的"选图 + 预览 + 确认"页 —— 网格缩略图、右下角加号、底部提交按钮。
---
## 一、这个 Demo 解决什么问题
笔记上传前要先选图、预览、确认。本页是一个真实的**多图选择页**:
```
选图(PickerService 弹层)→ 网格缩略图预览 → 删除不满意的 → 底部提交(预留上传接口)
```
它演示了三个可复用的套路:
1. **加号格子**:GridView 比图片多一格,点它弹选图层;
2. **删除角标**:每张图右上角一个小圆叉(Stack + Positioned);
3. **双端缩略图**:同一份代码在原生 / Web 都能显示缩略图(`PickerService.buildThumb`)。
> 对照 Vue:相当于"el-upload 多图模式 + 预览删除"的前端部分,上传接口那步留了 TODO。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: NotePickerPage()));
```
- 点加号 → 弹「拍照/相册」底部弹层(原生是微信风格相册,可多选;Web 降级 image_picker);
- 选完图回到网格,右上角小叉可删;
- 选满 9 张后加号自动消失;
- 提交按钮显示 `上传笔记(n/9)`,未选图时点击提示"请先选择笔记图片"。
---
## 三、核心知识点(新手向)
### 1. 网格多一格 = 加号位
```dart
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 列
mainAxisSpacing: 10.h,
crossAxisSpacing: 10.w,
),
itemCount: _images.length + 1, // 关键:多一格给"加号"
itemBuilder: (context, index) {
if (index == _images.length) { // 最后一格
if (_images.length >= _maxCount) {
return const SizedBox.shrink(); // 满了:占位空格
}
return _buildAddCell(); // 没满:加号
}
return _buildImageCell(index); // 前面是已选图片
},
)
```
### 2. 删除角标 = Stack + Positioned
```dart
Stack(
fit: StackFit.expand,
children: [
ClipRRect(borderRadius: BorderRadius.circular(10.r), child: thumb),
Positioned(
top: 4.h, right: 4.w, // 钉在右上角
child: GestureDetector(
onTap: () => _onRemoveTap(index),
child: Container( // 20x20 黑色半透明圆
width: 20.w, height: 20.h,
decoration: const BoxDecoration(
color: Colors.black54, shape: BoxShape.circle),
child: const Icon(Icons.close, size: 12, color: Colors.white),
),
),
),
],
)
```
### 3. 双端缩略图:永远走服务层,别自己判断平台
```dart
Widget _buildThumb(PickedImage image) {
return PickerService.buildThumb(image);
// 原生 → AssetEntityImage(实体缩略图,秒开不卡顿)
// Web → Image.memory(字节)
}
```
### 4. 提交按钮的"禁用态"套路
```dart
FilledButton(
onPressed: _uploading ? null : _onSubmit, // null = 禁用(置灰)
child: Text(_uploading ? '上传中...' : '上传笔记(${_images.length}/$_maxCount)'),
)
```
`onPressed` 传 `null` 就是禁用,这是 Flutter 的通用约定(比 Vue 的 `:disabled` 更内建)。
---
## 四、关键代码剖析
```dart
Future<void> _onAddTap() async {
final picked = await PickerService.instance.showPickSheet(
context,
maxCount: _maxCount - _images.length, // 剩余可选张数(限总数 9)
);
if (picked == null || picked.isEmpty) return; // 用户取消
setState(() => _images.addAll(picked)); // 不可变对象直接合并
}
```
提交时预留了上传接口位(等接口文档到了替换 TODO):
```dart
// TODO: 上传接口待用户提供(UploadApi.uploadImages 已预留位置)
// final urls = await UploadApi.instance.uploadImages(_images);
// 成功后把 urls 提交给"创建笔记"接口,再 Navigator.pop 返回。
await Future<void>.delayed(const Duration(milliseconds: 500)); // 模拟请求
```
---
## 五、踩坑记录
1. **千万别在 GridView 里用 `Image.file` 判断平台**——`dart:io` 在 Web 编译不过,必须用 `AssetEntityImage`(原生)/ `Image.memory`(Web);
2. `PickedImage` 是不可变对象:直接 `addAll` 进列表再 `setState` 即可,**不需要深拷贝**;
3. 上传前校验:没选图时点击要提示,别发空请求;
4. `setState` 里 `_images.removeAt(index)` 即可删图,网格随 `itemCount` 变化自动收缩;
5. 满 9 张时加号格用 `SizedBox.shrink()` 占位而不是直接不渲染,**保持网格布局稳定**。
---
## 六、扩展思考
- 选图弹层本身是另一个封装(`showPickSheet` 内部是 `showModalBottomSheet`),见 [smooth_sheet_demo.md](smooth_sheet_demo.md) 的"模态 vs 非模态"对比;
- 上传动作的通用包装(loading 防重入、finally 复位)见 [share_demo.md](share_demo.md) 的 `_sharing` 模式;
- 真实项目:把 TODO 处换成 `UploadApi.uploadImages`,再把返回的 url 列表交给"创建笔记"接口——本页结构不用动。
+121
View File
@@ -0,0 +1,121 @@
# 【Demo】OSS 直传(客户端签名 + PUT 上传)
> 源文件:`lib/pages/demo/oss_upload_demo.dart` | 分类:网络
> 一句话:选图 → 后端分配路径 → 拿 STS → V4 签名 → PUT 直传 OSS,全程带进度。
---
## 一、这个 Demo 解决什么问题
图片上传不走"客户端 → 业务服务器 → OSS"的转发链路(浪费带宽、服务器压力大),
而是 **STS 临时凭证 + 客户端直传 OSS**:后端只负责分配路径和签发凭证,图片字节直接传阿里云。
### 完整链路(与 UniApp ossUpload.js 对照)
```
选图 → 后端分配路径(GetXxxOSSUploadUrl)
→ 拿 STS 凭证(GetAliOSSSetting)
→ V4 签名
→ PUT 直传 OSS(带进度)
→ 返回 CDN URL
```
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: OssUploadDemoPage()));
```
1. 选择上传场景:头像 / 笔记 / AI判题(`UploadScene` 枚举,对应后端 mode);
2. "选择图片"(走 PickerService,见 [note_picker_demo.md](note_picker_demo.md));
3. "上传到 OSS":读字节 → 4 步链路 → 显示 CDN URL + 进度。
> ⚠ 需要后端 OSS 接口可访问(本地/测试环境)。
---
## 三、核心知识点(新手向)
### 1. 双端读取图片字节(不用 dart:io!)
```dart
// web :image_picker 已读好字节
// 原生 :photo_manager 的 AssetEntity.originBytes(纯 Dart API)
final Uint8List bytes = image.bytes ??
await image.entity?.originBytes ??
Uint8List(0);
if (bytes.isEmpty) { /* 提示重新选择 */ }
```
**关键:页面不 import `dart:io`,Web 才能编译**(dart:io 在 Web 是禁用的)。
双端都能拿字节 → 上传逻辑统一。
### 2. 上传入口(OssUploader 封装)
```dart
final url = await OssUploader.uploadImage(
bytes: bytes,
fileName: fileName,
scene: _scene, // UploadScene 枚举:头像/笔记/AI判题
onSendProgress: (sent, total) {
if (total > 0) {
setState(() => _progress = sent / total); // total<=0 兜底
}
},
);
```
### 3. 403 的含义
**403 = 签名或 STS 有问题** —— 看控制台 LogInterceptor 打印的响应体定位:
- AccessDenied:签名计算不一致(method/path/时间戳对不上);
- InvalidAccessKeyId / ExpiredToken:STS 凭证失效;
- SignatureDoesNotMatch:V4 签名算法细节错。
---
## 四、关键代码剖析
```dart
// 场景名映射(switch 表达式,Dart 3)
static String _sceneName(UploadScene s) => switch (s) {
UploadScene.avatar => '头像',
UploadScene.note => '笔记',
UploadScene.aiGrading => 'AI判题',
};
// 场景选择(上传中禁用切换)
Wrap(
spacing: 10.w,
children: UploadScene.values
.map((s) => ChoiceChip(
label: Text(_sceneName(s)),
selected: _scene == s,
onSelected: _uploading ? null : (_) => setState(() => _scene = s),
))
.toList(),
)
// 上传按钮(没选图或上传中 → 禁用)
FilledButton.icon(
onPressed: (_image == null || _uploading) ? null : _upload,
icon: const Icon(Icons.cloud_upload_outlined),
label: Text(_uploading ? '上传中...' : '上传到 OSS'),
)
```
---
## 五、踩坑记录
1. **别用 `Image.file` 双端判断** —— dart:io 在 Web 编译不过,统一走 bytes;
2. **403 优先查签名**:method/URL 编码/时间格式与后端约定不一致是头号原因;
3. `total <= 0` 时进度要兜底(和 dio_pro_demo 同一个坑);
4. 上传中要禁用场景切换和重复点击(`_uploading` 统一控制);
5. 文件名生成:`image.name ?? 'image_时间戳.jpg'`,避免空名。
---
## 六、扩展思考
- **防盗链/私有读**:OSS 私有 Bucket 的 URL 需要签名,展示时再向后端换"临时公开 URL";
- 断点续传/分片:大文件(视频)用 OSS 分片上传 API,`uploadFile` 封装在 OssUploader 里扩展;
- 与 [dio_pro_demo.md](dio_pro_demo.md) 对照:PUT 直传的进度就是 `onSendProgress` 的应用;
- 笔记图片多图上传:先全部直传拿 URL,再一次性提交给"创建笔记"接口(见 note_picker_demo 的 TODO 注释)。
+107
View File
@@ -0,0 +1,107 @@
# 【Demo】选择器页:pop 带值返回
> 源文件:`lib/pages/demo/picker_page.dart` | 分类:路由
> 一句话:路由返回值的经典用法 —— 点"男/女",把结果带回上一个页面。
---
## 一、这个 Demo 解决什么问题
"选性别"这类**选择器页**:打开 → 点选项 → 关闭并把结果带回调用页。
本页是 34 行的最小完整例子,专门讲清 **pop 的第二个参数**。
```
首页 await push 选择器页 → 用户点"男" → Navigator.pop(context, '男')
→ 首页的 await 拿到 '男' → 弹 SnackBar 展示
```
> 对照 Vue:等价于 `this.$emit('select', '男')` 后关闭弹窗——Flutter 里返回值走 `pop` 的第二个参数。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp()); // 从首页"方式 2"按钮进入本页
```
首页点"选择性别" → 本页两个选项(男/女)→ 点任一 → 回到首页并弹 SnackBar 显示结果;
按系统返回键(不选)→ 首页提示"未选择"。
---
## 三、核心知识点(新手向)
### 1. pop 的第二个参数就是"返回值"
```dart
ListTile(
title: const Text('男'),
onTap: () => Navigator.pop(context, '男'), // 第二个参数:返回给上一页的值
),
```
### 2. 与 push 侧配对使用
```dart
// 接收侧(router_home_page.dart)
final result = await Navigator.push<String>(context, MaterialPageRoute(...));
// 返回侧(本页)
Navigator.pop(context, '男');
```
- `pop(context, value)` 的值类型与 `push<String>` 的泛型对应;
- **用户没选(系统返回键 / 手势返回)→ 没调 pop 带值 → 接收侧拿到 `null`**,所以接收侧要 `?? '未选择'` 兜底。
### 3. pop 之后,页面就销毁了
`Navigator.pop` 会把当前路由出栈并销毁。所以**pop 之后再不能用本页的 context**(页面没了)——这也是"await 之后判 mounted"问题的另一面。
---
## 四、关键代码剖析
```dart
class PickerPage extends StatelessWidget {
const PickerPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('选择性别')),
body: Column(
children: [
ListTile(
leading: const Icon(Icons.male, color: Color(0xFF3B82F6)),
title: const Text('男'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.pop(context, '男'), // ← 带值返回
),
Divider(height: 1.h, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.female, color: Color(0xFFF06292)),
title: const Text('女'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.pop(context, '女'),
),
],
),
);
}
}
```
全程没有 `setState`、没有返回值声明——**StatelessWidget 就够了**,选择器页是最典型的"纯跳转 + 带值返回"场景。
---
## 五、踩坑记录
1. **pop 不带值 = 返回 null**:用系统返回键关页时不会触发任何 `pop(context, value)`,接收侧必须处理 null;
2. **类型要配对**:`push<String>` 对应 `pop(context, '男')`;如果你 push 没写泛型,pop 的值类型就是 dynamic,接收侧要强转;
3. pop 之后本页 context 已失效,别再 await 什么(本页没有异步,天然安全);
4. 更多返回值的真实场景:日期选择、城市选择、筛选条件——结构都和本页一致。
---
## 六、扩展思考
- 接收侧完整代码见 [router_home_page.md](router_home_page.md)(`await push` + `context.mounted` + `?? '未选择'`);
- 参数**传入**的两种通道见 [detail_page.md](detail_page.md)(构造参数 vs 命名路由 arguments);
- 进阶:`Navigator.pop` 可以返回任意类型(对象、Map),比如"选了一个城市"返回整个城市模型,接收侧依然按泛型拿。
+118
View File
@@ -0,0 +1,118 @@
# 【Demo】响应式布局:一套代码适配手机 / Pad
> 源文件:`lib/pages/demo/responsive_demo.dart` | 分类:基础布局
> 一句话:屏幕适配 ≠ 响应式布局 —— 一个是"等比缩放",一个是"改变结构"。
---
## 一、这个 Demo 解决什么问题
同一个页面,手机单列列表、Pad 双列卡片墙,**一套代码自动切换**。
新手最容易混淆的概念在这里讲清楚。
### 两个维度(务必分清)
| 概念 | 手段 | 解决什么 |
|---|---|---|
| **屏幕适配** | flutter_screenutil(`.w/.h/.sp`) | 不同屏幕比例不变形(尺寸等比缩放) |
| **响应式布局** | 断点 + LayoutBuilder(本 Demo) | 不同屏幕宽度改变布局**结构**(单列/双列) |
> 两者配合使用:尺寸用 `.w/.h/.sp` 缩放,结构用断点切换。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ResponsiveDemoPage()));
```
- 顶部模式条实时显示"手机模式 / Pad 模式 + 当前宽度 dp";
- 手机(<600dp)→ 单列 ListTile 列表;
- Pad(≥600dp)→ 多列卡片网格(列数按宽度自适应)。
---
## 三、核心知识点(新手向)
### 1. 断点标准(Material 官方)
| 宽度 | 设备 |
|---|---|
| < 600dp | 手机 |
| 600 ~ 839dp | 小平板 / 折叠屏 |
| >= 840dp | 大平板 / 桌面 |
> ⚠ 关键:判断的是**屏幕宽度**,不是 Platform!
> iOS 也有 iPad,Android 也有平板 —— 用平台判断永远分不清它们。
### 2. 两种拿宽度的方式
```dart
MediaQuery.sizeOf(context).width // 整个屏幕宽度
LayoutBuilder(...) // 父容器实际给你的可用宽度
```
- 页面铺满屏幕时两者一样;
- 页面内有**可伸缩区域**(侧栏收起/展开)时必须用 `LayoutBuilder`;
- 本 Demo 用 `LayoutBuilder`,因为能拿到最真实的可用宽度。
### 3. 核心切换逻辑
```dart
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final isTablet = width >= _tabletBreakpoint; // 600dp
// 列数自适应:每列至少 280dp 宽,最多 4 列
final columns = (width / _minCardWidth).floor().clamp(1, 4);
return isTablet
? _buildGrid(columns) // Pad:多列网格
: _buildList(); // 手机:单列列表
},
)
```
---
## 四、关键代码剖析
```dart
// 手机列表项
ListView.separated(
padding: EdgeInsets.all(12.w),
itemCount: _items.length,
separatorBuilder: (_, _) => SizedBox(height: 8.h),
itemBuilder: (context, i) => _ListTileItem(item: _items[i]),
)
// Pad 卡片网格(同一个数据源 _items,只是换了一种展示容器)
GridView.builder(
padding: EdgeInsets.all(12.w),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns, // 由宽度算出来的列数
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 1.4, // 宽高比,数字大了卡片变矮
),
itemCount: _items.length,
itemBuilder: (context, i) => _CardItem(item: _items[i]),
)
```
**关键思想:数据不变,变的是"容器"** —— 同一份 `_items`,手机渲染成列表项,Pad 渲染成卡片。
组件只负责"怎么显示一条数据",页面负责"按宽度选容器"。
---
## 五、踩坑记录
1. **别用 `Platform.isAndroid` 判断手机/Pad** —— 永远用宽度断点;
2. `MediaQuery.sizeOf(context).width` 拿的是全屏宽,页面嵌在可伸缩容器里会算错 —— 用 LayoutBuilder;
3. `(width / 280).floor()` 在窄屏可能算出 0 列 —— 必须 `.clamp(1, 4)` 兜底;
4. 断点值用常量集中管理(`_tabletBreakpoint = 600`),别散落在各页面写死魔法数字。
---
## 六、扩展思考
- **官方推荐**:`flutter/foundation` 的 `defaultTargetPlatform` + 宽度断点结合;或引入 `responsive_builder` 包;
- 平板横竖屏切换:竖屏 8.4" ≈ 800dp,横屏 1280dp —— 断点要覆盖两种情况;
- 进阶模式:`LayoutBuilder` + `CustomScrollView`([sliver_demo.md](sliver_demo.md))组合做"响应式长列表";
- 本项目 theme_demo 的 `PageTheme` 按页面层级给配色,响应式按宽度给结构 —— 两个维度正交,可自由组合。
+123
View File
@@ -0,0 +1,123 @@
# 【Demo】富文本渲染(RichTextContent:HTML → 题目/解析)
> 源文件:`lib/pages/demo/rich_text_demo.dart` | 分类:组件封装
> 一句话:后端给的 HTML(题目/解析/听力材料)渲染成富文本 —— 还支持字号调节、深色模式、自定义样式。
---
## 一、这个 Demo 解决什么问题
教育 App 的题目/解析是**后端返回的 HTML 字符串**(`<h3>`、`<img>`、`<table>`、`<audio>`…),
前端要把它渲染成漂亮的可交互富文本。本页演示 `RichTextContent`(`lib/core/components/rich_text_content.dart`):
- 完整渲染:标题、粗体、**上标下标**、`<ul>` 选项、`<table>` 表格;
- `<audio>` 标签**自动替换成本项目的 AudioPlayerCard**;
- 学生端能力:**字号 Aa± 实时调节、深色模式切换**(通过 `textStyle` 注入);
- 自定义样式:`.answer` 类 → 红色加粗(`customStylesBuilder`)。
> 对照 Vue:相当于 `v-html` + 样式定制,但 Flutter 没有浏览器引擎,得用 flutter_html 这类包逐标签渲染。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: RichTextDemoPage()));
```
- 顶部控制栏:滑块拖字号(12~24)、切深色开关 → 正文实时变化;
- 页面里:化学方程式上下标、本地图片(`asset://` 协议)、表格、听力音频、视频、红色加粗的答案 B。
---
## 三、核心知识点(新手向)
### 1. 一行渲染 HTML
```dart
RichTextContent(
html: _html, // 后端返回的 HTML 字符串
// 字号 + 深色模式都通过 textStyle 注入(学生端统一入口)
textStyle: TextStyle(
fontSize: _fontSize,
height: 1.6,
color: _dark ? Colors.white70 : const Color(0xFF333333),
),
)
```
**`textStyle` 注入是核心设计**:页面不重建 HTML、不改渲染器,只改一个 TextStyle 就全局换字号/颜色——学生端"无障碍调节"的标准做法。
### 2. 自定义样式:customStylesBuilder
```dart
customStylesBuilder: (element) {
if (element.className.contains('answer')) {
return const {'color': '#EF4444', 'font-weight': 'bold'};
}
return null; // 其他元素不特殊处理
},
```
后端 HTML 里写 `<span class="answer">B</span>` → 渲染成红色加粗。**按 class 定制样式**,不用后端改结构。
### 3. 本地图片:asset:// 协议
```html
<img src="asset://assets/img/luola_logo.png" width="160" height="160"/>
```
`asset://` + 资源路径 = 加载本地资源图片(最稳,不受网络影响)。实际后端返回完整 URL 或相对路径(相对路径需业务层拼 baseUrl)。
### 4. audio 标签自动替换
```html
<audio src="asset://assets/audio/sample-30s.mp3" title="听力材料(30 秒)" controls></audio>
```
组件内部检测 `<audio>` → 替换成 `AudioPlayerCard`(见 [audio_player_demo.md](audio_player_demo.md))——听力题直接能播。
---
## 四、关键代码剖析
```dart
static const String _html = '''
<h3>第 1 题 · 阅读理解</h3>
<p>光合作用是植物利用光能将<strong>二氧化碳</strong>和<strong>水</strong>合成有机物并释放氧气的过程。化学方程式:6CO<sub>2</sub> + 6H<sub>2</sub>O → C<sub>6</sub>H<sub>12</sub>O<sub>6</sub> + 6O<sub>2</sub>,面积用 x<sup>2</sup> 表示。</p>
<img src="asset://assets/img/luola_logo.png" width="160" height="160"/>
<ul>
<li>A. 光合作用只发生在夜间</li>
<li>B. 光合作用会释放氧气</li>
</ul>
<table border="1" cellpadding="6">
<tr><th>条件</th><th>有光</th><th>黑暗</th></tr>
<tr><td>释放 O₂</td><td>√</td><td>×</td></tr>
</table>
<audio src="asset://assets/audio/sample-30s.mp3" title="听力材料(30 秒)" controls></audio>
<p><b>【解析】</b>正确答案:<span class="answer">B</span>。</p>
''';
```
字号控制条(12~24 共 12 档):
```dart
Slider(
value: _fontSize,
min: 12,
max: 24,
divisions: 12, // 档位数 → 滑块只能停在整数
activeColor: const Color(0xFF3B82F6),
onChanged: (v) => setState(() => _fontSize = v),
)
```
---
## 五、踩坑记录
1. **textStyle 只影响文本**:图片、表格、AudioPlayerCard 是各自样式——深色模式下卡片白底不随主题变(业务侧需按需适配);
2. **内联 CSS 只支持常用属性**,复杂布局会降级(flutter_html 的边界,别拿它当浏览器);
3. 公式(LaTeX)本 demo 未含:以后在 `RichTextContent` 的 `customWidgetBuilder` 里扩展(如 flutter_math_fork);
4. 相对路径图片要业务层拼 baseUrl,别把裸相对路径直接塞进 html;
5. 深色模式只是 `_dark` 布尔 + 换色,**页面底色/AppBar 也要跟着换**(本页手动处理了,主题化见 [theme_demo.md](theme_demo.md))。
---
## 六、扩展思考
- `<audio>` 替换成的播放器组件见 [audio_player_demo.md](audio_player_demo.md);
- 富文本在真实考试页的运用见 [exam_demo.md](exam_demo.md)(题干 HTML 渲染);
- 学生端"字号调节 + 深色模式"这类**偏好设置**,进阶做法是存进全局状态(Riverpod),见 [exam_architecture.md](../exam_architecture.md) 的 Riverpod 章节;
- 真实项目:`_html` 换成接口返回的题目 HTML,`asset://` 换成 CDN 完整 URL 即可。
+99
View File
@@ -0,0 +1,99 @@
# 【Demo】路由导航:应用入口(RouterApp)
> 源文件:`lib/pages/demo/router_demo.dart` | 分类:路由
> 一句话:一个只负责"组装"的入口文件 —— 注册路由表、指定首页。
---
## 一、这个 Demo 解决什么问题
把路由导航 Demo 的三个页面(首页 / 详情页 / 选择器页)组装成一个可运行的应用。
演示的是**组件化结构 + 命名路由表**:
```
lib/pages/demo/router_demo.dart → 应用入口(只组装,不写页面)
lib/pages/demo/router_home_page.dart → 首页(三个跳转按钮)
lib/pages/demo/detail_page.dart → 详情页(接收参数)
lib/pages/demo/picker_page.dart → 选择器页(pop 带值返回)
```
> 对照 Vue:RouterApp ≈ `main.ts + router/index.ts`;每个页面一个文件 ≈ `views/` 目录。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp());
```
首页三个按钮,演示三种跳转姿势:
1. push + 构造函数传参;
2. push 选择器并 **await 接收 pop 返回的结果**;
3. pushNamed 命名路由 + arguments 传参。
---
## 三、核心知识点(新手向)
### 1. 命名路由表
```dart
MaterialApp(
title: '路由导航 Demo',
theme: ThemeData(colorSchemeSeed: const Color(0xFF3B82F6)),
// 命名路由表:name -> 页面构造(详情页在这里注册)
routes: {
'/detail': (context) => const DetailPage(),
},
home: const RouterHomePage(),
)
```
- 路由名规范:`/xxx` 开头,如 `/detail`、`/user/info`;
- 注册后才能用 `Navigator.pushNamed(context, '/detail')`;
- `home` 是应用启动后的第一个页面(相当于 Vue 的默认路由)。
### 2. 命名规范
| 内容 | 规范 |
|---|---|
| 文件名 | snake_case(小写下划线):`router_home_page.dart` |
| 类名 | PascalCase:`RouterHomePage` |
| 引入 | 跨文件用 `import` |
| 文件组织 | **每个公开类一个文件** |
---
## 四、关键代码剖析
```dart
class RouterApp extends StatelessWidget {
const RouterApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '路由导航 Demo',
theme: ThemeData(colorSchemeSeed: const Color(0xFF3B82F6)),
routes: {
'/detail': (context) => const DetailPage(),
},
home: const RouterHomePage(),
);
}
}
```
---
## 五、踩坑记录
1. **入口文件只负责组装**,别在里面堆业务代码——页面跳转见 [router_home_page.md](router_home_page.md);
2. 命名路由的 `arguments` 是 dynamic,详情页取值要判空强转(见 [detail_page.md](detail_page.md));
3. 真实项目路由更复杂(嵌套/守卫/转场),用 go_router 或 auto_route 包,本项目 demo 用原生 Navigator 讲清原理。
---
## 六、扩展思考
- 三种跳转姿势的详细讲解见 [router_home_page.md](router_home_page.md);
- 参数接收的两种方式见 [detail_page.md](detail_page.md);
- 真实项目里 MaterialApp 在 `lib/main.dart`,路由表在 `core/router/` 或 go_router 的配置里——原理与本页一致。
+104
View File
@@ -0,0 +1,104 @@
# 【Demo】路由首页:三种跳转姿势
> 源文件:`lib/pages/demo/router_home_page.dart` | 分类:路由
> 一句话:push 传参 / await 接返回值 / pushNamed 命名路由 —— 跳转的三个基本姿势。
---
## 一、这个 Demo 解决什么问题
页面跳转是 App 的基本操作。本页演示三种姿势及其适用场景:
1. **push + 构造函数传参**:参数少、类型安全,最常用;
2. **push 并等待返回结果**:从"选择器页"带值回来(选性别等);
3. **pushNamed 命名路由 + arguments**:按路由名跳转,参数以 Map 传递。
> 核心思想:**页面跳转是"入栈/出栈"**(Navigator 是个栈)。
> push = 压栈,pop = 出栈;pop 时带值 → push 的 await 拿到值。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp());
```
- 方式 1:跳详情页(张三,18);
- 方式 2:跳选择器页,选"男/女" → 返回首页弹 SnackBar 显示选择结果;
- 方式 3:跳详情页(李四,25,走命名路由)。
---
## 三、核心知识点(新手向)
### 1. 方式 1:push + 构造函数传参(类型安全,首选)
```dart
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DetailPage(name: '张三', age: 18),
),
);
```
- 参数是 Dart 强类型:写错类型编译期就报错;
- 页面构造写在 `builder` 里(路由是"惰性构建",进入页面才执行)。
### 2. 方式 2:await push 接收 pop 返回值
```dart
onPressed: () async {
// 注意:push 返回的是 Future,用 await 接收 pop 带回来的值
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const PickerPage()),
);
if (context.mounted) { // await 之后必须判 mounted!
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('你选择了:${result ?? '未选择'}')),
);
}
},
```
- 泛型 `<String>` 声明返回类型;
- 用户点系统返回键(没有 pop 带值)→ result 为 null,`?? '未选择'` 兜底;
- **await 后 context.mounted 必须判**(页面可能已被销毁)。
### 3. 方式 3:pushNamed 命名路由 + arguments
```dart
Navigator.pushNamed(
context,
'/detail', // 必须在 MaterialApp.routes 里注册过
arguments: {'name': '李四', 'age': 25},
);
```
- arguments 是 `dynamic`(任意类型),取值需判空强转(见 [detail_page.md](detail_page.md));
- 适合路由名集中管理的场景,代价是**丢失了类型安全**。
---
## 四、关键代码剖析
```dart
// 页面底部常驻的知识点提示(学习型 Demo 的惯例)
Text(
'知识点:页面跳转是【入栈/出栈】。'
'返回页 pop 时带值 → 接收页 await push 拿到值。'
'命名路由的 arguments 是 dynamic,取值需判空强转。',
)
```
---
## 五、踩坑记录
1. **`await` 之后用 `context` 必须先判 `context.mounted`**(Flutter 3.7+ 的推荐写法,替代旧式 `if (!mounted) return`);
2. `pushNamed` 没在 routes 注册 → 运行期 `onUnknownRoute` 报错/黑屏;
3. 忘了 `await push` 就收不到 pop 返回值;
4. 返回结果用泛型约束:`Navigator.push<String>`,别让类型随意流转。
---
## 六、扩展思考
- pop 带值的一方见 [picker_page.md](picker_page.md)(`Navigator.pop(context, '男')`);
- 参数接收的两种通道见 [detail_page.md](detail_page.md);
- 真实项目推荐 go_router:声明式路由 + 守卫 + 深链,但底层仍是 Navigator 这套入栈/出栈机制。
+91
View File
@@ -0,0 +1,91 @@
# 【Demo】Seed 色板验证页(colorSchemeSeed 原理)
> 源文件:`lib/pages/demo/seed_palette_demo.dart` | 分类:基础布局
> 一句话:所有"没写死颜色"的 Material 组件,换一个 seed 就整体变色 —— 本页就是证据。
---
## 一、这个 Demo 解决什么问题
新手经常困惑:`colorSchemeSeed` 到底有什么用?为什么有的页面换主题变色,有的不变?
答案:**MaterialApp 的 `theme.colorSchemeSeed` 会自动生成整套 ColorScheme**,
页面里**不指定颜色**的 Material 组件(FilledButton/Checkbox/Switch/进度圈/聚焦输入框等)
颜色全部取自这套色板。本页把所有"不写死颜色"的组件摆在一起,换 seed 看整体变色。
---
## 二、怎么运行 / 演示内容
```bash
flutter run # 默认品牌 → 本页所有组件 SkyBlue 蓝
flutter run --dart-define=BRAND=chucai # 换品牌 → 本页所有组件变 Red 红
```
`--dart-define` 是编译期变量,由 `core/config/app_config.dart` 读取并决定 `seedColor`。
> 对照登录页为什么不变色:登录页所有颜色写死 `Color(0xFF3B82F6)` → 永远蓝色,不随主题走。
> **写死了就不跟主题,没写死才跟** —— 这是理解主题系统的关键。
---
## 三、核心知识点(新手向)
### 1. 色板怎么生成的
```dart
ThemeData(colorSchemeSeed: seedColor)
```
Material 的 ColorScheme.fromSeed 算法从种子色推出一整套颜色:
primary / onPrimary / secondary / tertiary / error / surface 等几十个语义色。
组件默认取语义色:按钮取 primary、进度圈取 primary、Checkbox 选中取 primary……
### 2. 本页验证清单(全部未写死颜色)
- 按钮组:FilledButton / OutlinedButton / TextButton;
- Checkbox / Switch / ChoiceChip 选中态;
- CircularProgressIndicator / LinearProgressIndicator;
- TextField 聚焦边框 + 光标。
### 3. 取色值显示
```dart
String _hex(Color c) {
final v = c.toARGB32() & 0xFFFFFF; // Flutter 3.27+ 用 toARGB32 取色值
return '#${v.toRadixString(16).padLeft(6, '0').toUpperCase()}';
}
```
---
## 四、关键代码剖析
```dart
// 当前品牌主题(来自 AppConfig,启动时由 --dart-define 决定)
final brandTheme = AppConfig.theme;
// 展示 seed 色块 + 品牌信息
Container(
width: 56.w, height: 56.h,
decoration: BoxDecoration(color: brandTheme.seedColor, ...),
),
Text('主题 ${brandTheme.name} · seed ${_hex(brandTheme.seedColor)}'),
// 一个不写颜色的组件(示例)—— 它的颜色来自当前 seed 色板
FilledButton(onPressed: () {}, child: const Text('FilledButton')),
```
---
## 五、踩坑记录
1. **`.w/.h` 的 widget 不能 const** —— 本页 CircularProgressIndicator 外面套的 SizedBox 就踩过
(`const_eval_extension_method` 编译错误);
2. `toARGB32()` 是新 API,旧的 `.value` 已废弃;
3. 别在页面里 `Theme.of(context).colorScheme.primary` 到处取 —— 组件不写颜色才是正道;
4. `--dart-define` 是编译期常量,热重载不生效,改品牌要重新 run。
---
## 六、扩展思考
- 二级主题(页面级细节)见 [theme_demo.md](theme_demo.md);
- 想精确控制色板:`ColorScheme.fromSeed(seedColor: x, primary: y, ...)` 手动覆盖某些槽位;
- 动态切换主题(运行时换色):Theme 局部覆盖或 AnimatedTheme,不依赖 --dart-define。
+150
View File
@@ -0,0 +1,150 @@
# 【Demo】分享功能演示(ShareService 五个场景)
> 源文件:`lib/pages/demo/share_demo.dart` | 分类:组件封装
> 一句话:文本分享 / 选图分享 / Widget 截图生成海报分享 / 截图预览 / 保存相册 —— 一个服务类覆盖全部分享场景。
---
## 一、这个 Demo 解决什么问题
"分享"是 App 的标配能力,但场景很多:分享文字、分享图片、**把 UI 截图成海报分享**、保存到相册。
本页演示 `ShareService`(`lib/core/services/share_service.dart`)的 5 个场景:
| # | 场景 | 方法 | 用途 |
|---|---|---|---|
| 1 | 分享文本/链接 | `shareText(text:)` | 邀请链接、文案 |
| 2 | 选图后分享 | `shareImage(filePath:, text:)` | 分享单张图片 |
| 3 | Widget 截图 → 分享 | `shareFromWidget(context:, text:, builder:)` | **攻克海报分享** |
| 4 | Widget 截图预览 | `captureWidget(context:, builder:)` | 截图不发,先看效果 |
| 5a | 选图保存相册 | `saveToGallery(filePath:)` | 存图 |
| 5b | 截图保存相册 | `saveToGalleryFromBytes(bytes:)` | 海报存图 |
> 核心思想:**"分享"是平台能力,不是页面能力** —— 封装成 Service 单例,任何页面一行调用。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ShareDemoPage()));
```
- ① 输入框改文案 → 分享文本;
- ② 选一张图 → 系统分享面板;
- ③ 点"截图并分享" → 海报 Widget 被截成图片 → 分享面板;
- ④ 点"仅截图预览" → 弹窗显示截图 + 图片大小(KB);
- ④ 场景的两个按钮:`选图→保存` / `截图→保存` 写入系统相册。
---
## 三、核心知识点(新手向)
### 1. 场景 3 的核心:Widget → 图片 → 分享
```dart
final result = await ShareService.instance.shareFromWidget(
context: context,
text: '我攻克了一道难题,快来挑战!',
builder: (_) => _buildPosterWidget( // 任意 Widget 都能截图
userName: '张三同学',
taskName: '二次函数综合练习',
score: 98,
),
);
```
海报就是一个**普通 Container**(300 宽、渐变背景、纯 UI 无交互)——不是图片素材,是代码画出来的。
分享时被 `captureWidget` 截成 PNG 字节,再交给系统分享。
### 2. 场景 4:截图预览 = bytes → Image.memory
```dart
final bytes = await ShareService.captureWidget(context: context, builder: ...);
if (bytes == null) { _showResult('截图失败'); return; }
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Column(children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(bytes, width: 280), // 字节流直接渲染
),
Text('图片大小:${(bytes.length / 1024).toStringAsFixed(1)} KB'),
]),
),
);
```
`captureWidget` 返回 `Uint8List?`——**null 表示截图失败**,必须判空。
### 3. 防重入 + 复位:异步按钮的三段式
```dart
setState(() => _sharing = true); // ① 锁
try {
...await 分享...
} catch (e) {
_showResult('分享失败:$e');
} finally {
if (mounted) setState(() => _sharing = false); // ③ 无论成败都解锁
}
// 按钮:_shareButton(onPressed: _sharing ? null : onPressed) ② 置灰
```
`_sharing` 置灰所有分享按钮,防连点重复弹系统面板;`finally` 保证异常也不卡死按钮。
### 4. Web 端限制:路径分享不可用
```dart
final filePath = picked.first.path;
if (filePath == null) {
_showResult('Web 端暂不支持文件路径分享'); // Web 拿不到真实文件路径
return;
}
```
---
## 四、关键代码剖析
```dart
/// 模拟"攻克海报"布局(实际项目中从业务数据构建)
Widget _buildPosterWidget({
required String userName,
required String taskName,
required int score,
}) {
return Container(
width: 300,
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
gradient: LinearGradient( // 海报背景:紫蓝渐变
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
),
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.school, color: Colors.white, size: 40), // Logo
const Text('爱学蝶变', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
...
Text('$score', style: const TextStyle(color: Colors.white, fontSize: 48, fontWeight: FontWeight.w800, height: 1)),
]),
);
}
```
海报 Widget 的纪律:**固定宽度 + 纯展示 + 不依赖页面状态**(参数全传入)——这样截图永远稳定。
---
## 五、踩坑记录
1. **captureWidget 必须在页面挂载后调用**(context 有效),否则截图 null;
2. **bytes 判空**:`if (bytes == null) return;`,别直接 `Image.memory(bytes!)` 崩掉;
3. Web 端 `picked.first.path` 为 null——图片分享/存相册要走别的通道,代码里已降级提示;
4. 所有按钮共享 `_sharing` 一把锁:分享中所有分享按钮都禁用,防止并发弹面板;
5. `finally` 里必须判 `mounted` 再 setState(await 期间页面可能已销毁);
6. 海报是"代码画的图",字体/图标依赖系统字体,跨设备效果略有差异——正式海报可换用真实图片素材。
---
## 六、扩展思考
- 选图用的 `PickerService.showPickSheet` 与缩略图渲染见 [note_picker_demo.md](note_picker_demo.md);
- 异步三段式(锁 + try/catch + finally 复位)同样用在 [note_picker_demo.md](note_picker_demo.md) 的上传和 [dio_demo.md](dio_demo.md) 的请求里;
- 真实项目:"攻克海报"数据来自答题结果接口,把 `_buildPosterWidget` 的参数换成接口返回的 userName/taskName/score 即可;
- `ShareService` 内部用 share_plus / screenshot / gal 插件——想学封装思想可以直接读它的源码。
+114
View File
@@ -0,0 +1,114 @@
# 【Demo】CustomScrollView + Sliver 混排布局
> 源文件:`lib/pages/demo/sliver_demo.dart` | 分类:基础布局
> 一句话:折叠 AppBar + 网格 + 长列表"整页只滚一次",靠 Sliver 碎片拼装。
---
## 一、这个 Demo 解决什么问题
页面要同时包含"可折叠的大图 AppBar + 网格区块 + 长列表区块"时:
- **ListView 不能嵌套 ListView/GridView**(同向滚动会冲突,控制权打架);
- 正解是 `CustomScrollView`:把所有区块变成 Sliver"碎片"拼在一起,**共用一个滚动控制器,整页只滚一次**。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: SliverDemoPage()));
```
页面从上到下:折叠大图 AppBar → 网格标题 → 12 格网格 → 列表标题 → 50 条列表,
下滑时 AppBar 收起固定,其余内容连续滚动。
---
## 三、核心知识点(新手向)
### 1. Sliver 是什么
Sliver = "碎片"。`CustomScrollView` 把多个 sliver 拼成一个统一滚动视图。
### 2. 常用 sliver 对照表
| Sliver | 作用 | 对应普通组件 |
|---|---|---|
| `SliverAppBar` | 可折叠/固定的 AppBar | AppBar |
| `SliverToBoxAdapter` | 把普通组件(文字/卡片)塞进滚动流 | 任意 Widget |
| `SliverGrid.builder` | 网格区块(懒加载) | GridView |
| `SliverList.builder` | 列表区块(懒加载) | ListView.builder |
| `SliverPadding` | 给某个 sliver 加内边距 | Padding |
### 3. 核心结构
```dart
CustomScrollView(
slivers: [
SliverAppBar( // ① 折叠 AppBar
expandedHeight: 180.h,
pinned: true, // 滚动时固定在顶部
flexibleSpace: FlexibleSpaceBar(
title: Text('Sliver 系列 Demo'),
background: DecoratedBox(...), // 展开时的大图
),
),
SliverToBoxAdapter( // ② 普通文字标题
child: Padding(...Text('网格区块(SliverGrid)')),
),
SliverPadding( // ③ 网格区块(带外边距)
padding: EdgeInsets.all(16.w),
sliver: SliverGrid.builder(...),
),
SliverList.builder( // ④ 列表区块
itemCount: 50,
itemBuilder: ...,
),
],
)
```
### 4. 判断口诀(什么时候用 Sliver)
- 页面是"**一个网格**"或"**一个列表**" → `GridView` / `ListView` 更简单;
- 页面是"**折叠头 + 网格 + 长列表**"混排 → `CustomScrollView + slivers`。
---
## 四、关键代码剖析
### 折叠 AppBar(本页的灵魂)
```dart
SliverAppBar(
expandedHeight: 180.h, // 展开时高度(大图区域)
pinned: true, // 上滑时缩成一条标题栏并固定在顶部
backgroundColor: Color(0xFF3B82F6),
flexibleSpace: FlexibleSpaceBar(
title: Text('Sliver 系列 Demo'),
centerTitle: true,
background: DecoratedBox( // 展开时显示的大图(这里是渐变)
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF8E6FE8)],
),
),
),
),
)
```
---
## 五、踩坑记录
1. **slivers 里的组件必须是 Sliver 家族**,普通 Widget(如 Container)直接放会报类型错误——
要用 `SliverToBoxAdapter` 包一层;
2. **同向嵌套滚动是禁区**:CustomScrollView 里不能再放 ListView/GridView;
3. `SliverAppBar` 不设 `pinned` 时,上滑后 AppBar 会完全滚出屏幕(floating 可以浮回)——按产品需求选;
4. Sliver 的懒加载与 GridView/ListView 相同:builder 系列才懒加载。
---
## 六、扩展思考
- **Sliver 与 GridView 的本质关系**(重点记忆):
`GridView = CustomScrollView + 1 个 SliverGrid`,两者都是"单个滚动 + 懒加载",区别只在混排能力;
- 进阶 Sliver:`SliverPersistentHeader`(吸顶 Tab)、`SliverAnimatedList`(增删动画列表)、`SliverAppBar` 的 `snap` 模式;
- 电商首页 = Sliver 混排的教科书场景:搜索栏吸顶 + 轮播 + 宫格 + 瀑布流商品;
- 对照 [responsive_demo.md](responsive_demo.md):Sliver 解决"滚动结构",响应式解决"不同宽度下的结构",两者可叠加。
+125
View File
@@ -0,0 +1,125 @@
# 【Demo】常驻多停靠底部面板(PersistentSnapSheet)
> 源文件:`lib/pages/demo/smooth_sheet_demo.dart` | 分类:组件封装
> 一句话:模拟"附近学校"地图页 —— 背景可交互 + 底部常驻拖拽面板,3 个停靠点自动吸附。
---
## 一、这个 Demo 解决什么问题
地图类页面常见的交互:**底部常驻一个可拖拽面板**,像抽屉一样有多个停靠位置。
本页模拟教育 App 的"附近学校"页面:
| 停靠点 | 比例 | 显示内容 |
|---|---|---|
| 收起 | 12% 屏高 | 只有手柄 + "附近学校"标题 |
| 半屏 | 50% 屏高 | 前 2 所学校 + "查看全部" |
| 展开 | 85% 屏高 | 完整学校列表,内部 ListView 可滚动 |
关键交互:面板**自由拖拽、松手自动吸附**最近停靠点;**背景(地图)始终可交互**(非模态);面板内列表滚到顶继续下拉可收起面板。
> 核心组件:`PersistentSnapSheet`(`lib/core/components/persistent_snap_sheet.dart`),是对 smooth_sheets 包的封装。
> 对照 Vue:`showBottomSheet` / 抽屉组件的"常驻版"——不挡背景、可多档位。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: SmoothSheetDemoPage()));
```
- 拖拽面板松手 → 观察自动吸附最近停靠点;
- 收起状态点地图背景 → 背景仍可交互(非模态);
- 展开面板后滚动列表到底再下拉 → 面板收起;
- 顶部 3 个按钮可代码控制面板跳到指定停靠点。
---
## 三、核心知识点(新手向)
### 1. 三行配置:停靠点比例
```dart
/// 3 个停靠点:底部收起 / 半屏 / 大半屏
static const List<double> _snapFactors = [0.12, 0.5, 0.85];
```
`snapFactors` 是**占屏高的比例**(0.12 = 12% 屏高),组件负责换算成像素并做吸附动画。
### 2. 结构:Stack 背景层 + 面板层
```dart
Scaffold(
body: Stack(
children: [
_buildMapBackground(context), // 背景层:模拟地图(真实项目换地图 SDK)
PersistentSnapSheet( // 面板层:常驻面板
snapFactors: _snapFactors,
child: Column(
children: [
_buildHandle(context), // 手柄区域
Expanded(child: _buildSheetContent(context)), // 面板内容
],
),
),
],
),
)
```
### 3. 经典拖拽指示条(36×4 圆角小横条)
```dart
Container(
width: 36.w,
height: 4.h,
decoration: BoxDecoration(
color: const Color(0xFFD1D5DB),
borderRadius: BorderRadius.circular(2.r),
),
)
```
这是 iOS/微信风格的"可拖拽暗示":看到它就明白面板能拉。
### 4. 非模态:面板盖住背景,但不拦截背景交互
`PersistentSnapSheet` 是**常驻布局组件**(不是 `showModalBottomSheet` 弹窗),
面板下面的地图照常能点——这是"常驻面板"和"模态弹层"的本质区别。
---
## 四、关键代码剖析
```dart
/// 面板内部内容:分割线 + 可滚动学校列表(与面板手势联动)
Expanded(
child: ListView.separated(
padding: EdgeInsets.symmetric(horizontal: 16.w),
itemCount: _schools.length,
separatorBuilder: (_, _) => Divider(height: 1.h, color: const Color(0xFFF1F5F9)),
itemBuilder: (context, index) => _SchoolListTile(
school: _schools[index],
index: index,
),
),
),
```
- 面板里放 **Expanded + ListView**:面板多高,列表多高;
- 列表滚到顶部继续下拉 → 手势被组件接住 → 面板收起(smooth_sheets 的核心联动,本页零代码实现);
- 列表项 `_SchoolListTile` 用图标 + 双行文本 + 导航按钮组成,颜色按 `index % 8` 轮换(8 色数组)。
---
## 五、踩坑记录
1. **snapFactors 是屏高比例,不是固定像素**——改屏幕/横竖屏后停靠点自动适配,别写死 300.0 这类像素值;
2. 想"盖住背景不可交互"的场景(确认弹窗)**别用 PersistentSnapSheet**,用 `showModalBottomSheet`(见 [note_picker_demo.md](note_picker_demo.md) 的选图弹层);
3. 面板内滚动列表必须用 **Expanded 包裹**,否则列表高度 = 内容高度,滚不动;
4. 手柄区域 `HitTestBehavior.opaque` 保证空白处也能接住拖拽手势;
5. 真实项目把 `_buildMapBackground` 换成高德/百度地图 SDK 组件即可,面板层完全不用动。
---
## 六、扩展思考
- 对比模态弹层:`showModalBottomSheet` 的用法见 [note_picker_demo.md](note_picker_demo.md)(PickerService 内部);
- 面板里的列表复用 [list_demo.md](list_demo.md) 的 `ListView.separated` 知识点;
- 面板内容进阶:搜索框 + 学校筛选(联动 [input_demo.md](input_demo.md) 的 TextField 知识);
- 组件本体 `PersistentSnapSheet` 在 `lib/core/components/persistent_snap_sheet.dart`——读它 = 学 smooth_sheets 包的使用。
+119
View File
@@ -0,0 +1,119 @@
# 【Demo】Stack + Positioned 层叠布局
> 源文件:`lib/pages/demo/stack_demo.dart` | 分类:基础布局
> 一句话:UI 重叠 = CSS 的 `position: absolute`,本页附赠一个真实踩过的"全 Positioned 尺寸崩溃"大坑。
---
## 一、这个 Demo 解决什么问题
做"头像 + 右上角角标"、"卡片 + 半透明蒙层"、"悬浮按钮"这类**元素重叠**的 UI。
Flutter 用 `Stack` 实现:
- `Stack` = 图层容器(**后写的子组件盖在上面**);
- `Positioned` = 给子组件做绝对定位(**只能直接放在 Stack 的 children 里**)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: StackDemoPage()));
```
页面演示 5 块内容:
1. 基础重叠:蓝色盒子 + 右上角悬空角标 + 居中文字;
2. `Positioned.fill` 半透明蒙层;
3. 坑①复现:全 Positioned 时 Stack 尺寸异常;
4. 修复方案 1:SizedBox 固定宽高;
5. 修复方案 2:非 Positioned 底层组件撑尺寸。
---
## 三、核心知识点(新手向)
### 1. Stack 的两个核心参数
```dart
Stack(
alignment: Alignment.topLeft, // 对【没被 Positioned 包裹】的子组件做默认对齐
clipBehavior: Clip.none, // 允许子组件绘制超出 Stack 边界(默认 Clip.hardEdge 会裁剪)
children: [...],
)
```
### 2. Positioned 的用法
```dart
Positioned(
top: 10, // 距 Stack 上边
right: -10, // 负值 = 超出边界(配合 clipBehavior: Clip.none 才能显示)
child: ...,
)
Positioned.fill( // 填满整个 Stack —— 做蒙层最方便
child: Container(color: Colors.black54),
)
```
### 3. 布局规则一句话
Stack 的尺寸 = **非 Positioned 子组件**决定的(取其中最大的那个);
Positioned 组件只是"贴"在上面,不参与 Stack 自身尺寸计算。
---
## 四、关键代码剖析(含大坑)
### 坑①:全部子组件都是 Positioned → Stack 尺寸异常
```dart
// 真实行为:Stack 尺寸不由内容决定,直接取父约束最大值(constraints.biggest)
Stack(
children: [
Positioned(top: 10, left: 10, child: Text("A")),
],
)
```
- 放在**有界约束**里(如 SizedBox 300×60)→ Stack 占满整个 300×60,而不是收缩到文字大小;
- 裸放在 **Column 等无界高度**里 → debug 模式直接断言崩溃:
`A Stack requires bounded constraints`,**整个 body 都画不出来**(只看到标题栏)。
### 修复方案 1:给 Stack 套 SizedBox 固定宽高
```dart
SizedBox(
width: 300.w,
height: 80.h,
child: Stack(children: [Positioned(top: 10, left: 10, child: Text("A"))]),
)
```
### 修复方案 2:放一个非 Positioned 的底层组件撑起大小
```dart
Stack(
children: [
ColoredBox( // 非 Positioned 底层组件:由它决定 Stack 尺寸
color: Color(0xFFE8E8E8),
child: SizedBox(width: 300.w, height: 80.h),
),
Positioned(top: 10, left: 10, child: Text("A")),
],
)
```
> 经验:**Stack 里永远留一个"地基"**(非 Positioned 的底层组件或外层 SizedBox),
> 既避免崩溃,也方便后续加 Positioned 不担心尺寸突变。
---
## 五、踩坑记录
1. **Positioned 只能直接作为 Stack 的 children**,中间套了其他 Widget 会失效(编译警告)。
2. **做悬浮角标务必 `clipBehavior: Clip.none`**,否则负偏移部分被裁掉看不见。
3. **全 Positioned 的 Stack 在无界约束下崩溃**(见上),这是本页作者真实遇到过的 bug:页面只剩标题。
4. `Container` 在 Flutter 3.44 已不是 const 构造,外层 `Center` 别写 `const`(编译报错)。
5. 想让内容很短也能拉动滚动(演示页效果),用 `physics: BouncingScrollPhysics()` / `AlwaysScrollableScrollPhysics()`。
---
## 六、扩展思考
- 蒙层进阶:`Positioned.fill` + 半透明色 + 点击手势 = 简易弹窗/引导层;
- 角标进阶:红点角标(Badge 组件)本质也是 Stack + Positioned 的封装;
- 与 [list_demo.md](list_demo.md) 对照:列表右下角的"回到顶部"FAB 就是 `Stack + Positioned` 实现的;
- 与 [smooth_sheet_demo.md](smooth_sheet_demo.md) 对照:地图背景 + 底部面板就是"Stack 背景层 + 面板层"的组合。
+148
View File
@@ -0,0 +1,148 @@
# 【Demo】Text 文本组件全家桶
> 源文件:`lib/pages/demo/text_demo.dart` | 分类:基础布局
> 一句话:样式、对齐、截断、行高、富文本、可点击协议文本 —— 文本相关的细节一次看全。
---
## 一、这个 Demo 解决什么问题
Text 是 Flutter 里出现频率最高的组件,但细节坑不少:
- `textAlign` 为什么有时不生效?
- 超长文字怎么显示省略号?
- "已阅读并同意《用户协议》"这种**部分文字可点击**怎么做?
- 系统字体缩放怎么适配?
本页 7 个区块逐一演示。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: TextDemoPage()));
```
1. 基础样式(fontSize / color / fontWeight / fontStyle);
2. 对齐 textAlign(左/中/右);
3. 截断三件套(ellipsis / clip / softWrap:false);
4. 行高 height 对比(默认 / 1.2 / 2);
5. 字间距 letterSpacing / 词间距 wordSpacing;
6. 富文本 Text.rich + TextSpan + 可点击协议;
7. 字体缩放 textScaler。
---
## 三、核心知识点(新手向)
### 1. Text 全家桶速记
| 场景 | 组件 |
|---|---|
| 单样式文字 | `Text` |
| 富文本拼接(不同颜色/可点击段落) | `Text.rich` + `TextSpan` |
| 全局默认样式 | `DefaultTextStyle`(MaterialApp 自带,Text 会继承) |
### 2. textAlign 生效的前提
**Text 必须有宽度约束**,对齐才有空间生效:
```dart
Container(
width: 300.w,
child: Column(
// 关键:stretch 让每个子组件横向拉满,textAlign 才有空间生效
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('左对齐', textAlign: TextAlign.left),
Text('居中对齐', textAlign: TextAlign.center),
Text('右对齐', textAlign: TextAlign.right),
],
),
)
```
> 新手最常见的疑惑:在 Column 里写 `textAlign: center` 不居中 —— 因为 Text 宽度只包住文字。
> 要么 `crossAxisAlignment: center`(让 Text 居中),要么 `stretch` + `textAlign`(让文字在拉满的宽度里居中)。
### 3. 截断三件套(必须配齐)
```dart
Text(
'很长很长...',
maxLines: 1,
overflow: TextOverflow.ellipsis, // 末尾加省略号
)
```
- `ellipsis`:省略号;`clip`:直接裁掉;`softWrap: false` + `visible`:真·溢出画到盒子外;
- 前提同样是**有界宽度**(外面套固定宽 Container)。
### 4. 行高与间距
```dart
TextStyle(height: 1.2) // 行高倍数(1.0 = 紧凑,2.0 = 松散)
TextStyle(letterSpacing: 4) // 字间距(中文按字)
TextStyle(wordSpacing: 8) // 词间距(按空格分词,中文基本无效)
```
### 5. 富文本 + 可点击(协议文本的官方姿势)
```dart
Text.rich(
TextSpan(
children: [
TextSpan(text: '已阅读并同意', style: TextStyle(color: Color(0xFF888888))),
TextSpan(
text: '《用户协议》',
style: TextStyle(color: Color(0xFF3B82F6), fontWeight: FontWeight.bold),
recognizer: _agreementRecognizer, // 点击手势(必须 dispose!)
),
],
),
)
```
`TapGestureRecognizer` 必须放在 **StatefulWidget 里创建,并在 dispose() 里释放**(与 createState 一一对应,防内存泄漏)。
### 6. 字体缩放(Flutter 3.x)
```dart
Text('线性放大 1.5 倍', textScaler: TextScaler.linear(1.5));
// 读取系统缩放:MediaQuery.textScalerOf(context)
```
---
## 四、关键代码剖析
```dart
class _AgreementRichTextState extends State<_AgreementRichText> {
// late final:整个 State 生命周期只创建一次,复用同一个 recognizer
late final TapGestureRecognizer _agreementRecognizer = TapGestureRecognizer()
..onTap = _handleAgreementTap;
void _handleAgreementTap() {
// 点击回调可能晚于 dispose(点击瞬间页面被切走),用 mounted 兜底
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('点了《用户协议》')),
);
}
@override
void dispose() {
_agreementRecognizer.dispose(); // 关键:释放 recognizer
super.dispose();
}
}
```
---
## 五、踩坑记录
1. **`textScaleFactor` 已废弃**(Flutter 3.x)→ 用 `textScaler` / `MediaQuery.textScalerOf(context)`;
2. **`withOpacity` 已废弃** → 用 `withValues(alpha: x)`;
3. textAlign 不生效 = 没有宽度约束(用 stretch 或固定宽);
4. 截断不生效 = 没有 `maxLines`(三件套要配齐);
5. `TapGestureRecognizer` 不 dispose 会内存泄漏(页面反复进出后崩溃/卡顿)。
---
## 六、扩展思考
- 富文本进阶:本项目的 `RichTextContent`([rich_text_demo.md](rich_text_demo.md))渲染 HTML 富文本(题目/解析),
底层就是 TextSpan 体系的自动生成;
- 全局字体缩放:`MaterialApp(builder: ...)` 里统一包 `MediaQuery` 改造,实现"阅读字号调节"(见 rich_text_demo 的 Aa±);
- 文本选择:`SelectableText` 让用户可复制(本项目的日志区就用它)。
+128
View File
@@ -0,0 +1,128 @@
# 【Demo】品牌换肤(两级主题模型)
> 源文件:`lib/pages/demo/theme_demo.dart` | 分类:基础布局
> 一句话:换品牌 = 一级换整套色板 + 二级换页面细节,一套代码全 App 变色。
---
## 一、这个 Demo 解决什么问题
教育 App 常有"多品牌运营"需求:不同机构定制不同主色、页面配色、甚至标题文案。
本 Demo 演示项目核心的**两级主题模型**:
```
一级 = 品牌:seedColor 生成整套 Material 色板(全 App 共享)
二级 = 页面细节(PageTheme):按页面层级(home/detail/result)
区分背景色 / 头部色 / 强调色 / 专属 banner / 标题文案
```
页面只声明"我是哪个层级",细节从品牌对象按层级查出来:
**换品牌 → 一级色板全换 + 二级细节全换(一套代码)**。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ThemeDemoPage()));
```
- 顶部 ChoiceChip 切换品牌(从注册表 `brandThemes` 自动生成按钮,新增品牌自动出现);
- "一级"区:显示 seed 色与生成的 primary 对比 + 跟随主题的按钮/进度条;
- "二级"区:home/detail/result 三个层级各渲染一个页面骨架预览(背景/头部/强调色/文案全跟随品牌)。
---
## 三、核心知识点(新手向)
### 1. 主题的两层结构(模型已解耦,见 `core/theme/`)
| 层 | 内容 | 存放 |
|---|---|---|
| 模型 + 注册表 | `BrandTheme` / `PageTheme` / `PageLevel` + `brandThemes` | `core/theme/brand_theme.dart` |
| 每品牌配置 | 只有具体色值/文案 | `core/theme/brands/xxx_theme.dart` |
新增一个品牌 = 新建一个配置文件 + 注册表加一行,**业务代码零改动**。
### 2. 一级:colorSchemeSeed 的原理
```dart
ThemeData(colorSchemeSeed: _brand.seedColor)
```
传一个种子色,Material 自动生成整套 ColorScheme(primary/secondary/tertiary...),
**没写死颜色的 Material 组件全部自动跟随**(FilledButton/Checkbox/Switch/进度条/聚焦输入框)。
验证页见 [seed_palette_demo.md](seed_palette_demo.md)。
> ⚠ colorSchemeSeed 生成的 primary 不是精确色值(Material 会调和),
> 要求精确用 `ColorScheme.fromSeed(...)` 后手动覆盖 primary。
### 3. 二级:PageTheme 按层级查询
```dart
// 页面只声明层级(本 demo 直接取,真实项目从路由/参数拿)
final page = _brand.pages[PageLevel.home]!;
// 使用:背景 page.bg、头部 page.headerBg、强调 page.accent、banner page.banner
```
- banner 存资源路径(如 `assets/img/banner_sky.png`),页面 `Image.asset(page.banner!)` 即可跟随换图;
- 本 demo 没准备两套图片,用"强调色渐变块"模拟 banner 占位(代码里标注了替换点)。
### 4. 局部主题覆盖(不依赖全局 MaterialApp 的切换法)
```dart
return Theme(
data: ThemeData(colorSchemeSeed: _brand.seedColor),
child: Scaffold( ... ), // 页面内组件都从局部主题取色
);
```
> 真实项目把 `_brand` 换成 Riverpod Provider + MaterialApp 的 theme,切换点移到根层。
---
## 四、关键代码剖析
```dart
// 品牌切换(注册表驱动,新增品牌自动出现)
Wrap(
spacing: 8,
children: [
for (final b in brandThemes)
ChoiceChip(
label: Text(b.name),
selected: _brand.name == b.name,
onSelected: (_) => setState(() => _brand = b),
),
],
)
// 二级细节消费示例(页面骨架预览)
Container(
color: page.bg, // ← 二级:背景
child: Column(children: [
Container(
color: page.headerBg, // ← 二级:头部色
child: Text(page.title), // ← 二级:标题文案
),
// banner 占位:真实项目替换成 Image.asset(page.banner!)
Container(decoration: BoxDecoration(gradient: LinearGradient(colors: [page.accent, page.headerBg]))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: page.accent), // ← 二级:强调色
child: const Text('本页主按钮'),
),
]),
)
```
---
## 五、踩坑记录
1. **colorSchemeSeed 生成的 primary 不精确** —— 需要精确色值时手动覆盖;
2. **局部 `Theme(data:)` 必须包在 Scaffold 外面**,AppBar 才能取到(包在 body 里 AppBar 取不到);
3. **PNG 位图不跟主题着色** —— 能用矢量(IconData)就别放多份图;
4. 深色模式要单独配 `darkTheme` + `themeMode`,不是简单换个 seed;
5. `.w/.h/.sp` 的组件不能 const(`const_eval_extension_method`)。
---
## 六、扩展思考
- 持久化:品牌选择存 shared_preferences,启动时读出来传给 MaterialApp 的 theme;
- 动态切换动画:`AnimatedTheme` 包 MaterialApp,换主题有过渡动画;
- 与 [seed_palette_demo.md](seed_palette_demo.md) 对照:一个验证"seed 生效",一个演示"两级模型的消费";
- 更重的多主题需求可引入 `flex_color_scheme` 包,本项目的轻量模型够用且无依赖。
+144
View File
@@ -0,0 +1,144 @@
# 【Demo】Wrap 自动换行布局 + Chip 标签家族
> 源文件:`lib/pages/demo/wrap_demo.dart` | 分类:基础布局
> 一句话:Row 一行排不下就溢出报错,Wrap 会自动换行 —— 做"标签流"必备。
---
## 一、这个 Demo 解决什么问题
"搜索历史标签"、"科目标签"、"技能标签"这类**数量不固定**的横向排列场景:
- `Row` 一行排不下会**直接溢出报错**(黄色条纹警告),不会换行;
- `Wrap` 空间不够时**自动换到下一行**,是标签流的正解。
本 Demo 还完整演示了 Flutter 的 **Chip 标签家族**:`Chip` / `ActionChip` / `FilterChip`,
以及一个**像素级可控的自绘标签 `CustomTag`**(设计稿要求精确高度/间距时的选择)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: WrapDemoPage()));
```
页面是一个 Wrap,里面混排了 8 种 Chip 样式 + 自绘 CustomTag:
带图标、可删除、可点击、选中态、深色底、胶囊圆角……一屏看完所有变体。
---
## 三、核心知识点(新手向)
### 1. Wrap 的核心属性
```dart
Wrap(
direction: Axis.horizontal, // 排布方向(默认水平)
spacing: 24, // 同一行内 item 之间的水平间距
runSpacing: 20, // 行与行之间的垂直间距(run = "行/批")
children: [...],
)
```
### 2. 对比记忆
| 组件 | 行为 |
|---|---|
| `Row` | 横向排列,**溢出报错**,不换行 |
| `Wrap` | 横向排列,**空间不足自动换行** |
| `Column` | 纵向排列,溢出报错(想纵向换行用 `Wrap(direction: Axis.vertical)`) |
### 3. Chip 家族速查(Flutter 3.x 注意!)
| 组件 | 用途 | 必填回调 |
|---|---|---|
| `Chip` | 纯展示 / 可删除标签 | `onDeleted` 可选 |
| `ActionChip` | 可点击的标签(**旧 `Chip.onPressed` 已移除**) | `onPressed` |
| `FilterChip` | 带选中态的标签(**旧 `Chip.selected` 已移除**) | `onSelected` |
```dart
// 可删除
Chip(
label: const Text("Flutter"),
deleteIcon: Icon(Icons.close, size: 16.w),
onDeleted: () => print("删除 Flutter"),
)
// 可点击(Flutter 3.x 用 ActionChip)
ActionChip(
label: const Text("UniApp"),
onPressed: () => print("点击 UniApp"),
)
// 选中态(Flutter 3.x 用 FilterChip)
FilterChip(
label: const Text("JavaScript"),
selected: true,
selectedColor: const Color(0xFF4CAF50),
onSelected: (_) {},
)
```
### 4. 自绘标签 CustomTag 为什么存在
标准 Chip 的高度/内边距是 Material 规范定的,**设计稿要求"固定 24px 高、图标与文字精确 4px"时**,
Chip 调不精确 —— 这时用 `Container + Row` 手写一个,完全可控:
- `InkWell` 提供点击涟漪(`borderRadius` 让涟漪跟随圆角);
- `TextStyle(height: 1)` 去掉文字默认行高,避免把容器撑高。
---
## 四、关键代码剖析
```dart
// 自绘标签骨架(像素级可控的核心结构)
return Material(
color: Colors.transparent, // 消除 InkWell 默认的白底
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4.r),
child: Container(
height: 24.h, // 精确高度
padding: EdgeInsets.only(left: 6.w, right: 6.w),
decoration: BoxDecoration(
color: const Color(0xFFEDF2FF),
borderRadius: BorderRadius.circular(4.r),
border: Border.all(color: const Color(0xFFD6E4FF)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12.w, color: iconColor),
SizedBox(width: 4.w), // 图标与文字精确 4px
],
Text(label, style: TextStyle(fontSize: 12.sp, height: 1)),
if (onDeleted != null) ...[
SizedBox(width: 2.w),
GestureDetector(onTap: onDeleted, child: Icon(Icons.close, size: 12.w)),
],
],
),
),
),
)
```
要点:
- `Material + InkWell` 组合才有水波纹;`InkWell` 直接包 `Container` 时涟漪会被容器背景盖住;
- 用 `if (xx != null) ...[]` 实现"可选 icon / 可选删除叉"——传 `null` 就不渲染。
---
## 五、踩坑记录
1. **`Chip.onPressed` / `Chip.selected` 在 Flutter 3.x 已移除**,分别改用 `ActionChip` / `FilterChip`(旧代码会编译报错)。
2. `Wrap` 外层要给它**有界的宽度**(比如直接当 body 用,或放 `Padding` 里),否则换行无从谈起。
3. 自绘标签里文字务必 `height: 1`,否则默认行高会让"24px 高"变成"30px+ 高"。
4. `print` 只适合 Demo 调试,真实项目用 `debugPrint` 或日志组件(见 dio_demo 的注释)。
---
## 六、扩展思考
- **`Flow`**:Wrap 的高阶版,自定义换行算法(间距均匀分布、瀑布流),性能更好但代码量大;
- 标签流 + 搜索历史的真实场景:数据存本地(shared_preferences)→ 读取渲染 Wrap → 点击标签直接搜索;
- Chip 的 `shape: StadiumBorder()` = 胶囊全圆角,`avatar` 放小图标、`labelPadding` 控制文字内边距;
- 本项目 `theme_demo` 里的品牌切换、`note_picker_demo` 里的"加号格",都用了 Wrap/网格思路,可对照学习。
+128
View File
@@ -0,0 +1,128 @@
# 【Demo】接口 + Isar + watch 完整链路(答错记错题)
> 源文件:`lib/pages/demo/wrong_flow_demo.dart` | 分类:存储
> 一句话:回答"现在不都调接口吗?" —— 接口、本地库、响应式三层协作的真实业务链路。
---
## 一、这个 Demo 解决什么问题
答错一题,要同时完成三件事:
1. **接口上报**(远端):服务端记录错题 —— 可能失败、可能慢;
2. **本地落库**(Isar):错题本快照,**离线也能看**;
3. **watch 响应式**:本地库一变,错题列表自动刷新。
```
流程:答错 → ① 上报接口(失败也继续)→ ② 写本地错题本 → ③ watch 自动推送 → 列表自动更新
```
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: WrongFlowDemoPage()));
```
- 三道内置题,答错 → 完整链路日志(接口 → 本地 → watch);
- "模拟断网"按钮:跳过接口,只写本地(演示离线兜底);
- 下方错题本列表:watch 订阅,零手动刷新。
> ⚠ 依赖 Isar(native only),Web 端降级提示。
---
## 三、核心知识点(新手向)
### 1. 为什么接口失败本地仍记录(离线兜底)
学生端弱网是常态。**错题是"本地刚需数据"**——先落库先能用;
联网后再**增量同步**回服务端(真实项目用 sync 队列,错题加 `isSynced` 字段标记,
本例为演示未做,代码里有注释)。
```dart
// ① 上报接口:失败也继续(本地兜底)
await _reportToServer(wrongAnswer: _options[index]);
// ② 写本地错题本:watch 自动推送 → 下方列表自动更新
await WrongQuestionStore.instance.add(
questionId: 100 + _qIndex,
title: _question,
wrongAnswer: _options[index],
);
```
### 2. 别把本地写入放接口 onError 里!
```dart
try {
await DioClient.instance.post('/posts', data: {...});
_appendLog('📡 接口上报成功');
} on ApiException catch (e) {
// 接口失败不阻断:本地仍要记,联网后再同步
_appendLog('📡 接口上报失败:${e.message}');
_appendLog(' ↳ 本地仍记录,联网后增量同步(sync 队列)');
} finally {
if (mounted) setState(() => _reporting = false);
}
```
**独立的两步**:上报和落库互不依赖。
如果写在 onError 里,就成了"只有失败才记录"——在线答错的题反而不进错题本,逻辑就反了。
### 3. 对比"纯接口 + 手动刷新"
不 watch 就得:答完错题 → 手动通知错题 tab 刷新(事件总线/页面重建)。
watch 让 UI 直接订阅数据库,**写入和展示彻底解耦**。
### 4. Dart 3 记录类型题库
```dart
// 题库:(题干, 选项, 正确答案索引)
static const List<(String, List<String>, int)> _bank = [
('7 × 8 = ?', ['54', '56', '58', '63'], 1),
('Flutter 官方推荐的状态管理?', ['Provider', 'Riverpod', 'BLoC', 'GetX'], 1),
];
String get _question => _bank[_qIndex].$1; // 题干
List<String> get _options => _bank[_qIndex].$2; // 选项
int get _rightIndex => _bank[_qIndex].$3; // 正确答案索引
```
---
## 四、关键代码剖析
```dart
// 答题:对 → 不记录;错 → 接口上报 + 本地错题(watch 自动刷新)
Future<void> _onPick(int index) async {
if (_reporting) return; // 上报中防重入
final isRight = index == _rightIndex;
setState(() { _selected = index; _isRight = isRight; });
if (isRight) {
_appendLog('✅ 回答正确,不记错题');
} else {
_appendLog('❌ 答错「${_question}」(选了 ${_options[index]}),进入链路...');
await _reportToServer(wrongAnswer: _options[index]); // ① 上报
await WrongQuestionStore.instance.add(...); // ② 落库
_appendLog('💾 已写入本地错题本 → watch 自动刷新列表');
}
if (!mounted) return;
setState(() { _qIndex = (_qIndex + 1) % _bank.length; _selected = null; _isRight = null; });
}
```
---
## 五、踩坑记录
1. **watch 只在 native 可用**(web 空实现),本页 `kIsWeb` 降级提示;
2. **写库必须在 writeTxn 内**(store 已封装);
3. **接口上报和本地写入是两个独立步骤**,别把本地写入放接口 onError 里;
4. 上报中要防重入(`_reporting` 禁用选项点击),否则连点会重复上报;
5. 异步(await)之后 setState 前判 mounted。
---
## 六、扩展思考
- **sync 队列进阶**:错题表加 `isSynced` 字段 → 启动/联网时查 `isSynced == false` 批量补报 → 成功后置位;
- 与 [isar_watch_demo.md](isar_watch_demo.md) 对照:一个讲"watch 本身",一个讲"完整业务链路";
- 上报失败的重试策略可复用 `DioClient` 的指数退避(见 [dio_pro_demo.md](dio_pro_demo.md));
- 真实项目:本页的 `_bank` 换成接口拉取的题,`WrongQuestionStore.add` 换成带 userId 的写入。
+605
View File
@@ -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 → 写组件 → 页面桥接。
+712
View File
@@ -0,0 +1,712 @@
# Flutter 学习全旅程讲解 — 从零到生产级项目
> **预计时长:40 分钟** | **项目:`new_learn`** | **Flutter 3.44.x + Dart 3.12**
---
## 目录
| 章节 | 主题 | 预估时长 |
|------|------|----------|
| 一 | 环境安装与项目初始化 | ~4 min |
| 二 | Dart 3 语法核心要点 | ~4 min |
| 三 | Flutter 3 核心框架与分层架构 | ~5 min |
| 四 | 全局状态管理:Riverpod 方案 | ~4 min |
| 五 | Dio 网络请求生产级封装 | ~4 min |
| 六 | 屏幕适配:ScreenUtil 方案 | ~3 min |
| 七 | 多品牌主题配置体系 | ~3 min |
| 八 | 多环境变量配置体系 | ~3 min |
| 九 | 本地存储:Isar + 响应式 watch | ~3 min |
| 十 | Demo 组件封装思路汇总 | ~5 min |
| 十一 | 学习路径与心得总结 | ~2 min |
---
## 一、环境安装与项目初始化(~4 min)
### 1.1 工具链一览
| 工具 | 版本 | 用途 |
|------|------|------|
| Flutter SDK | 3.44.x | 编译、热重载、多端构建 |
| Dart SDK | 3.12 | 语言运行时 |
| Android Studio / VS Code | 最新 | IDE + 插件 |
| Android SDK | API 34+ | 安卓构建 |
| Xcode | 15+(Mac) | iOS 构建 |
> 本项目路径:`D:\dev\flutter_3.44.8\flutter`(见 [local.properties](file:///d:/flutter_learn/new_learn/android/local.properties))
### 1.2 创建项目
```bash
flutter create --org com.example --platforms=android,ios,web,windows new_learn
```
### 1.3 项目核心依赖
完整依赖见 [pubspec.yaml](file:///d:/flutter_learn/new_learn/pubspec.yaml),关键项:
```yaml
environment:
sdk: ^3.12.2
dependencies:
flutter: { sdk: flutter }
dio: ^5.10.0 # 网络请求
go_router: ^17.4.0 # 声明式路由
flutter_riverpod: ^3.3.2 # 状态管理
flutter_screenutil: ^5.9.0 # 屏幕适配
cached_network_image: ^3.4.1 # 网络图片缓存(ImageDemo)
infinite_scroll_pagination: ^5.1.1 # 分页列表
flutter_widget_from_html_core: ^0.17.2 # 富文本 HTML 渲染
shared_preferences: ^2.5.3 # 轻量本地存储
flutter_secure_storage: ^9.2.4 # 敏感凭据安全存储
isar_community: ^3.3.2 # 本地数据库
isar_community_flutter_libs: ^3.3.2
path_provider: ^2.1.5
wechat_assets_picker: ^9.8.0 # 相册多选/预览(原生端)
wechat_camera_picker: ^4.6.0 # 微信风格相机(原生端)
image_picker: ^1.2.3 # Web 兜底图片选择
permission_handler: ^11.3.1 # 权限管理
image: ^4.8.0 # 纯 Dart 图片处理(含 Web)
flutter_image_compress: ^2.5.1 # 原生压缩(Android/iOS)
photo_view: ^0.15.0 # 图片预览缩放
video_player: ^2.13.0 # 视频播放
chewie: ^1.13.1 # 视频 UI 封装
just_audio: ^0.10.6 # 音频播放
share_plus: ^10.1.4 # 系统分享
gal: ^2.3.3 # 保存到相册
package_info_plus: ^8.3.0 # 版本信息
url_launcher: ^6.3.1 # 外部跳转
smooth_sheets: ^1.1.1 # 常驻底部面板
crypto: ^3.0.6 # MD5 等加密
# 完整依赖与注释说明以 pubspec.yaml 为准(此处为学习旅程的“关键依赖速览”)
```
### 1.4 目录结构
```
lib/
├── main.dart # 启动入口(5步初始化)
├── core/ # 核心基础设施层
│ ├── app.dart # 根组件 TabApp
│ ├── config/ # 环境+品牌配置
│ ├── network/ # Dio 封装 + API
│ ├── providers/ # Riverpod 全局状态
│ ├── router/ # go_router 路由表
│ ├── theme/ # 多品牌主题
│ ├── storage/ # Isar + 安全存储
│ ├── services/ # 图片/选择/分享服务
│ └── components/ # 9个可复用组件
├── features/ # 业务功能层
│ ├── auth/ # 登录+隐私协议
│ ├── exam/ # 试卷练习
│ ├── my/ # 个人中心
│ └── ...
└── pages/demo/ # 28个独立学习Demo
```
> **讲解要点**:这套 `core / features / pages` 三层分离,对标前端 `utils / views / components`,让基础设施和业务逻辑彻底解耦。
---
## 二、Dart 3 语法核心要点(~4 min)
### 2.1 与 JavaScript/TypeScript 的关键差异
| 概念 | JS/TS | Dart 3 |
|------|-------|--------|
| 类型声明 | `let x: number` | `int x` / `var x`(类型推断)|
| 空安全 | 可选 `?` | 健全空安全 `String?`,编译期拦截 |
| 异步 | Promise | `Future` + `async/await` |
| 集合 | Array/Map | `List<T>` / `Map<K,V>` / `Set<T>` |
| 类 | class | class + `mixin` 混入 |
| 模式匹配 | 无 | `switch` 表达式 + 解构(Dart 3 新特性)|
### 2.2 空安全(Sound Null Safety)
```dart
String name = 'Flutter'; // 不可为 null
String? nickname; // 可为 null,使用前必须判空
// 安全调用
int len = nickname?.length ?? 0; // ?. + ?? 兜底
```
> **核心规则**:所有变量默认非空,`?` 才允许 null。编译期就帮你拦住空指针。
### 2.3 异步编程
`fromJson` 是构造函数,把字典转换成我们定义的 User 实体类对象,获得类型安全、点访问、编译检查。
```dart
Future<User> fetchUser() async {
final response = await dio.get('/user');
return User.fromJson(response.data);
}
```
### 2.4 模式匹配(Dart 3 特性)
```dart
// switch 表达式
String describe(Object obj) => switch (obj) {
int() => '整数',
String() => '字符串',
List<int>() => '整数列表',
_ => '未知',
};
```
### 2.5 类与混入
```dart
mixin Disposable {
void dispose();
}
class MyController with Disposable {
@override
void dispose() { /* 释放资源 */ }
}
```
> **讲解要点**:Dart 3 的空安全是「健全」的,编译期保证非空变量不会拿到 null,比 TS 的可选链更彻底。模式匹配让分支逻辑更简洁。
---
## 三、Flutter 3 核心框架与分层架构(~5 min)
### 3.1 Widget 是什么?
**核心认知**:Widget 是「配置对象」不是「渲染 View」。
```
Widget(配置蓝图)→ Element(实例化树节点)→ RenderObject(真正测量布局绘制)
```
- `StatelessWidget`:纯展示,props 不变 → 如 `Text`、`Icon`
- `StatefulWidget`:有内部状态,`setState` 触发重建 → 如 `Checkbox`、`TextField`
### 3.2 核心布局原理
> **Flutter 布局第一法则**:父组件向子组件传递**约束(Constraints)**,子组件根据约束**决定自身大小(Size)**,然后向上返回。
```
父 Widget ──Constraints──> 子 Widget
↓ 决定自身大小
父 Widget <──Size────────── 子 Widget
```
- **主轴**(MainAxis):Row 的水平方向 / Column 的垂直方向
- **交叉轴**(CrossAxis):与主轴垂直的方向
### 3.3 启动流程链路
入口文件 [main.dart](file:///d:/flutter_learn/new_learn/lib/main.dart):
```
main() 5步串行初始化
↓
1. WidgetsFlutterBinding.ensureInitialized() // 插件通道就绪
2. GoRouter.optionURLReflectsImperativeAPIs // 修复 web 地址栏同步
3. DioClient.instance.restoreToken() // 回填登录 Token
4. privacyAcceptedNotifier // 恢复隐私协议状态
5. AppDatabase.instance.init() // 初始化 Isar
↓
runApp(ProviderScope(child: TabApp())) // Riverpod 容器 + 根组件
```
根组件 [app.dart](file:///d:/flutter_learn/new_learn/lib/core/app.dart):
```
TabApp (StatelessWidget)
└─ ScreenUtilInit // 屏幕适配
└─ MaterialApp.router // Material3 + 品牌种子色
├─ builder: PrivacyStartupGate // 启动层隐私拦截
└─ routerConfig: appRouter // 全局路由表
```
### 3.4 路由体系
路由表 [app_router.dart](file:///d:/flutter_learn/new_learn/lib/core/router/app_router.dart):
```
GoRouter
├─ redirect: _guardLogin // 登录守卫(对标 vue-router beforeEach)
│ ├─ 未同意隐私 → /privacy-consent
│ ├─ 已同意+在隐私页 → /login 或 /
│ ├─ 未登录 → /login
│ └─ 已登录+在登录页 → /
├─ refreshListenable: authTick + privacyNotifier // 响应式守卫
├─ ShellRoute (4 tab 壳)
│ ├─ / → TaskPage
│ ├─ /recite → RecitePage
│ ├─ /class → ClassPage
│ └─ /my → MyPage
├─ /privacy-consent
├─ /login
└─ /myPoint
```
> **讲解要点**:go_router 的 `refreshListenable` 是核心 — 登录态变化时自动 `refresh()` 重算 redirect,不需要手动跳转,对标 Vue Router 的响应式 `beforeEach`。
---
## 四、全局状态管理:Riverpod 方案(~4 min)
### 4.1 为什么选 Riverpod?
| 维度 | Provider(旧) | Riverpod 3.x |
|------|----------------|--------------|
| 依赖注入 | `context.read()` 耦合 BuildContext | 全局容器,不依赖 context |
| 测试 | 难 mock | `ProviderContainer` 轻量覆盖 |
| 安全 | 运行时报错 | 编译期检查(Provider 不可缺)|
| 异步 | 手写 FutureBuilder | AsyncNotifier 内建 loading/error/data |
### 4.2 本项目的两种状态方案
#### 方案 A:ValueNotifier — 轻量全局信号
隐私协议状态 [privacy_provider.dart](file:///d:/flutter_learn/new_learn/lib/core/providers/privacy_provider.dart):
```dart
final ValueNotifier<bool> privacyAcceptedNotifier = ValueNotifier<bool>(false);
```
> **为什么不用 Riverpod?** go_router 的 `refreshListenable` 需要 `Listenable`,ValueNotifier 足够轻量,且 `main()` 中 `runApp` 前就能直接恢复状态。
#### 方案 B:Riverpod AsyncNotifier — 全局用户状态
用户状态 [user_provider.dart](file:///d:/flutter_learn/new_learn/lib/core/providers/user_provider.dart):
```dart
class CurrentUserNotifier extends AsyncNotifier<CurrentUserExtension?> {
@override
Future<CurrentUserExtension?> build() async {
// 冷启动异步从本地缓存回填
return await UserStorage.instance.readUser();
}
void set(user) {
state = AsyncData(user); // 有值
authTick.value++; // 触发路由守卫重算
}
}
// 全局 Provider
final currentUserProvider =
AsyncNotifierProvider<CurrentUserNotifier, CurrentUserExtension?>(...);
```
状态四态生命周期:
```
AsyncLoading() → 正在读缓存
AsyncData(user) → 有值(已登录)
AsyncData(null) → 明确未登录
AsyncError(e) → 异常
```
> **讲解要点**:`authTick`(ValueNotifier<int>)是登录态变化的信号源,Riverpod 写状态、ValueNotifier 通知路由,两者协作实现「响应式登录守卫」。
---
## 五、Dio 网络请求生产级封装(~4 min)
### 5.1 封装架构
核心文件 [dio_client.dart](file:///d:/flutter_learn/new_learn/lib/core/network/dio_client.dart):
```
DioClient (单例)
├─ BaseOptions
│ ├─ baseUrl → 来自 AppConfig(品牌×环境组合)
│ └─ 超时:连接 15s / 接收 15s / 发送 15s
├─ 拦截器三件套
│ ├─ onRequest → 自动加 Authorization: Bearer token
│ ├─ onResponse → 日志打印
│ └─ onError → 401清Token + DioException→ApiException
├─ 泛型请求方法
│ ├─ get<T>(path, {cancelToken, queryParameters})
│ ├─ post<T>(path, {data, cancelToken, onSendProgress})
│ └─ put<T>(path, {data})
└─ restoreToken() → 冷启动回填 Token
```
### 5.2 统一异常翻译
```dart
// UI 层只需要 catch ApiException,不用管 DioException
ApiException _mapError(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
return ApiException('网络连接超时,请检查网络');
case DioExceptionType.connectionError:
return ApiException('网络连接失败,请检查网络');
// ... badResponse / cancel / unknown
}
}
```
### 5.3 生产级能力(Demo 演示)
| 能力 | 实现方式 | Demo |
|------|----------|------|
| 下载进度 | `onReceiveProgress` 回调 | [dio_pro_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/dio_pro_demo.dart) |
| 取消请求 | `CancelToken` + dispose 自动取消 | 同上 |
| 失败重试 | 指数退避 `_canRetry`(仅网络层错误)| 同上 |
| Token 自动注入 | 拦截器 onRequest | dio_client.dart |
| 401 自动登出 | 拦截器 onError | dio_client.dart |
| OSS 直传 | 已有 authorization 头时跳过注入 | [oss_uploader.dart](file:///d:/flutter_learn/new_learn/lib/core/network/oss_uploader.dart) |
### 5.4 数据模型 fromJson
```dart
// user_model.dart — 防御性解析,兼容后端返回字符串数字
factory CurrentUserExtension.fromJson(Map<String, dynamic> json) {
return CurrentUserExtension(
userId: json['userId'] as int,
userNo: json['userNo']?.toString() ?? '',
level: _toInt(json['level']), // 兼容 "1" 或 1
balance: _toDouble(json['balance']),
// ... 17 个字段
);
}
```
> **讲解要点**:`fromJson` 用 `_toInt` / `_toDouble` 兜底,是真实项目中最常见的后端类型不稳定问题。UI 层永远只 catch `ApiException` 一个类型。
---
## 六、屏幕适配:ScreenUtil 方案(~3 min)
### 6.1 核心文件
适配模板 [screen_adapt_template.dart](file:///d:/flutter_learn/new_learn/lib/core/utils/screen_adapt_template.dart):
```
AppAdapt 工具类
├─ tabletBreakpoint = 600 // Material 平板断点
├─ phoneDesign = Size(375, 812) // iPhone 逻辑尺寸
├─ designSizeOf(context) // 方案B:平板用设备自身尺寸
└─ fontSizeResolver // 方案A:字体放大封顶 1.2 倍
```
### 6.2 双方案策略
| 方案 | 目标 | 实现 |
|------|------|------|
| 方案 A | 大屏字体不过分放大 | `fontSizeResolver` 封顶 `maxFontScale=1.2` |
| 方案 B | 平板布局不被拉伸 | 宽高都 ≥600dp 时用设备自身尺寸做 designSize,缩放回到 ~1 |
### 6.3 使用规则
```dart
// 字号:用 .spMin(大屏只缩不放)
Text('你好', style: TextStyle(fontSize: 16.spMin));
// 宽度/横向间距:用 .w
SizedBox(width: 16.w);
// 高度/纵向:用 .h
SizedBox(height: 48.h);
// 圆角:用 .r
BorderRadius.circular(8.r);
```
> **讲解要点**:字号必须用 `.spMin` 而非 `.sp` — `.sp` 在大屏会把字体放得过大,`.spMin` 确保只缩小不放大。
---
## 七、多品牌主题配置体系(~3 min)
### 7.1 三层解耦设计
```
环境(接口在哪) → app_env.dart 编译期 --dart-define 定死
品牌(谁家 App) → app_brand.dart 发布期定死,7个品牌
主题(长什么样) → brand_theme.dart 运行时可切换
配置聚合 → app_config.dart 全项目唯一取配置入口
```
### 7.2 两级主题模型
模型文件 [brand_theme.dart](file:///d:/flutter_learn/new_learn/lib/core/theme/brand_theme.dart):
```
BrandTheme(一级品牌)
├─ name: 'SkyBlue'
├─ seedColor: Color(0xFF4FC3F7) // 种子色 → Material3 自动生成整套色板
└─ pages: Map<PageLevel, PageTheme>
├─ home: PageTheme { bg, headerBg, accent, title, banner }
├─ detail: PageTheme { ... }
└─ result: PageTheme { ... }
```
品牌主题文件:
- [sky_blue_theme.dart](file:///d:/flutter_learn/new_learn/lib/core/theme/brands/sky_blue_theme.dart) — seedColor `0xFF4FC3F7`
- [red_theme.dart](file:///d:/flutter_learn/new_learn/lib/core/theme/brands/red_theme.dart) — seedColor `0xFFEF4444`
### 7.3 新增主题三步走
```
1. 在 brands/ 下新建 xxx_theme.dart → 定义 seedColor + 三层级配置
2. 在 brand_theme.dart 中 import + 添加到 brandThemes 列表
3. 在 app_brand.dart 中给品牌指定 themeId
```
> **讲解要点**:「模型文件不写颜色、品牌文件不写逻辑」— 新增主题零改页面代码。`colorSchemeSeed` 让 Material 3 自动从种子色生成完整配色。
---
## 八、多环境变量配置体系(~3 min)
### 8.1 编译期注入
环境配置 [app_env.dart](file:///d:/flutter_learn/new_learn/lib/core/config/app_env.dart):
```dart
// 编译期注入,默认 prod
static const String env = String.fromEnvironment('ENV', defaultValue: 'prod');
static const String brand = String.fromEnvironment('BRAND', defaultValue: 'aixue');
```
### 8.2 三套环境地址
| 环境 | baseUrl | 用途 |
|------|---------|------|
| dev | `k12-student-api-test.23544.com:16400` | 本地开发联调 |
| test | `axdb-test.cnzhyx.cn` | 测试环境 |
| prod | `ylax-api.cqbzyx.com`(被品牌专属地址覆盖)| 生产 |
### 8.3 运行命令
```bash
# 开发环境 + 爱学蝶变品牌
flutter run --dart-define=ENV=dev --dart-define=BRAND=aixue
# 生产环境 + 楚才品牌
flutter run --dart-define=ENV=prod --dart-define=BRAND=chucai
# 构建 APK
flutter build apk --dart-define=ENV=prod --dart-define=BRAND=chucai
```
### 8.4 配置聚合层
[app_config.dart](file:///d:/flutter_learn/new_learn/lib/core/config/app_config.dart) — 全项目唯一取配置入口:
```dart
// baseUrl 规则:prod 用品牌专属地址覆盖,否则环境通用地址
static String get baseUrl =>
AppEnv.isProd ? (currentBrand.baseUrl ?? AppEnv.baseUrl) : AppEnv.baseUrl;
// 主题:按品牌查主题注册表,未知兜底第一个
static BrandTheme get theme =>
brandThemes.firstWhere((t) => t.name == currentBrand.themeId,
orElse: () => brandThemes.first);
```
> **讲解要点**:`String.fromEnvironment` 是编译期常量,不占运行时开销。品牌 × 环境组合让一套代码出 7 个品牌的 App,只需改运行命令。
---
## 九、本地存储:Isar + 响应式 watch(~3 min)
### 9.1 存储组合
| 存储方式 | 用途 | 文件 |
|----------|------|------|
| `flutter_secure_storage` | Token 安全存储 | [user_storage.dart](file:///d:/flutter_learn/new_learn/lib/core/storage/user_storage.dart) |
| `shared_preferences` | 隐私协议同意状态 | [privacy_storage.dart](file:///d:/flutter_learn/new_learn/lib/core/storage/privacy_storage.dart) |
| `isar_community` | 题库/错题/播放记录 | [app_database.dart](file:///d:/flutter_learn/new_learn/lib/core/storage/database/app_database.dart) |
### 9.2 Isar 数据模型
```dart
@collection
class WrongQuestion {
Id id = Isar.autoIncrement;
late int questionId;
late int userId;
late String questionTitle;
late DateTime createdAt;
}
```
> 通过 `build_runner` 生成 `.g.dart`,提供类型安全的 CRUD。
### 9.3 平台分流实现
```
app_database.dart → 抽象接口
├─ app_database_io.dart → 原生平台 Isar 实现
└─ app_database_web.dart → Web 调试自动跳过(Isar 不支持 Web)
```
### 9.4 watch 响应式 — 错题自动刷新
Demo 演示 [isar_watch_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/isar_watch_demo.dart):
```dart
// Isar watch:数据变化自动通知 UI 刷新
Stream<List<WrongQuestion>> watchWrongQuestions() {
return database.wrongQuestions
.where()
.userIdEqualTo(userId)
.watch(fireImmediately: true);
}
```
> **讲解要点**:Isar 的 `watch()` 是真正的响应式 — 数据库写入后自动通知 Stream 监听者,UI 无需手动刷新。对标前端的「数据驱动视图」,但是是本地数据库级别的。
---
## 十、Demo 组件封装思路汇总(~5 min)
### 10.1 28 个 Demo 的分类学习路径
#### 第一阶段:基础组件
| Demo | 核心知识点 | 代码 |
|------|-----------|------|
| Text | 样式/截断/富文本/手势 | [text_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/text_demo.dart) |
| Image | 四种来源 + BoxFit + 错误兜底 | [image_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/image_demo.dart) |
| Input | controller + dispose + 密码显隐 | [input_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/input_demo.dart) |
| Align | 对齐 + Riverpod 全局信息 | [align_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/align_demo.dart) |
| Stack | 层叠 + 全 Positioned 尺寸坑 | [stack_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/stack_demo.dart) |
| Wrap | 自动换行 + Chip + 自绘 CustomTag | [wrap_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/wrap_demo.dart) |
#### 第二阶段:布局进阶
| Demo | 核心知识点 | 代码 |
|------|-----------|------|
| List | 懒加载/下拉刷新/触底加载 | [list_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/list_demo.dart) |
| Grid | count/builder/extent 三种构造 | [grid_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/grid_demo.dart) |
| Sliver | CustomScrollView + 折叠 AppBar | [sliver_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/sliver_demo.dart) |
| Responsive | 手机单列 vs Pad 多列 | [responsive_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/responsive_demo.dart) |
#### 第三阶段:路由与表单
| Demo | 核心知识点 | 代码 |
|------|-----------|------|
| Router | 命名路由表 + 三种跳转姿势 | [router_home_page.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/router_home_page.dart) |
| Detail | 构造传参 vs 命名路由 arguments | [detail_page.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/detail_page.dart) |
| Form | 7 个校验知识点/两段式提交 | [form_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/form_demo.dart) |
| Picker | pop 带值返回 | [picker_page.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/picker_page.dart) |
#### 第四阶段:网络与数据
| Demo | 核心知识点 | 代码 |
|------|-----------|------|
| Dio | GET/POST/错误兜底三态 UI | [dio_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/dio_demo.dart) |
| Dio Pro | 下载进度/CancelToken/重试 | [dio_pro_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/dio_pro_demo.dart) |
| Isar Watch | 错题本自动刷新 | [isar_watch_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/isar_watch_demo.dart) |
| Wrong Flow | 接口+Isar+watch 完整链路 | [wrong_flow_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/wrong_flow_demo.dart) |
| OSS Upload | 客户端签名+PUT直传 | [oss_upload_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/oss_upload_demo.dart) |
#### 第五阶段:主题与媒体
| Demo | 核心知识点 | 代码 |
|------|-----------|------|
| Theme | 两级主题模型 + 品牌换肤 | [theme_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/theme_demo.dart) |
| Seed Palette | colorSchemeSeed 自动生成配色 | [seed_palette_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/seed_palette_demo.dart) |
| Animation | 隐式/显式/Hero/CustomPaint | [animation_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/animation_demo.dart) |
| Rich Text | HTML 题目渲染 + audio 替换 | [rich_text_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/rich_text_demo.dart) |
| Audio | just_audio 控制条 | [audio_player_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/audio_player_demo.dart) |
| Share | 文本/图片/截图海报/存相册 | [share_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/share_demo.dart) |
| Smooth Sheet | 常驻多停靠底部面板 | [smooth_sheet_demo.dart](file:///d:/flutter_learn/new_learn/lib/pages/demo/smooth_sheet_demo.dart) |
### 10.2 核心组件封装思路(9个可复用组件)
| 组件 | 封装思路 | 代码 |
|------|----------|------|
| **PrivacyStartupGate** | 启动层隐私 Gate — 冷启动不闪登录页,视觉第一拦 | [privacy_startup_gate.dart](file:///d:/flutter_learn/new_learn/lib/core/components/privacy_startup_gate.dart) |
| **CommonPagedList** | 通用分页列表 — 基于 infinite_scroll_pagination,对标 z-paging | [common_paged_list.dart](file:///d:/flutter_learn/new_learn/lib/core/components/common_paged_list.dart) |
| **AudioPlayerCard** | 通用音频控制条 — 播放/进度/倍速/循环,切后台自动暂停 | [audio_player_card.dart](file:///d:/flutter_learn/new_learn/lib/core/components/audio_player_card.dart) |
| **VideoPlayerView** | 通用视频播放器内嵌版 — 无 Scaffold,可嵌入任意位置 | [video_player_view.dart](file:///d:/flutter_learn/new_learn/lib/core/components/video_player_view.dart) |
| **ImagePreviewViewer** | 通用图片预览 — photo_view 双指缩放 + Hero 动画 | [image_preview_viewer.dart](file:///d:/flutter_learn/new_learn/lib/core/components/image_preview_viewer.dart) |
| **RichTextContent** | 富文本渲染 — HTML + audio/video 自动替换为播放器 | [rich_text_content.dart](file:///d:/flutter_learn/new_learn/lib/core/components/rich_text_content.dart) |
| **PersistentSnapSheet** | 常驻底部面板 — smooth_sheets 封装 | [persistent_snap_sheet.dart](file:///d:/flutter_learn/new_learn/lib/core/components/persistent_snap_sheet.dart) |
| **AppUpdateDialog** | 版本更新弹窗 — 强制/可选 + 平台分流跳转 | [app_update_dialog.dart](file:///d:/flutter_learn/new_learn/lib/core/components/app_update_dialog.dart) |
### 10.3 服务层封装思路(3个核心服务)
| 服务 | 封装思路 | 代码 |
|------|----------|------|
| **PickerService** | 图片选择统一服务 — Web用image_picker / 原生用微信选择器 + 权限守卫 | [picker_service.dart](file:///d:/flutter_learn/new_learn/lib/core/services/picker_service.dart) |
| **ImageProcessor** | 图片压缩 — 原生走flutter_image_compress / Web走纯Dart image包(Isolate隔离)| [image_processor.dart](file:///d:/flutter_learn/new_learn/lib/core/services/image_processor.dart) |
| **ShareService** | 分享服务 — 截图Widget渲染树→PNG→分享/存相册 | [share_service.dart](file:///d:/flutter_learn/new_learn/lib/core/services/share_service.dart) |
### 10.4 封装设计原则
1. **平台分流**:同一接口,io/web 双实现,条件 import自动选择
2. **资源释放**:所有 `TextEditingController` / `StreamSubscription` / `CancelToken` / `AnimationController` 在 `dispose` 中销毁
3. **防御性解析**:`fromJson` 兜底后端类型不稳定(`_toInt` / `_toDouble`)
4. **单一职责**:组件只管渲染,数据逻辑下沉到 Provider/Service
5. **配置驱动**:环境/品牌/主题互不依赖,全项目统一走 `AppConfig` 取配置
---
## 十一、学习路径与心得总结(~2 min)
### 11.1 推荐学习顺序
```
第一阶段:Widget 基础
Text → Image → Input → Align → Stack → Wrap
↓
第二阶段:布局与列表
List → Grid → Sliver → Responsive
↓
第三阶段:路由与表单
Router → Detail → Form → Picker
↓
第四阶段:网络与数据
Dio → Dio Pro → Isar Watch → Wrong Flow → OSS Upload
↓
第五阶段:主题与媒体
Theme → Seed Palette → Animation → Rich Text → Audio → Share → Smooth Sheet
```
### 11.2 从前端转 Flutter 的核心思维转变
| 前端思维 | Flutter 思维 |
|----------|-------------|
| DOM 是真实节点 | Widget 是不可变配置,每帧重建 |
| 数据驱动视图(Vue/React)| setState 触发 Element 树 diff + 重建 |
| CSS 盒模型 | 约束传递模型(父给约束,子定大小)|
| 路由守卫 beforeEach | GoRouter redirect + refreshListenable |
| Pinia/Vuex 全局 store | Riverpod Provider + AsyncNotifier |
| axios 拦截器 | Dio 拦截器三件套 |
| rem/vw 适配 | ScreenUtil .w/.h/.spMin |
| 多环境 .env 文件 | --dart-define 编译期注入 |
### 11.3 本项目的技术亮点
1. **一套代码出 7 个品牌 App** — 编译期 `--dart-define` 注入品牌+环境
2. **响应式登录守卫** — Riverpod 写状态 + ValueNotifier 通知路由
3. **双方案屏幕适配** — 手机用 375 设计稿 + 平板回退原始尺寸 + 字体封顶
4. **两级主题模型** — 种子色自动生成色板 + 页面细节配置
5. **生产级 Dio 封装** — 拦截器/统一异常/重试/取消/进度
6. **OSS 客户端直传** — V4 签名 + PUT 直传 + 进度
7. **Isar 响应式数据库** — watch 驱动 UI 自动刷新
8. **28 个 Demo 全覆盖** — 从 Text 到 OSS,每个都有配套文档
### 11.4 配套文档
- Demo 文档索引:[docs/demos/README.md](file:///d:/flutter_learn/new_learn/docs/demos/README.md)
- 试卷架构设计:[docs/exam_architecture.md](file:///d:/flutter_learn/new_learn/docs/exam_architecture.md)
- 每个 Demo 都有六章结构文档:问题背景 / 运行方式 / 核心知识点 / 关键代码剖析 / 踩坑记录 / 扩展思考
---
> **结语**:Flutter 的学习不是背 API,而是理解「Widget 是配置、Element 是实例化、RenderObject 是渲染」这条主线。所有的高级用法 — 状态管理、路由守卫、主题换肤 — 都是建立在这条主线上的。本项目 28 个 Demo 覆盖了从基础组件到生产级封装的完整路径,建议大家按顺序跑一遍,每个 Demo 都有配套文档。