120 lines
4.6 KiB
Markdown
120 lines
4.6 KiB
Markdown
# 【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 做"分页错题本"。
|