把TodoList做到第六个版本,我发现最有意思的地方不是UI动效,而是编辑任务时那一整套状态流转。最近我把它用Flutter跑到了OpenHarmony设备上,顺手把任务编辑这块按“不可变更新 + 局部状态隔离”的方式重写了一遍。这两个词听起来像论文标题,实际落地以后就是两行极朴素的原则:改数据时不要动老对象,管状态时不要让全局Store把手伸到每个输入框。这篇文章会完整记录这个子系统的需求拆解、代码结构,以及在真机上碰到的几个坑。如果你正打算在OpenHarmony上跑Flutter,或者项目里TodoList的编辑体验越写越乱,这篇应该能给你一个可以直接抄的参考答案。
1. 为什么TodoList编辑值得单拎出来做一个子系统
1.1 一个编辑动作背后有多少步状态流转
很多人觉得TodoList简单,无非是列表加勾选加删除。但你试着把“编辑”这件事拆开看,它和“列表展示”是两种完全不同的复杂度。
列表展示是单向映射:拿到 List ,刷出 ListView,完事。编辑则是一个多步状态流转:用户点进编辑页,页面要回显当前任务的标题、备注、优先级;用户逐字修改,这个过程中产生的是“半成品”数据;用户可能中途返回放弃;保存时要校验,校验通过后要把新Task交给列表;列表收到新对象后要决定怎么重绘自己。任何一步处理得糙,都会表现为“页面卡一下”“列表闪一下”或者“数据改丢了”。
这也是我一开始就决定把“任务编辑”独立成一个子系统的原因:它的复杂度集中在状态的生命周期管理,而不是UI渲染。Flutter的build方法只管得了某一次快照长什么样,管不了数据从“草稿”到“正式提交”这个流动过程。而这个流动过程,恰恰需要明确的边界和协议来约束。
1.2 为什么是Flutter而不是ArkTS来写OpenHarmony应用
这个话题在某些社区容易变成立场之争,我不想引战。客观讲,如果从零开始做一个只跑在OpenHarmony上的小型工具类应用,用ArkTS写完全没毛病,系统级API贴近、文档齐全、调试链路短。但我这边的场景不一样:团队已经有相当多沉淀下来的Flutter业务组件和一套完整的跨端工程体系,把这些逻辑用ArkTS再重写一遍,成本高且后续双端维护很痛苦。
OpenHarmony如今已经有社区维护的Flutter适配SDK,支持在工程里增加ohos平台目录,构建产物可以拿去真机安装。也就是说,我的跨端业务代码可以近乎原样地跑在OpenHarmony设备上,需要额外打交道的只有平台适配层。这个路线让我们的团队能保留Flutter的跨端优势,同时吃到OpenHarmony的设备生态。
所以这篇文章里我不会花太多篇幅讲OpenHarmony系统开发,重点还是放在Flutter侧的应用层架构:任务编辑这个业务子系统,怎么用不可变更新保证数据安全,怎么用局部状态隔离保证UI精准重建。
1.3 “不可变更新”和“局部状态隔离”先用大白话说清楚
先说不可变更新。想象你有一份纸质档案,正常做法是拿支笔在原件上涂改。不可变更新的做法是:复印一份原件,在复印件上修修改改,然后把复印件放回档案柜,原件作废。档案柜里始终是一份完整、干净的档案,外部任何时候读到它,都不会看到涂改到一半的脏状态。
再说局部状态隔离。想象一个公司里各部门共享一个中央大台账,任何人改任何一个字段,所有人都能看到。局部状态隔离的意思是,每个部门有自己独立的草稿纸,员工在草稿纸上写写画画不会影响别人,只有最终确认数字后才登记到中央台账。
两个原则配合起来正好覆盖TodoList编辑子系统的两个核心问题:数据怎么改才安全(不可变更新),改动怎么传播才不互相干扰(局部状态隔离)。
2. 不可变更新:让每次编辑都留下一个干净的快照
2.1 直接改对象为什么会越改越乱
先看一个典型的可变对象写法:
class MutableTask { String title; String note; bool completed; MutableTask({required this.title, required this.note, required this.completed}); }看上去很直接,对不对?但它埋了三个雷。
第一个是中间态外泄。假设用户在编辑页敲了三个字,列表页的某个统计逻辑正在扫描所有任务的标题长度,它读到的对象是“只写了一半”的状态。UI在那一帧可能渲染出奇怪的内容,然后下一次刷新又恢复,形成一种很难复现的闪烁bug。
第二个是前后对比困难。你要实现“标记为已完成的任务标题变灰”,判断依据如果是引用相等,那修改内容后引用没变,判断就直接失效。如果内容是可变字段,你对比新旧值要么深拷贝做慢速diff,要么干脆放弃。
第三个是回滚困难。用户编辑一半想取消,可变模型下你必须有备份、快照甚至撤销栈。而不可变模型下,只要原始引用还在,取消就等于“不替换新对象”,天然支持一次性回滚。
我们在任务编辑子系统里采用了不可变模型:
class Task { final String id; final String title; final String? note; final bool completed; final int priority; const Task({ required this.id, required this.title, this.note, this.completed = false, this.priority = 0, }); Task copyWith({ String? title, Object? note = _unset, bool? completed, int? priority, }) { return Task( id: id, title: title ?? this.title, note: identical(note, _unset) ? this.note : note as String?, completed: completed ?? this.completed, priority: priority ?? this.priority, ); } static const Object _unset = Object(); }这里所有字段都是final,构造函数用const保证实例可以稳定缓存,修改动作全部通过copyWith生成新实例。
2.2 copyWith里那个不为人知的哨兵值
上面代码里我特意写了_unset这个哨兵值,这个细节是很多初学Flutter的人踩坑的地方。
经典的copyWith写法是:
Task copyWith({String? title, String? note}) { return Task(id: id, title: title ?? this.title, note: note ?? this.note); }初看没问题,但注意note是一个可空字段。如果我想把note从“买东西”清空成null,调用copyWith(note: null),因为null会走?? this.note,最终结果仍然是“买东西”,清不动。这就是“无法用null区分‘不修改’和‘修改为null’”的可空字段经典陷阱。
如果哪天你的Task多了个截止日期字段,同样会撞这个鬼。解决办法就是用哨兵值:参数默认值是_unset,只有调用方真正传了一个非哨兵值时才会覆盖。这样既能保留旧值,又能显式把字段置空。我用的是Object哨兵方案,代码最简洁,不需要额外引入第三方库。
2.3 列表层的不可变更新:换名单而非涂改名单
单个Task要不可变,承载Task的列表同样要不可变。这里定义TodoListModel,它保存整个列表的唯一状态源:
class TodoListModel extends ChangeNotifier { List<Task> _tasks = []; bool _loading = false; List<Task> get tasks => List.unmodifiable(_tasks); bool get loading => _loading; Task? taskById(String id) { for (final task in _tasks) { if (task.id == id) return task; } return null; } void updateTask(Task updated) { final index = _tasks.indexWhere((task) => task.id == updated.id); if (index < 0) return; final newList = [..._tasks]; newList[index] = updated; _tasks = newList; notifyListeners(); } void removeTask(String id) { _tasks = _tasks.where((task) => task.id != id).toList(); notifyListeners(); } void addTask(Task task) { _tasks = [..._tasks, task]; notifyListeners(); } }注意两个细节。
一是get tasks返回List.unmodifiable,外部代码拿到这个列表也只能读,不能偷偷改,否则会破坏列表层的不可变性。代价是每次访问会包一层,但TodoList规模撑死几百条,完全可接受。
二是updateTask的替换逻辑:先找到索引,再用展开语法创建新列表覆盖对应位置。这里没有用_tasks[index].title = xxx这种原地修改,本质上是“换名单”而不是“涂改名单”。
为什么不直接复制整个列表?因为Dart的List是引用数组,[..._tasks]是浅拷贝,Task对象本身是不可变的,所以浅拷贝就已经足够安全。列表层的不可变更新,确保的是“哪一个是当前有效列表版本”这个问题有唯一答案,配合notifyListeners,通知机制可以精确表达“列表换了一版”。
2.4 不可变数据和Provider到底怎么共存
“flutter provider 怎么用”是社区高频问题,很多人对Provider的印象是“一个可变的大Store”。实际上Provider只是个依赖注入框架,跟不可变更新完全不冲突,甚至能配合得很好。
我这边是Provider + ChangeNotifier的组合:外层用Provider把TodoListModel注入子树,内层Model依然保存不可变列表。
ChangeNotifierProvider( create: (_) => TodoListModel(), child: const TodoListApp(), )消费端用Selector而不是直接watch,这是“精准重建”的关键:
Selector<TodoListModel, Task>( selector: (context, model) => model.taskById(taskId), shouldRebuild: (prev, next) => prev != next, builder: (context, task, child) => TaskTile(task: task), )Selector只会在model.taskById(taskId)返回的对象引用变化时重建对应Tile。Task是不可变对象,引用变化恰恰代表着内容确实变了。这种特征让不可变模型和响应式通知形成天然默契,你不用自己写深比较。
这也是“不可变更新”在消费端带来的额外红利:引用相等性就是diff结果。
3. 局部状态隔离:编辑器不准碰全局Store
3.1 全局Store误伤列表的现场还原
场景是这样的:任务编辑页有标题输入框、备注输入框、优先级下拉、完成态开关。第一版我图省事,把编辑页做成直接监听TodoListModel,每次键击把草稿写进全局Model。
结果灾难来了——列表页也watch同一个Model,用户每敲一个字符,notifyListeners就触发一次,整个列表连带重建。真机上表现是:软键盘弹起已经让布局抖一次了,每个字符敲下去列表又全部刷新一遍,滚动位置跳来跳去,点击响应也变得迟钝。这还不算最严重的:如果用户在编辑中途退出,那一半的脏数据还留在全局Model里,列表可能短暂显示一个标题只有两个字的任务。
这个问题的根源就是状态隔离粒度不对。键盘输入产生的中间态,本质上是“编辑页内部事务”,它不应该出现在全局状态里。全局状态只需要知道最终结果:某任务被修改成了什么。
3.2 草稿和真实数据的边界怎么划
所以我把编辑页设计成完全独立的局部状态容器:它接收一个Task快照来初始化UI,之后所有输入都沉淀在页面自己的State里,直到用户点保存。
class TaskEditPage extends StatefulWidget { final Task task; final ValueChanged<Task> onSave; const TaskEditPage({super.key, required this.task, required this.onSave}); @override State<TaskEditPage> createState() => _TaskEditPageState(); } class _TaskEditPageState extends State<TaskEditPage> { late final TextEditingController _titleController; late final TextEditingController _noteController; late bool _completed; late int _priority; @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.task.title); _noteController = TextEditingController(text: widget.task.note ?? ''); _completed = widget.task.completed; _priority = widget.task.priority; } @override void dispose() { _titleController.dispose(); _noteController.dispose(); super.dispose(); } void _save() { final updated = widget.task.copyWith( title: _titleController.text.trim(), note: _noteController.text.trim().isEmpty ? null : _noteController.text.trim(), completed: _completed, priority: _priority, ); widget.onSave(updated); } }注意这个页面完全不认识TodoListModel,它不依赖Provider、不接收全局Store,它只知道一个Task和一个ValueChanged<Task>回调。这就是局部状态隔离的完整形态:编辑页自己的草稿自己管理,最终通过统一出口把新Task抛出去,至于外面怎么合并、怎么持久化,编辑页一概不关心。
这样设计的另一个好处是编辑页天然可复用、可测试。任何地方想弹出一个编辑页,只需要构造Task和回调,完全不需要先搭一个全局状态环境。对于多人协作的项目,这种组件级解耦能避免大量“改我的代码时崩了你的页面”的问题。
3.3 保存与取消的交接协议
除了onSave回调,编辑页通常还需要让调用方知道“用户取消了”。取消动作可以通过Navigator.pop返回null来表达,也可以通过显式回调。我这边两种都在用,各有权衡:
// 回调方式,适合非路由场景,比如底部弹出的编辑面板 final task = await showModalBottomSheet<Task>( context: context, builder: (_) => TaskEditPage(task: task, onSave: (_) {}), ); // 路由方式,适合全屏编辑器,返回值即编辑结果 final updated = await Navigator.push<Task>( context, MaterialPageRoute(builder: (_) => TaskEditPage(task: task, onSave: () {})), );要说明的是,即使走路由方式,TaskEditPage内部始终只通过onSave通知保存。页面顶部那个返回按钮,或者用户系统的返回手势,默认都是“丢弃草稿”。如果希望在用户有未保存修改时弹确认框,编辑页自己在PopScope里拦截。
这套协议的优点在于信息最小化:对外只暴露“我要保存新对象”这一条通道,调用方不需要知道编辑页里维护了哪几个Controller,编辑页也不需要知道调用方把Task存到了哪里。这样状态边界就非常清晰。
3.4 组件通信的几种姿势,怎么选
“flutter组件通信”在热搜词里居高不下,说明大家对这块是真的困惑。结合这个子系统的经验,我系统梳理一下Flutter里组件通信的几种方式,以及它们各自适合的场景。
- 属性+回调:父组件把数据传给子组件,子组件通过回调把结果抛回去。适用于父子之间一对一通信,这是Flutter最原生、最直观的通信方式,也是任务编辑子系统的主通道。
- 事件总线/Stream:适合兄弟组件、跨页面、一对多广播。比如“任务数据同步到本地缓存”这种不关心谁发出、谁接收的场合,用Stream就很顺手。缺点是不好调试,事件满天飞的时候你很难定位是谁发的。
- 全局状态注入(Provider/Riverpod/Bloc):适合多个组件共享同一份业务数据,而且有明确的读取和改写需求。TodoList列表页用Provider合适,因为列表页有大量组件读取同一个Model;但编辑页就完全不需要连这个Model。
我的原则是:通信半径越小,越应该用简单朴素的回调;同一个页面内的父子关系,用回调解决;跨页面但一次性,用路由返回值;全局共享状态,才轮到状态管理库。很多人一上来任何组件之间都用Provider拉线,最后整个项目状态耦合成一锅粥,其实就是没做局部状态隔离。
下面用一个表格总结这个选择逻辑:
| 场景 | 推荐方式 | 为什么不选别的 |
|---|---|---|
| 父组件传数据给子组件 | 构造参数 | 最直接,类型安全 |
| 子组件把结果交还父组件 | 回调/路由返回值 | 通信半径最短,状态边界最清晰 |
| 兄弟组件横向联动 | Stream/事件总线 | 不引入全局依赖,解码松耦合 |
| 多组件共享业务数据 | Provider/Riverpod | 适合全局业务模型,但要注意隔离粒度 |
| 全局广播通知(如登录过期) | Stream/全局事件 | 一对一通信用这个会淹没代码可读性 |
4. 在OpenHarmony上跑通Flutter工程的几个关键点
4.1 环境配置与“新建项目跑不起来”的常见原因
这次把工程迁到OpenHarmony平台,我遇到的最先一个坎就是“新建项目跑不起来”。Flutter的标准命令flutter create默认生成的是android、ios这些平台目录,OpenHarmony平台需要借助社区维护的Flutter适配SDK,创建项目时要通过特定参数让工程带上OHOS平台壳。具体命令和版本在不同适配SDK版本下会有差异,但核心就一件事:你的Flutter SDK必须是支持OpenHarmony的fork分支,而不是官方原版。
后续构建阶段,OpenHarmony侧的HAP产物打包需要DevEco Studio参与签名和安装。如果你发现项目跑不到设备上,先别急着怀疑业务代码,按照下面顺序排查:
- Flutter SDK是否用的是适配分支。
- DevEco Studio里OpenHarmony SDK路径是否配置正确。
- 真机的开发者模式、USB调试是否开启。
- HAP签名是否已配置,未签名的包无法安装到真机。
我这边的经验是,前三步都确认无误之后,问题几乎都落在签名上。这是个非常不Flutter的坑,容易让人懵掉,因为报错信息看起来像设备连不上,实际是包没签名。
4.2 子系统文件划分与整体数据流
任务编辑子系统的工程结构我按“模型-页面-协议”三个维度拆,避免把所有逻辑塞进一个文件里:
lib/ models/ task.dart // 不可变Task模型与copyWith pages/ task_list_page.dart widgets/ task_tile.dart // 单条任务,Selector精准重建 task_edit_page.dart state/ todo_list_model.dart // ChangeNotifier,持有不可变List<Task> main.dart整体数据流是:TaskListPage监听TodoListModel,点击某条任务时,把对应Task快照传给TaskEditPage;编辑页内部维护草稿;保存时回调带上新Task;列表页收到回调调用model.updateTask;model用不可变列表替换旧对象并notifyListeners;Selector对比引用变化,只重建那一行。
链路里没有一条跨越编辑页的全局数据入口,这就是局部状态隔离在整个子系统层面的体现。截图式地讲,TodoListModel是整个系统状态的中枢,但它永远只接收“完整、合法、终态”的Task对象,中间态都被挡在编辑页内部。
4.3 编辑页交互细节:校验、完成态切换与软键盘
编辑页除了状态隔离,交互细节也会直接影响体验。保存前要校验标题不能为空,这个校验不能只在保存按钮上做,还要在保存回调之后再次兜底,因为回调可能被封装在底部面板或者路由里。
完成态切换我放在编辑页顶部做,本质是一个隐藏的Checkbox,点击时切换局部_completed变量,然后UI立刻反映删除线样式。同样地,这一改动不会影响全局列表,全局列表只会在用户点“保存”那一刻才收到新的Task。
软键盘遮挡输入框是编辑器里最常见的问题。解决方式是让编辑页成为一个可滚动区域,并且监听键盘高度:
AnimatedPadding( duration: const Duration(milliseconds: 120), padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: column... ), )经验之谈:AnimatedPadding的duration设短一点,100到150毫秒就够,太长会显得拖沓;配合SingleChildScrollView可以让当前聚焦的输入框在键盘弹出后自动滚到可视范围内。如果不加这个处理,在屏幕较小的OpenHarmony设备上编辑备注时,输入框很容易被键盘完全盖住。
5. 真机实测的经验教训
5.1 Unhandled exception:TextEditingController的dispose顺序
跑真机之后第一个脏活儿是偶发的Unhandled exception,日志长这样:
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: A TextEditingController was used after being disposed.这个错误在快速退出编辑页的时候很容易触发。原因很典型:页面dispose时释放了Controller,但某个异步操作(比如防抖保存、请求回调)比dispose晚回来,它在回调里访问了Controller的text属性。
排查思路不复杂:先看是不是所有Controller都只在State生命周期内使用;再看异步回调里是不是没有做mounted检查。修复方式是在回调里判断页面是否还活着:
final title = _titleController.text.trim(); if (!mounted) return; widget.onSave(Task(...title: title));其实最佳实践是:在读取Controller值的那一刻就把值拷贝到局部变量,后面所有逻辑都操作局部变量,不再触碰Controller。这样即使页面已销毁,也不会因为Controller访问报错。这个习惯对所有输入框场景都适用,不只是OpenHarmony平台。
5.2 下拉刷新和局部重建的冲突
TodoList页面我加了RefreshIndicator做下拉刷新,结果和局部状态隔离撞了个正着:刷新会把整个列表替换成新数据,每条Task的引用都变化,所有Tile全部重建。如果用户当时正展开某条任务查看详情,一个下拉就会把它收起,体验很割裂。
我的处理办法是给刷新过程增加全局锁:刷新时TodoListModel里有一个_refreshing标志位,列表在刷新期间不接收编辑子系统的任何更新,或者反过来,编辑子系统的保存动作在刷新期间暂时排队。具体业务可以不同,但原则一致——你要明确“刷新”和“编辑提交”这两个操作在状态层面谁是主导者,不能两个同时无协调地改同一个列表。
5.3 什么场景不该用这套方案
说了这么多优势,也得泼点冷水。不可变更新加局部状态隔离这套东西,最适合的规模就是TodoList这一类“中轻量级业务”。如果你的编辑页和多个页面共用一份全局数据,且数据之间存在复杂的跨页联动,你仍然可以靠这套思想分层,但要注意以下两个问题。
第一,不可变更新的成本。每次修改都生成新对象,内存和GC开销比原地修改高一点。TodoList级别完全感受不到,但如果是几千上万条数据的列表,且每秒都在高频更新,不做批量合并就会有点吃力。第二,局部状态隔离要求你的状态边界划得足够清晰,如果两个页面本身职责纠缠不清,硬套隔离反而会让通信协议变得越来越臃肿,最后变成“明明要个数字,非要传一个对象”。
5.4 给后来者的三条实操建议
结合这次实战,我想给打算在Flutter for OpenHarmony上做类似功能的朋友三条具体建议:
第一,先把Task模型写不可变,再去写Model和页面。模型是整个子系统的地基,地基若可变,上面所有层都会跟着摇摆。写的时候特别注意可空字段的置空问题,哨兵值是我能想到的最轻量方案。
第二,编辑页在接到TodoListModel之前先做一个独立的页面测试。不依赖全局状态的编辑页是最好调试的,你可以用一段假数据直接推入这个页面,验证草稿、校验、取消、保存全链路。一旦发现页面需要依赖全局才能正常工作,说明隔离没做干净,赶紧回头修。
第三,所有跨子系统的通信协议都要先定签名。Task的保存通道就是ValueChanged<Task>,这个签名谁都不要改;如果你后面要加“重复编辑”“批量编辑”功能,新增独立的回调类型,而不要在一个回调里塞多个参数。协议稳定,状态边界才不会崩塌。
在这套方案跑起来之后,我又回头把列表页和编辑页的测试用例补齐了。现在每次提交代码,CI里都会跑一遍“编辑保存后列表精准更新这一行”的用例,说实话比之前在Android端靠肉眼看渲染结果要踏实多了。状态管理这件事,说到底不是选择一个多么高级的框架,而是你愿不愿意把每个状态变量的生命周期都想清楚。想清楚了,TodoList这种最小业务也能变成一个结构良好的子系统;想不清楚,再复杂的框架也救不了那一点点状态的账。