鸿蒙化适配排到我们组的时候,我一开始真以为最大的工程量在UI和原生插件,跑起来才发现,最磨人的其实是状态管理那一堆手写样板。项目里有十几个页面,每个都要手写Event、State、Bloc,一个字段的增删要连带着改构造方法、copyWith、props、哈希比较,漏改一处就是线上状态错乱。后来我们把fbloc_event_gen接进鸿蒙工程,用yaml定义事件和状态,代码生成器负责产出整套BLoC样板代码,而且生成的State默认基于Equatable实现,跑起来之后才发现这步适配的收益比预期大得多。
这篇内容不聊通用理论,直接说怎么让fbloc_event_gen在OpenHarmony环境下正常跑起来,怎么处理Equatable深度比较、构建缓存、模板定制这些细节。如果你正在做Flutter工程向鸿蒙环境迁移,或者单纯想减少BLoC样板代码,这篇值得看完。
1. 为什么是fbloc_event_gen:鸿蒙化场景下的状态管理样板难题
1.1 鸿蒙迁移中绕不开的BLoC样板代码
Flutter工程迁到OpenHarmony环境,大部分纯Dart代码可以直接复用,真正要折腾的是平台通道、原生插件、构建脚本这些东西。但我在实际迁移中发现,业务侧最耗时间的反而是一个很朴素的问题:每个页面的状态流转代码全都得手写,量大、重复、容易抄错。
拿一个典型列表页来说,事件层至少要有加载、刷新、分页加载、重试四类,状态层要有初始态、加载中、已加载、失败态,再加上一个Bloc类负责把Event映射到State。一个中等复杂度的页面,光Event和State的样板代码就能到200行左右。这些代码没有任何业务创新,全是结构化的模板,但你又不能省,因为Bloc模式本来就依赖这些类型来区分不同的UI状态。
更麻烦的是Equatable相关代码。为了让页面只在状态真正变化时重新构建,State类要继承Equatable、实现props,把参与比较的字段一个个挂上去。这个操作看起来简单,实际维护起来特别容易出错。我见过同事在一个State加了一个ErrorCode字段,但忘了把它加进props,结果后端返回不同错误码时,UI在BlocBuilder层面被判定为状态没变,页面根本不刷新,排查了很久才发现是props漏了字段。
1.2 fbloc_event_gen的定位与优势
fbloc_event_gen就是解决这个问题的:它读取一份yaml定义,自动生成Event类、State类、Bloc类的完整骨架。你只描述“这个页面有哪几类事件、每个事件带什么参数、状态有哪些字段”,生成器负责把BLoC样板代码填好,包括构造方法、props、hashCode这些琐碎内容。
这套东西的核心优势不是“少写几行代码”,而是“改动风险从人肉维护变成配置驱动”。以前加一个请求参数,要改Event构造函数、改State字段、改props、改页面里所有构造调用;现在只需要改yaml里的一个字段,重新跑一遍生成命令,所有样板代码自动保持一致。
1.3 为什么纯Dart工具在鸿蒙化里是最容易啃的骨头
鸿蒙适配分几层:最底层是Flutter引擎本身要能在OpenHarmony上跑,中间是各种平台插件的鸿蒙实现,最上层是业务代码。fbloc_event_gen属于业务层以下的纯Dart代码生成工具,不依赖任何原生能力,不碰PlatformView,不走MethodChannel,所以它的适配成本天然就低。
实际验证下来确实如此。我原本担心代码生成器会跟鸿蒙的构建链路冲突,毕竟基线SDK、Dart版本、依赖解析都换了环境,结果发现我要处理的主要是依赖版本对齐、路径解析、构建缓存这几个老问题,比想象中简单不少。在鸿蒙化的整体进度里,它属于那种“先吃到的红利”。
2. fbloc_event_gen工作原理:从yaml定义到Equatable代码
2.1 生成管线与关键模块
fbloc_event_gen的入口有两种形态:一是以命令行方式直接扫描目录里的yaml文件,输出对应Dart文件;二是注册成源码生成器,由build_runner统一触发。我实测的版本走的是build_runner注册这条链路,它用source_gen做代码分析,再用模板引擎渲染Dart源码,本质上和别的代码生成器没什么区别。
把生成链路拆开看,大概有四步:
- 读取指定目录下的
.event.yaml配置文件; - 解析配置里的事件名、状态名、字段名、字段类型;
- 将解析结果塞进Template模板,逐个渲染出Dart代码;
- 写入对应输出路径,生成配套文件。
这个链路不需要分析已有代码,不涉及AST变换,所以它比代码修改类工具(比如那些做自动mixin注入的生成器)要干净得多,出问题的概率更低。鸿蒙适配中最大的变量在第二步:你把yaml文件放哪、用什么路径解析、模板里的import前缀对不对,这些才容易踩坑。
2.2 一个yaml定义对应的产出
以下是我按团队实际使用整理出的最简结构,各家版本字段名可能略有差异,但原理一致。假设要做一个登录页面的状态管理,yaml定义大致长这样:
events: - name: SubmitLogin fields: - name: phone type: String - name: code type: String - name: ResetLogin fields: [] state: name: LoginState fields: - name: status type: LoginStatus - name: message type: String跑完生成命令后,你会在同目录下得到类似这样的文件:
class SubmitLogin extends LoginEvent { const SubmitLogin({required this.phone, required this.code}); final String phone; final String code; @override List<Object?> get props => [phone, code]; }State文件里会看到Equatable的影子:
class LoginState extends Equatable { const LoginState({this.status = LoginStatus.idle, this.message = ''}); final LoginStatus status; final String message; @override List<Object?> get props => [status, message]; }这些代码看着普通,但它是从配置自动生成的,意味着你不会出现“加了字段忘加props”这种低级错误,因为生成器会把字段和props派发逻辑一起写出来。
2.3 Equatable深度比较在生成代码里是怎么实现的
Equatable的核心是两件事:重写==和hashCode,让两个不同对象实例在字段相同时被认为是相等的;通过props返回参与比较的字段列表,比较逻辑由Equatable统一处理。
fbloc_event_gen生成的State类默认继承Equatable,所以你在做页面刷新判断时可以直接依赖状态对象是否相等,而不需要自己写equals。但这引出一个常见误区:很多人以为Equatable一定是“深度比较”,实际上它遍历的是props里对象的==结果。如果某个字段是普通List,列表里装的是没有实现==的Dart对象,那么两个列表即使内容相同,元素引用不同,比较结果也是不相等。
所以在适配鸿蒙工程时,我建议把“深比较”理解成:生成器帮你把字段整齐放进props,但字段内部的值语义要你自己保证。后面第五节会专门讲这个坑。
3. 鸿蒙化适配第一步:工程依赖、SDK与构建器
3.1 先说清楚鸿蒙Flutter工程到底有什么不同
OpenHarmony环境下的Flutter工程,用的不是官方主干SDK,而是OpenHarmony社区维护的flutter_flutter分支。这个分支的Dart SDK版本可能与你在pubspec里声明的环境约束不一致,这是第一个要处理的问题。
另一个不同点是构建产物的差异。鸿蒙Flutter工程最终要产出HAP包,构建过程会走鸿蒙IDE的Gradle或系统构建链路,对源码生成阶段的干扰比Android工程更敏感。你如果让build_runner在项目根目录生成文件,注意别让生成物碰到鸿蒙侧的entry/src等原生目录,否则构建工具会把这些Dart文件也纳入编译,轻则多编译几秒,重则引入重复类定义。
3.2 dev_dependencies与build.yaml注册
fbloc_event_gen是开发期依赖,不能放到dependencies里。在pubspec.yaml里这样配置:
dev_dependencies: build_runner: ^2.4.0 fbloc_event_gen: ^x.y.z source_gen: ^1.5.0如果要用build_runner方式触发生成,还需要在项目根目录准备build.yaml,注册生成器。原理类似的注册方式可以参考:
builders: fbloc_event_gen: import: "package:fbloc_event_gen/builder.dart" builder_factories: ["fblocEventGen"] build_extensions: ".event.yaml": - ".event.dart" - ".state.dart" - ".bloc.dart" auto_apply: dependents这份build.yaml的作用是告诉build_runner:遇到.event.yaml文件时,启用fbloc_event_gen生成器,并且输出哪几个Dart文件。路径定义越清晰,后续排查越省事。
3.3 内网/离线环境的依赖同步方案
鸿蒙适配团队经常面对内网开发环境,pub仓库拉包不一定顺手。我的做法是在内网搭一个pub镜像仓库,把fbloc_event_gen及其依赖链全部同步进去,锁版本同步。
具体操作分三步:
- 在能联网的机器上用
flutter pub deps导出完整依赖列表; - 把列表中的每个包连同版本号上传到内网仓库;
- 在内网工程里通过环境变量或pubspec配置文件指定仓库地址。
这里有个容易漏的点:fbloc_event_gen自身可能依赖yaml和source_gen,而这两个包又有各自的传递依赖。只同步顶层包、不递归同步传递依赖,跑起来照样报“无法解析依赖”。所以一定要把flutter pub deps导出的整棵树都对一遍。
3.4 版本对齐与dependency_overrides
鸿蒙分支的Dart版本往往落后于官方最新版,而fbloc_event_gen如果用了较新的analyzer API,就可能出现解析失败。这时候不要急着改生成器源码,先用dependency_overrides把相关包降到鸿蒙分支兼容的版本试试。
我在一个项目里就遇到过analyzer版本冲突,表现为build_runner一跑就抛类型转换异常。用dependency_overrides把analyzer锁到某个旧版本后,问题直接消失。这一步是鸿蒙适配里性价比最高的操作,比fork源码改生成器逻辑快得多。
4. 实操全流程:把一个登录页完全生成出来
4.1 定义业务yaml
不管入口是CLI还是build_runner,第一步都是写yaml定义。我习惯在feature目录下放一份配置,目录结构长这样:
lib/features/login/ ├── login.event.yaml ├── login_page.dart └── login_repository.dartlogin.event.yaml里把事件和状态一起描述完,核心字段有类型、默认值、注释。这里我强烈建议给每个event补上description字段,模板渲染时会变成类上面的注释,让你在几百行生成代码里能快速定位某个事件的业务含义。
4.2 跑生成命令
如果走build_runner,命令很标准:
dart run build_runner build --delete-conflicting-outputs如果走CLI,通常是扫描目录后输出:
dart run fbloc_event_gen -i lib/features/login -o lib/features/login第一次跑完,你会看到login.event.dart、login.state.dart、login.bloc.dart出现在指定目录。这些文件建议先人工检查一遍再纳入版本控制,尤其是确认文件头注释里的package名是否和pubspec里一致。
4.3 检查生成结果
打开生成的login.state.dart,看两件事:
- 类是否继承了Equatable;
- props是否覆盖了yaml里定义的所有字段。
再看login.bloc.dart,确认on语句已经把每个Event都绑定到了对应handler。如果业务侧还需要走Repository异步请求,生成代码里一般会预留注入点,你在构造函数里传进去即可。
4.4 页面接入与刷新验证
页面侧接入和标准BLoC写法一致,用BlocProvider承载Bloc,用BlocBuilder监听状态变化。因为生成的State继承Equatable,你可以放心地在构建方法里通过状态字段做细粒度刷新控制,比如只在message变化时展示SnackBar,而不是整个页面重建。
验证流程我建议从弱到强走一遍:先在鸿蒙模拟器里看页面能否正常渲染,再手动触发异步请求观察状态流转,最后打开Dart虚拟机服务检查State实例数量。如果State实例数量在相同操作下频繁增长且UI无变化,优先怀疑props漏了字段。
5. 踩坑实录:Equatable深比较与生成链路的五个坑
5.1 坑一:生成代码里的import路径炸了
鸿蒙工程迁移后,最容易先爆的问题是import路径。现象很清楚:生成的Dart文件在IDE里一打开就标红,编译报uri_does_not_exist。
我当时的定位过程是先看文件头注释里的GENERATED CODE区块,发现它生成出来的import一直是package:旧工程名/xxx.dart,而鸿蒙分支工程在创建时可能改了project name,导致package路径对不上。
解决办法:检查pubspec里的name字段是否与当前工程一致;再打开生成器的模板配置,把package名参数改成正确的值。如果是历史工程迁移,建议全局搜一下旧的package名,避免生成代码里残留旧路径。
5.2 坑二:以为是深比较,结果还是引用比较
这个坑影响最隐蔽,现象是:列表下拉刷新后,页面偶发不刷新,日志里能看到数据已经返回,但BlocBuilder判断旧状态和新状态相等,直接跳过重建。
定位链路是这样的:先看State的props,确认列表字段已经在里面;再打印新旧两个List的hashCode,发现hashCode不同;接着检查列表元素类,发现它是普通Dart类,没有重写==和hashCode。
根因就是Equatable比较的是props里对象的==结果,而普通类的==默认按引用比较。两个List里各装了一个字段完全相同但引用不同的对象,ListEquality即使逐个对比元素,也会因为元素的引用不同而认为两个List不相等。这个坑不解决,你就会不断地无谓重建,或者在某些逻辑里误判状态没变。
解决思路有两个:一是让列表元素也实现Equatable或手工重写==、hashCode;二是在State里不把完整的Model对象放进去,而是放一个可以稳定比较的ID集合。我在鸿蒙工程里最终选了前者,因为生成的代码本来就依赖Equatable,Model层统一实现最符合直觉。
5.3 坑三:output目录冲突与脏缓存
有一段时间build_runner频繁报Conflicting outputs,定位后发现是工程里有人手动创建了和生成文件名相同的文件,build_runner认为存在两个source产出同一个output。
更常见的场景是:改了yaml,把某个字段删了,重新跑生成命令,旧的类还残留在生成文件里。这是因为build_runner有增量缓存,如果生成器逻辑本身没有清理旧输出,缓存里就会残留上一次的产物。
我的处理方式是分层解决:先用--delete-conflicting-outputs跑一次;还不行就dart run build_runner clean;再不行直接删掉.dart_tool/build目录重新构建。这个操作在鸿蒙工程里也是安全的,因为生成代码本来就是产物,重跑一遍就回来了。
5.4 坑四:Windows路径分隔符带来的解析问题
团队里有人用Windows开发,他那边跑生成命令时总是匹配不到yaml文件,我开始以为是他路径写错了,后来发现是路径分隔符的锅。
Windows下传进去的路径是lib\features\login这样带反斜杠的格式,而生成器内部如果用的是正斜杠做glob匹配,就会漏掉文件。命令行里把-i lib/features/login改成正斜杠后能解决一部分问题,但更稳妥的做法是让生成器内部统一用package:path的posix模式处理路径,或者干脆约定所有人在CI上跑生成命令,不依赖本地环境。
我建议后一种。现在团队里所有代码生成操作都放到CI脚本里执行,本地改动只需要提交yaml文件,既绕开了路径问题,又保证了生成环境的一致性。
5.5 坑五:重建后生成文件与热重载不同步
鸿蒙Flutter引擎的hot reload对新增文件的支持和标准SDK有些差异,我们在真机上遇到过:重新生成的event.dart文件里多了一个新事件类,页面代码已经引用了,点击热重载却一直报找不到类。
排查后发现,这是新增源文件后热重载没有正确拾取文件列表导致的,跟生成器本身无关。稳妥做法是:首次生成或删除文件时不要依赖热重载,先冷启动一次,确认类能被正常解析,之后的热重载才可靠。后来我在团队里定的规范是“生成代码之后必须冷启一次再开始写页面逻辑”,省了很多无谓的等待时间。
6. 适配前后效率对比与模板定制
6.1 从数据看样板代码成本
我在迁移过程中顺手统计了一个中等列表页和登录页的代码量,对比手写模式与配置生成模式的差异,结果很有说服力:
| 场景 | 手写样板代码 | yaml定义 | 生成后代码 |
|---|---|---|---|
| 登录页(3个事件+4个状态字段) | 约160行 | 约40行 | 约230行 |
| 新闻列表(4个事件+6个状态字段) | 约200行 | 约45行 | 约280行 |
| 表单页(多状态叠加) | 约260行 | 约55行 | 约340行 |
只看行数,生成化并没有减少总量,它甚至比手写代码还多。真正的收益在后续改动:手动维护200行样板,改一个字段要动好几处;配置驱动的方案里,改yaml后重新生成,所有关联代码自动同步。这个差异在需求变更频繁的迭代期尤其明显,一个字段的增删从半小时级别降到一分钟级别。
6.2 怎么定制团队自己的生成模板
fbloc_event_gen的渲染模板通常是公开的源码文件,你可以直接改模板代码来适配团队规范。我们当时的定制点有三个:
一是文件头注释。默认模板可能只是GENERATED CODE,我改成了带生成时间戳、生成器版本、触发命令的完整注释,方便出问题时追溯来源。
二是类的命名风格。团队约定State类统一加ViewState后缀,Event类统一加Action后缀,这些命名规则直接在模板里写死,避免每个成员各写各的。
三是给每个生成类补一个简单的toString方法。调试鸿蒙真机问题的时候,控制台打印State内容比盯着一堆Instance of 'LoginState'要直观得多。
定制模板后要注意一点:升级fbloc_event_gen版本时,模板文件可能被覆盖或冲突。我的建议是把定制好的模板单独放到tool/templates目录下,通过生成器参数指定模板路径,而不是直接改了包源码之后还升版本。
6.3 生成代码要不要提交仓库
这个问题在各团队吵过很多次,我的结论很明确:生成代码建议提交仓库。
理由有两个。第一,CI环境跑生成命令需要完整还原build_runner和source_gen依赖链,内网环境同步这些依赖本身就有成本,提交生成代码可以让CI直接编译,减少环境依赖。第二,代码评审时review生成结果比review yaml更直观,业务同学能看清实际运行的类长什么样。
缺点是合并冲突会多一点,尤其多人同时改yaml时,生成文件经常在git上产生冲突。解决方式是团队约定yaml文件只有一个owner,其他成员改之前先同步最新分支再动配置,不要让两个人同时改同一份业务yaml。
6.4 CI校验生成文件一致性
最后强烈建议在CI加一个检查步骤:跑一遍生成命令后,检查git diff是否为空。这样能防止有人改了yaml但忘记重新生成,或者改了模板但生成产物没更新。
脚本逻辑不复杂:
dart run build_runner build --delete-conflicting-outputs git diff --name-only --exit-code lib/ docs/如果diff不是空,CI直接报失败,让提交者回本地跑一次生成再重新提交。这个检查在鸿蒙适配期间特别重要,因为大家都在快速改代码适应新构建链路,很容易出现“本地能跑但生成的代码不是最新”的情况。
我在实际跑鸿蒙适配的过程中,最大的体会是:代码生成器这类工具,真正值钱的部分不是“让代码更少”,而是“让错误更少”。手写BLoC样板时,错误散布在构造方法、props、hashCode这些细节里,每一个都很难排查;把逻辑收敛到yaml和模板之后,出问题的只有配置本身和模板本身,排查范围一下子小了很多。如果你也在做Flutter工程的鸿蒙化迁移,建议先花半天把fbloc_event_gen的生成链路跑通,再回头处理插件适配,整个节奏会顺不少。