4.2 数据流设计:单向流动与响应式


4.2 数据流设计:单向流动与响应式

本节摘要:上一节选了工具,本节立规矩:状态只在唯一来源存放、数据只沿单向流动、模型一旦生成就不可变。这三条纪律与具体库无关,是从"功能能跑"到"改得起、测得动"的分水岭。轻记账的账单数据流会从模型到界面完整走一遍。

为什么是"单向":双向绑定的教训

界面框架的数据流有两大流派:双向绑定(界面改数据、数据改界面互相触发)与单向数据流(数据只能从状态流向界面,界面的意图作为"动作"流回去)。Flutter 的机制天然偏向单向——Widget 不可变、父级传子级、变化靠重建——但机制偏向不等于代码就位:你仍然可以在回调里东改一个全局变量、西改一个单例,把单向的河道挖得千疮百孔。

单向流动的完整回路是这样的:状态 →(构建)→ 界面 →(用户操作产生意图)→ 控制器 →(调用仓库改数据)→ 新状态 → 界面刷新。数据像环城水道,只朝一个方向流;界面永远只是状态的投影函数——同样的状态必然渲染出同样的界面。

这条纪律换来的第一份红利是可推理:界面显示错了,不用全项目搜索"谁改了它",因为修改只能发生在控制器的动作函数里,翻那里就够了。第二份红利是可测试:给控制器一组输入,断言输出状态,不需要架起界面。第三份红利在多端——同一个控制器动作,将来从按钮、从语音助手、从深度链接进来,走的是同一条路,行为天然一致。

图 10 轻记账的单向数据流回路

图 10 轻记账的单向数据流回路

不可变模型:copyWith 是标配

单向流的"新状态"必须是新的对象,而不是旧对象改字段——否则所有"数据变了界面没刷"的灵异事件都会找上门。轻记账的 Bill 模型带一套 copyWith,这是不可变模型的标配:

class Bill { final String id; final String title; final double amount; final String category; final DateTime createdAt; final bool synced; const Bill({ required this.id, required this.title, required this.amount, required this.category, required this.createdAt, this.synced = false, }); // 只改要改的,其余原样带走:同步状态翻转、金额修订都靠它 Bill copyWith({double? amount, String? category, bool? synced}) => Bill( id: id, title: title, amount: amount ?? this.amount, category: category ?? this.category, createdAt: createdAt, synced: synced ?? this.synced, ); @override bool operator ==(Object other) => identical(this, other) || other is Bill && other.id == id && other.amount == amount && other.synced == synced; @override int get hashCode => Object.hash(id, amount, synced); }

三个细节各有用处。copyWith 的参数全部可空、"空即不改",这让局部修订只写一个参数;== 与 hashCode 重写后,状态框架与列表 diff 才能正确判断"这条账单没变,别重建它的界面"——第 3 章的三棵树知识在这里接上了线;字段全 final,构造可 const,模型天然线程安全,塞进 Isolate 传输(第 2 章)也不用担心半途被改。

集合同理:更新账单列表用 [...state, newBill] 造新列表,删除用 where 过滤出列表。永远别 state.add(x) 之后指望界面知道。

仓库模式:数据来源的唯一入口

"状态从哪来"必须只有一个答案:仓库(Repository)。第 4.1 节的 BillRepository 现在补全它的双实现——内存版给测试与演示,数据库版给真实运行(实现在下一节):

abstract class BillRepository { List<Bill> loadAll(); void save(Bill bill); void delete(String id); } class MemoryBillRepository implements BillRepository { MemoryBillRepository({required List<Bill> seed}) : _store = [...seed]; final List<Bill> _store; @override List<Bill> loadAll() => List.unmodifiable(_store); @override void save(Bill bill) => _store..removeWhere((b) => b.id == bill.id)..add(bill); @override void delete(String id) => _store.removeWhere((b) => b.id == id); }

仓库的价值立刻能演示:给控制器注入 MemoryBillRepository 写一个单元测试,不需要任何设备——

test('记账后列表应包含新账单', () { final container = ProviderContainer(overrides: [ billRepositoryProvider.overrideWithValue( MemoryBillRepository(seed: const [])), ]); addTearDown(container.dispose); container.read(billListProvider.notifier).add(demoBill); expect(container.read(billListProvider).length, 1); });

这个测试跑在毫秒级,第 6 章的持续集成流水线里每次提交都跑一遍。仓库把"数据在内存还是数据库还是网络"这个问题隔离在一层之内,控制器与测试对来源一无所知——第 5 章接网络同步时,轻记账只需新增一个"远程优先"的仓库实现,业务代码零改动。

响应式的分寸:watch 该多细

单向流的最后一块拼图是"界面听谁的"。原则在第 3 章立过(最小范围),这里给出可操作的粒度表:整页只有一个状态的,页面级 watch;列表页对列表整体 watch、对无关状态(如设置)不 watch;列表项只 watch 自己那一条(按 id 选出),别的项变化不惊动它。watch 细一分,重建就少一片——这与第 6 章的重建计数实验结论完全一致。反面的分寸也要提:把一个状态切成八个微 Provider,依赖关系比界面还复杂,维护成本反噬。粒度的甜点区在"听我需要的,别听我邻居的"。

本节要点回顾

  • 单向回路:状态产出界面,界面发出意图,控制器处理意图改仓库出新状态;
  • 界面是状态的投影函数,修改只能发生在控制器动作里,排查问题有唯一入口;
  • 不可变模型配 copyWith、== 与 hashCode,是列表 diff 正确的前提;
  • 仓库隔离数据来源,内存实现直接换进单元测试,毫秒级跑完;
  • watch 粒度听小不听大,但别把状态碎成八块。

状态活在了内存里,但应用一重启就归零。下一节把状态落到磁盘——重启不丢,才算真正的应用。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U