做鸿蒙端的 Flutter 应用,最头疼的一类问题就是:这个三方库能不能跑?尤其是那些重度依赖状态管理和历史记录的编辑类库,移植起来更是牵一发动全身。最近我在适配一个名叫 invertible 的撤销重做库时,踩了一圈坑,也总结出了一套清晰可复用的鸿蒙化路径,今天完整拆给大家。invertible 本身是一个纯 Dart 实现的历史记录库,它不依赖任何原生平台能力,核心思想是把每一次编辑动作建模为一个可逆操作,操作本身知道如何 apply、如何 undo、如何 redo。这种设计让它的核心逻辑理论上可以无缝跑到鸿蒙的 Flutter 引擎上。但现实中,撤销重做栈的可靠性不只取决于核心逻辑,还和应用生命周期、内存回收、持久化方案紧密相关,这些恰恰是鸿蒙和传统移动平台差异最大的地方。如果你是做笔记、画板、自定义编辑器等需要高频编辑和撤销重做功能的开发者,这篇文章能帮你少踩至少两周的坑。
1. 为什么撤销重做这么难?invertible 的设计思路拆解
1.1 撤销重做的两种主流模型:快照与逆运算
在做编辑类功能时,撤销重做的第一道选择题是:用什么方式记录历史。我见过不少团队上来就写一个 List<State> 当历史栈,每次操作完把整个页面状态塞进去。早期页面小的时候没毛病,但一旦文档长起来、画布复杂起来,内存直接爆炸。这种方案叫“快照式”,优点是无脑、恢复快,缺点是存储代价极高。
另一种方案就是 invertible 走的路线:逆运算。它不是保存状态本身,而是保存“操作”。比如你在文档里插入了三个字符,那么一个 InsertTextOperation 会记录插入的位置和内容,撤销时执行它内置的 remove 逻辑,重做时又把内容加回去。这样历史栈里存的是轻量级的操作描述,而不是一坨坨完整状态。
拿实际对比来看,两种方案的取舍非常明显:
| 对比项 | 快照式 | 逆运算式 |
|---|---|---|
| 存储开销 | 随状态体积线性增长,编辑器场景下容易失控 | 只存操作参数,通常很小 |
| 撤销恢复速度 | 直接恢复快照,毫秒级 | 需要重新执行逆操作,复杂操作可能略有开销 |
| 实现难度 | 简单,直接整体替换 state | 需要为每个动作定义完美的“逆动作”,难度高 |
| 典型适用场景 | 表单编辑器、简单演示Demo | 文本编辑器、画板、实时协作 |
invertible 选择逆运算,核心原因就是它瞄准的是高交互编辑场景。用户可能在一个画布上连续画几百笔,如果用快照,每一笔都存一份完整画布数据,稍微复杂点的画布一次快照就是几十兆,完全不可接受。逆运算则只存“这一笔的颜色、粗细、路径点”,占的内存小得多,而且天然支持无限级别的撤销——只要操作定义是正确的。
这套思路看起来简单,但要落地到鸿蒙这样的新平台上,真正的难点反而不是算法,而是操作栈的生命周期和平台行为的一致性。这也引出了后面要聊的鸿蒙化适配核心。
1.2 invertible 的核心分层:操作、事务与历史栈
invertible 整个设计可以拆成三层来看。
第一层是“操作(Operation)”。在 Dart 里,一个操作需要实现四个关键方法:apply 在状态上应用本次修改,undo 把修改还原,redo 重新应用,还有一个 canMergeWith 用于判断两个操作是否能够合并成一个撤销步骤。canMergeWith 是个很精妙的点,它让连续的“同类型”操作可以被打包。举个例子,你在文本框里连续输入了 26 个英文字母,每个字符实际上对应一次 InsertOperation,如果不做合并,用户得按 26 次 Ctrl+Z 才能回到初始状态,这体验基本没法用。通过 canMergeWith 让相邻的插入操作自动合并,用户一次撤销直接删掉刚才打的一整串单词,这才是编辑工具该有的手感。
第二层是“事务(Transaction)”。有些业务动作不能被拆成单个原子操作,比如把一个图表从一个分组挪到另一个分组,涉及删除和新增两个子操作。invertible 通过事务机制把多个 Operation 包裹在一起,作为一个整体 push 到历史栈里。事务内部保证原子性:要么全部成功,要么全部回滚。
第三层是“历史栈(History Manager)”。它维护两个兄弟数组:undoStack 和 redoStack。执行一个操作时,先调用 apply 拿到新状态,然后把操作压入 undoStack,同时清空整个 redoStack——因为老路径已经变了,重做分支就没有意义了。执行撤销时,从 undoStack 弹出栈顶操作,调用 undo,再把它推入 redoStack。重做就是反向过程。这套逻辑很经典,几乎不会出错,问题往往出在它和外部系统交互的时候,比如鸿蒙应用退到后台后,这两个 Dart 数组里到底还能不能保住。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸿蒙化适配的整体思路与核心细节
2.1 纯 Dart 库真的就“无需适配”吗?
很多人的第一反应是:invertible 不读文件、不调相机、不用定位,纯 Dart 逻辑,放到鸿蒙工程里直接编译通过,不就完事了?这话对了一半。
我实际跑下来的感受是:纯 Dart 库在鸿蒙上的“代码兼容”确实是最大的优势。只要你的依赖没有牵涉到原生插件,用鸿蒙的 Flutter SDK 跑起来,Dart 层面基本无感。但“无感”不等于“可用”,适配的重点从“改代码”转移到了“测行为”。鸿蒙的 Flutter 引擎本身还在快速演进,我在适配时至少踩过两次引擎层面导致的异常:一次是 ChangeNotifier 的派发时序和标准 Flutter 不一致,另一次是热重载后在部分设备上出现 isolate 内存被回收的情况。
所以我的建议是:鸿蒙化的第一步先明确边界。把三方库拆成“纯逻辑层”和“平台交互层”,invertible 的整个历史栈、操作定义、合并策略都算纯逻辑层,理论上不需要动代码;但“在什么时候创建管理器、什么时候保存、什么时候销毁”属于平台交互层,必须针对鸿蒙的生命周期重新设计。后者的重要性一点也不比前者低,很多撤销重做“偶发性失灵”的 bug,查到最后都不是逻辑错,而是平台交互层的时序没对上。
2.2 生命周期管理:鸿蒙后台回收与历史栈持久化
传统 Android 应用如果进程被系统杀掉,应用内的 Dart isolate 也就没了。鸿蒙在后台资源回收上做得更激进,尤其是一些入门设备,应用切到后台几分钟后,Flutter 引擎就可能被挂起甚至回收。当引擎被回收后再恢复,你的 undoStack 里哪怕存了一百个操作,对象也没了,页面层面表现为“撤销失灵”,用户点了没反应。
要解决这个问题,必须把持久化当作一等公民。我的做法是:监听 AppLifecycleState,在状态变为 paused 或 inactive 的瞬间,把历史栈序列化到本地存储。这里有个关键细节:不能等 detached 再去存,因为 detached 时引擎可能已经来不及执行 Dart 代码了。实测下来,鸿蒙上最保险的时机是 paused 回调里,立即触发同步保存,注意不要用异步的 await 去写文件,应该用同步 IO 或者先把序列化好的字符串塞到一个内存缓冲里,让原生侧立刻接管写入。
invertible 本身没有直接提供序列化能力,需要自己给 Operation 加一个 toJson / fromJson。序列化时不存状态快照,只存操作参数,比如插入的文本内容、插入的位置、操作类型标记。这样历史栈序列化出来也就是几 KB,完全没有性能压力。
2.3 内存回收与对象持有的坑
鸿蒙 Flutter 引擎的垃圾回收机制目前和标准 Dart VM 也有一点点差异,主要体现在“弱引用”和“跨线程持有”的处理上。invertible 的操作对象内部难免会持有一些编辑状态,比如一个画布操作的 Paint 对象或文本操作的 TextStyle 对象。如果你的操作类直接持有这些重量级对象,一旦它们又被页面层的其他对象引用,就可能形成一条“操作对象 → 重量级状态 → 大量渲染对象”的引用链,导致 GC 回收不了,界面出现明显卡顿。
规避的办法有三个。一个是让 Operation 尽量只存“参数”而不是“对象”,文本插入操作只需要位置、长度、文本字符串,不需要持有整个富文本 controller。另一个是使用 WeakReference 包装那些非必需的对象引用,让 GC 在需要时可以回收。还有一个就是在 dispose 方法里,主动把每个操作持有的字段清空为 null。尤其要注意,鸿蒙的组件树销毁顺序可能和 Android 不同,不要指望 Flutter 的 BuildContext 会自动帮你解绑,自己做的 cleanup 才靠得住。
3. 实操:从零开始适配 invertible 到鸿蒙 Flutter 工程
3.1 环境准备与项目初始化
先说明一下我用的环境组合:HarmonyOS SDK 版本选择的是当前稳定版的 API Level,Flutter SDK 用的是适配鸿蒙的社区发行版,里面已经内置了对 ohos 目录的构建支持。安装好两个 SDK 之后,在命令行里执行 flutter doctor 确认能识别到鸿蒙设备。
创建一个新的 Flutter 工程时,需要在工程根目录下手动添加 ohos 目录。这个目录配置好了鸿蒙侧的原生壳工程,包括 entry 模块,类似 Android 项目的 app module。如果你用的是 IDE 模板,则会在创建时自动生成。
创建完之后,我先直接跑了一个空工程到鸿蒙模拟器上,确认 Flutter 渲染正常,再开始引入 invertible。这一步是必须的,目的是排除掉环境本身的问题,不然后面所有报错你都会怀疑是库的问题,排查会非常低效。
3.2 引入 invertible 依赖
invertible 目前没有发布到国内任一镜像仓库的鸿蒙分支,所以最省事的做法是直接在 pubspec.yaml 里通过 Git 依赖指定仓库地址和 commit。下面是我的配置示例:
yaml复制dependencies:
flutter:
sdk: flutter
invertible:
git:
url: https://example.com/invertible.git
ref: main
这里需要注意的是,不要直接拉最新主分支就完事。我一开始就是这么干的,结果发现最新的 main 分支里已经引入了某个依赖,这个依赖在鸿蒙的 Flutter 适配版本上解析不了。后来换到了该库维护者明确标注支持 dart 2.x 的某个 tag,才顺利把依赖解析通过。
引入依赖后,在鸿蒙模拟器上执行一次 flutter pub get,然后写个简单的 print 调用,确保库能正常导入和初始化。这一步通过后,再开始做业务封装。
3.3 封装鸿蒙侧的撤销重做服务
直接裸用 invertible 的底层 API,在 rountine 页面里写会特别啰嗦。我先封装一个服务类,专门管理历史栈和监听生命周期。
先定义一个操作接口的子类,以文本插入为例:
dart复制class InsertTextOperation extends Operation<TextEditingState> {
InsertTextOperation(this.index, this.text);
final int index;
final String text;
@override
TextEditingState apply(TextEditingState state) {
final newText = state.text.replaceRange(index, index, text);
return TextEditingState(newText);
}
@override
TextEditingState undo(TextEditingState state) {
final newText = state.text.replaceRange(index, index + text.length, '');
return TextEditingState(newText);
}
@override
bool canMergeWith(covariant InsertTextOperation next) {
// 如果两个插入操作紧密相邻,就合并成一次撤销
return next.index == index + text.length;
}
}
然后实现一个 HistoryService,它持有 invertible 的 HistoryManager,同时负责监听应用生命周期:
dart复制class HistoryService {
final _manager = HistoryManager<TextEditingState>();
final _storage = HistoryStorage();
void bindLifecycle(WidgetsBindingObserver observer) {
// 在observer里监听 paused 回调,调用 saveSnapshot()
}
void execute(Operation<TextEditingState> op) {
_manager.execute(op);
}
void undo() {
final state = _manager.undo();
// 将state回传给 UI 刷新
}
void saveSnapshot() {
final ops = _manager.getAllOperations();
final json = ops.map((op) => op.toJson()).toList();
_storage.write(json);
}
Future<void> restore() async {
final json = await _storage.read();
final ops = json.map((item) => Operation.fromJson(item));
_manager.restoreHistory(ops);
}
}
这个服务把 invertible 的细节屏蔽掉,页面层只需要调用 execute、undo、redo。适配鸿蒙时,restore() 一定要放在应用首帧渲染完成之后调用,否则你先恢复历史栈,UI 再初始化,老状态的监听关系会乱掉。这里我给一个具体的时序:先等 WidgetsBinding.firstFrameRasterized 回调,再调 restore(),然后刷新 UI。
3.4 历史栈持久化的序列化设计
序列化是鸿蒙后台回收之后能否恢复撤销能力的关键。我的序列化策略是不存“状态”,只存“操作数组”。以文本插入操作为例子,序列化后的 JSON 大概长这样:
json复制{
"type": "insertText",
"index": 1024,
"text": "今天讲的是鸿蒙适配"
}
整个历史栈就是这样一个数组的数组,因为涉及分组和事务,最外层结构可以设计成:
json复制{
"version": 1,
"groups": [
{ "operations": [ {"type": "insertText", "index": 1024, "text": "你好"}, {"type": "insertText", "index": 1026, "text": ",鸿蒙"} ] },
{ "operations": [ {"type": "deleteText", "index": 0, "length": 3} ] }
]
}
反序列化时,根据 type 分发到具体操作类的 fromJson。这个方案最大的好处是:操作参数比状态小几个数量级,即使有几千级历史,JSON 文件也就几十 KB 到几百 KB,而且后续如果需要做跨设备同步也很方便,把这些 JSON 同步到云端就行。
持久化文件写到哪?鸿蒙上建议用应用沙箱目录,通过 getApplicationSupportDirectory 获取。路径里不要用 Android 那种习惯性的 /data/data/*/ 拼接,鸿蒙的沙箱路径是动态变化的,直接拿接口返回值最可靠。
3.5 绑定到编辑控制器并集成到页面
最后,在页面里把服务接到实际的输入控件或画板上。以 TextField 为例,标准做法是监听 controller,把每次变更转成 InsertTextOperation 或 DeleteTextOperation:
dart复制TextField(
controller: _textController,
onChanged: (value) {
final oldValue = _lastValue;
final op = buildOperationFromDiff(oldValue, value);
if (op != null) {
historyService.execute(op);
}
_lastValue = value;
},
)
注意,这里要防止重复执行:invertible 的 undo() 会直接操作底层状态,如果我们又通过 controller 的 onChanged 把它当作新操作,就会出现“撤销后又变回原样”的死循环。我的做法是加一个 _isApplyingRestore 标志位,在 undo/redo 期间设置为 true,onChanged 里检测到标志位就直接 return。这个小细节是高频翻车点,务必在联调前处理好。
4. 常见问题与排查技巧实录
4.1 问题一:应用切后台再回前台,撤销栈莫名丢失
现象:在鸿蒙开发板上,把编辑应用退到后台,过两分钟再切回来,历史还在,但点撤销没有任何反应。进一步调试发现 undoStack 里已经没有任何操作对象。
根因:我们没有监听应用切后台的事件,在引擎被系统挂起后,整个 Dart 对象堆被回收了。等用户回到应用,Flutter 引擎被重建,历史栈自然就空了。
解决:给 WidgetsBindingObserver 加上 didChangeAppLifecycleState 处理,在 AppLifecycleState.paused 时调用 historyService.saveSnapshot()。同时把保存的 JSON 写入沙箱文件,这里我用的是同步写,省得异步线程还没跑完进程就被冻结。实测处理完之后,后台回收后恢复,历史栈能完整还原。
4.2 问题二:执行撤销时界面闪烁,甚至状态回弹
现象:撤销时旧状态和新状态交替出现,感觉屏幕闪了一下,有时输入框的内容会先变成空白,再变成正确的旧内容。
排查过程:一开始以为是状态合并器写错了,后来加日志发现是 undo() 返回状态后,UI 层先接到了新状态并刷新了一次,但 invertible 的历史管理器内部随后又触发了一次通知,导致第二次刷新。
解决:在页面层使用 canUndo 和 canRedo 的监听方式整合,确保每次操作只触发一次 UI 刷新。我的具体做法是用一个 ValueNotifier<EditState> 作为唯一的状态源,然后所有状态变化都必须经过主逻辑层分发,避免 TextField 自带的 controller 回写和 invertible 的回调相互干扰。
4.3 问题三:连续快速编辑后出现卡顿
现象:在鸿蒙真机上快速打字,每分钟输入超过两百个字符的时候,撤销面板出现明显卡顿,动画掉帧。
根因分析:最初怀疑是 canMergeWith 没有匹配到连续输入,每次按键都被当成一个独立操作 push 进栈。这样 undoStack 膨胀很快,每次 undo 又需要执行反向插删,操作量大。后来查看日志,发现 canMergeWith 返回了 false,因为我在构造 InsertTextOperation 时把 index 写成绝对位置,而连续输入时位置是实时变化的,导致相邻操作的 index 不连续。
解决:把 index 的计算逻辑改成基于当前文本的增量,在 apply 时重新计算索引,而不是在构造时写死绝对位置。同时引入“合并窗口”的概念,允许短时间内类型相同的操作在 canMergeWith 里通过时间差判断,比如 500 毫秒内的同类操作都合并。这样历史栈的规模控制在合理范围,快速输入也不卡。
4.4 问题速查表:鸿蒙适配过中的高频错误
我把整个适配过程中遇到的其他几个典型问题整理成一张表,后续再碰见可以直接对着查。
| 现象 | 直接原因 | 推荐的解决方案 |
|---|---|---|
| 引入 invertible 后编译报错,提示依赖解析失败 | Git 引用指向的 commit 依赖了新版本某个包,在鸿蒙 SDK 上解析不了 | 换到稳定的老 tag,或者把库 fork 到本地仓库去掉多余依赖 |
| 撤销/重做不生效,但没有报错 | 没有正确区分 UI 层的操作回调和 invertible 的内部回调 | 设置 _isApplyingRestore 标志位,在恢复状态时屏蔽外部回调 |
| 页面切后台再回来,历史记录还在但文档位置丢失 | 只序列化了操作栈,没有保存光标位置等临时 UI 状态 | 持久化时额外保存一份 UI 状态快照,恢复时同时还原 |
| 撤销列表内容显示错乱 | 操作合并策略定义得太宽松,把不同逻辑的动作合并了 | 重新设计 canMergeWith,只在类型、目标都一致时才允许合并 |
| 鸿蒙上运行稳定,但模拟器上偶现崩溃 | 模拟器内存压力小,触发了不同的回收策略 | 统一在发布前用低端真机做压测,模拟后台回收场景 |
这些坑里有一个共通点:问题往往不在 invertible 的核心逻辑,而在“状态是谁触发的”、“通知什么时候发”、“对象什么时候回收”这三个平台差异点上。鸿蒙的 Flutter 适配层相对年轻,平台侧的行为和 Android 不是完全对齐,所以适配时不要想当然,每个结论都要靠日志验证。
最后再分享一个小技巧:在调试撤销重做流程时,给每个 Operation 增加一个唯一的自增 ID,并且把 apply、undo、redo 的调用日志都打出来。这样一旦出了乱序问题,你能非常快地看出是哪个操作被重复执行了、哪个操作没被正确入栈。这个日志在鸿蒙上尤其有用,因为鸿蒙的 Flutter 引擎在部分版本上对日志输出有缓冲,按 ID 搜索就能过滤出真正有用的记录。我在适配期间靠这套日志排查出至少三个容易隐藏的时序问题,基本是效率最高的一种辅助手段。
