Dart 团队宣布放弃宏(Macros)功能的时候,我第一时间是想鼓掌的。不是我不需要这个功能,恰恰相反,这几年写 Flutter 业务代码,被 JSON 序列化、数据类、copyWith 这类样板代码折磨得不轻。但当我看到宏从预留特性到原型实现,再到最终被取消的全过程,尤其是看到了 DCM(Dart Code Metrics)这类静态分析工具在生态里爆发的潜力之后,我的结论和团队保持一致:取消宏,是 Dart 语言发展史上最务实的决定之一。这篇文章不聊八卦,就从一个普通 Flutter/Dart 开发者的视角,聊聊宏的初心、它死在哪个环节,以及“没有宏”的 Dart 靠什么活得更好。
1. 宏原本想解决的生产力痛点,如今依然是切肤之痛
1.1 Dart 生态里绕不开的样板代码三座大山
我承认,宏被提上日程的初衷完全是正当的。Dart 语言在服务端和客户端双线作战,大家抱怨最多的,不是语法不支持函数式,而是——手上的活全浪费在读模型和写模型上。
第一座大山是 JSON 的序列化和反序列化。Flutter 项目里最常见的场景就是接后端接口。从Map<String, dynamic>里手动as String或者as int,写一百个字段就得写一百行提心吊胆的转换代码。json_serializable这个包解决了一部分问题,但它需要 build_runner 先生成代码,生成完的.g.dart文件一有模型改动就要重新跑一遍。
第二座大山是数据类的不可变性。写一个User类,要手动写==、hashCode、toString、copyWith。如果你用过 Kotlin 的data class,回来看 Dart 的普通类,会觉得像回到了手工劳作的年代。freezed这个包填补了空白,但本质上仍然是在用代码生成器模拟语言级特性。
第三座大山是依赖注入的模板化。手写 ServiceLocator 的注册代码,每加一个 Repository 就要复制粘贴十几行,改一个依赖少则两三处,多则牵连一整个测试文件。
这三座大山的存在,让 Dart 团队在 2020 年前后认真考虑了宏。当时的思路很清晰:如果能像 Swift 的@Observable或者 Kotlin 的@Parcelize那样,直接在字段上标注一个装饰器,编译器自动展开成模板代码,上面所有痛点都能一次性解决,而且调用方代码会干净到令人发指。
1.2 从“注解”到“宏”的距离,比想象中远得多
这里插一句,很多人把 Dart 里的@override或者@Deprecated理解成宏,这是一种误读。Dart 的 annotation 只是元数据,本质是无操作的标记。@override是让编译器检查父类方法签名,@Deprecated只是给 IDE 一个提示。它们没有能力在编译期生成新的声明、修改类的结构或者注入方法体。
真正的宏,是要在编译期间捕获 AST(抽象语法树),甚至形成新的语法节点。这个“捕获”和“重写”的过程,在 Python 这种动态语言里还好说,Python 的装饰器是运行时拦截函数调用,对象模型本身就允许猴子补丁。但 Dart 是静态类型语言,类型检查要在宏展开前后都成立,不然你没法保证宏生成出来的代码不破坏类型安全。
所以,当初 Dart 团队rollout的宏方案,不是 Python 式的运行时装饰器,也不是 C 语言的纯文本替换,而是类似 Swift 的独立宏声明加外部实现的模式。宏定义者编写一个单独的宏类,编译器在指定的阶段(比如类型检查之后)调用宏的build方法,把宏展开的代码重新放进 AST 里。
理论上,这个方案能在保证类型安全的同时实现代码复用,甚至能做出 freezed 和 json_serializable 的编译期统一体。光想一想就觉得很美:part文件和.g.dart文件全都可以干掉,build_runner 也不再需要,编译一次成型,IDE 跳转直接进宏定义。
但是,一旦进入工程化落地的阶段,事情就变味了。
2. 宏方案在编译器和工具链里真实撞上的墙
2.1 编译速度这个大前提,差点被宏拖垮
Dart 作为 Flutter 的底层语言,一直把编译速度当成生命线。Flutter 引以为傲的热重载(Hot Reload),依赖的是 Dart 编译器的增量编译能力。你在 Android Studio 里改一行代码,编译器只需要把改动的那一个库重新编译,然后和之前的 assets 合并,整个链路要控制在秒级,用户才能获得“改完立刻看到效果”的体验。
而宏一旦进入这个链路,会带来一个可怕的问题:宏展开的缓存有效性判定。
如果常量或者编译期可执行的数据,展开逻辑不依赖外部的脏数据,那还好办。但宏的定义体里一旦引用了别的库里的常量,或者依赖了文件系统里的配置(比如dart define传进来的环境变量),编译系统就必须判断这个宏的输入哈希有没有变化。变了就要重新展开,而不只是重新编译被修改的代码。
这听起来还好?那再想一层:宏展开之后生成的 AST 会被重新解析为源码,参与后续的类型检查。类型检查的结果如果反过来影响宏的展开条件(比如某个字段在类型层面已被宏改写),就会产生循环依赖。解决这个问题的通用手段,是把编译阶段分成多轮,每一轮只展开不受上一轮结果影响的宏,直到 AST 不再变化。
这个多轮展开的机制一旦引入,增量编译的路径就不再是线性的。热重载时,编译器无法只关注和改动文件相关的库,因为宏可能从另外一个库展开到你正在改的这个文件里。这种不可预测的全局影响,对一个以“毫秒级反馈”为核心体验的框架来说,几乎是不可接受的。
2.2 类型系统的负担:宏展开后的代码还要再做一遍类型检查
第二个撞墙点是类型检查的重复计算。
Dart 的强类型系统本来就为 null safety 做了大量静态分析。引入宏之后,类型检查器必须对宏展开前后的代码各做一遍检查。第一遍检查宏本身写得对不对,第二个检查宏生成出来的代码在当前上下文里是否类型安全。
这里的复杂度不是线性增加的,而是组合爆炸。宏类里有泛型参数,宏方法有类型参数,宏展开的代码里还引用了宏调用者的私有类型或者函数,这些交叉会让编译器在分析和命名解析阶段需要维护一个非常复杂的中间表示。实现这个复杂度的难度,远超给语言加一个extension type或者新的 pattern matching 语法。
Dart 团队内部曾经在某个 issue 里坦承,宏的最简可行版本(MVP)就已经需要改动核心编译器的大部分架构,包括欢迎句法分析器、AST 转换层、常量求值器、库加载器,甚至还有前端服务器(用于 IDE 的语法分析)。这是个巨大的工程,不是一两个大版本能完成的。
2.3 宏和 IDE 的相爱相杀:补全和跳转必须实时的痛苦
像我们这种天天在 IDE 里写 Dart 的人,很难回忆起没有代码补全和可靠跳转的日子。而宏的存在,天然和 IDE 功能冲突。
设想一个场景:你用@jsonSerializable标记了一个模型类,IDE 要在输入user.的时候自动提示name、age、toJson这些成员。但是,toJson是宏展开出来的,它并不存在于源代码里。IDE 为了提示这个成员,必须先执行宏展开逻辑。
如果没有宏,IDE 只需要做标准的语法索引,成员补全可以直接从 AST 里提取。有了宏,IDE 就得内嵌一个 Dart 编译器的部分后端,并运行宏代码。宏的代码可能依赖第三方包,可能有 IO 操作,可能动态计算配置,这会让补全操作从几毫秒变成几十甚至几百毫秒。如果宏展开出错,代码补全直接消失,那是多么痛苦的开发体验。
Dart 团队和 JetBrains 合作在 IDE 插件层面做了不少实验,但结果都不理想。宏带来了一个“既要又要”的死局:既要编译期有完整的类型信息,又要在编辑期(还没编译完)就能给开发者展示成员。就算做成增量缓存,宏定义的修改也会让大范围的缓存失效,而 IDE 的响应能力是用户感知最直接的部分,一卡就会骂娘。
2.4 语义版本的雷区:宏展开器可以依赖语言内部细节
还有一个很容易被忽略的点,但做工具链的人会当场崩溃——宏展开器实现时依赖的底层库,可能和 Dart 语言自身的演进产生冲突。
Kotlin 和 Swift 的宏(或者编译器插件)相对更成熟,是因为它们背后的编译器实现公开稳定,有明确的各种 entry-point。而 Dart 的编译器历史上迭代非常快,前端、kernel、后端本来就经常调整。Dart 团队如果要提供一个公共宏 API,等于是要把内部架构冻结一部分,用来保证宏定义者的代码在未来的编译器版本上依然能转。
换句话说,引入宏会让语言开发背上一个“永久兼容”的包袱。以后编译器内部想重构,就得考虑所有宏定义包的兼容,而这个包袱本来是不需要的。Dart 团队显然意识到了,语言设计不能为了个别生产力特性,牺牲掉整个编译器和 SDK 的迭代自由度。
3. DCM 和代码生成器接棒:没有宏的生态反而更健康
3.1 code generation 的野路子:build_runner 生态的成熟
宏被取消,不代表样板代码的痛点就让人硬扛。实际上,Dart 生态走了一条曲线救国的路线:用代码生成器(code generator)加上静态分析插件,把原本想在编译器里做的事,挪到了构建时和编辑器里独立完成。
这条路线的代表就是 build_runner 生态。freezed、json_serializable、riverpod_generator、drift都是干这个的。虽然它们跑起来没有宏那么“无缝”,每次改完模型要重新跑一遍 build_runner,但这个代价换取的是方案的可控性:
- 生成的代码是纯 Dart 文件,可以提交进 git,也能直接在调试器里断点。
- 生成器本身是一个普通的 Dart 程序,可以用我们最熟悉的调试方式测试和断点。
- 如果生成器出了问题,不会连带拖垮编译器的核心进程。
- IDE 在生成完成之后,可以正常索引、补全、跳转,不需要理解额外的 AST 变换逻辑。
我自己在实际项目里是深度用户。比如用freezed定义可辨识联合类型,写完一个 sealed 类,然后自动生成when和map方法。虽然跑 build_runner 要等十几秒,但胜在结果透明、可审查。如果使用宏,生成的代码在 AST 里,出了问题连调试入口都找不到。
3.2 DCM 补上静态分析空缺的那块拼图
如果说代码生成器解决的是“样板代码产出”的问题,那 DCM(Dart Code Metrics)解决的则是“代码质量检查”的问题。
DCM 和 analyze 命令不一样,analyze 主要看语法错误和基础 lint,DCM 提供的是跨文件级别的复杂度和可维护性检查。你甚至可以订阅 DCM 的插件机制,自己写分析规则。
这里就有个关键点:DCM 之所以能跑得这么顺畅,就是因为 Dart 语言没有被宏的 AST 变换搞复杂。如果宏存在,每一个分析规则的编写者都要考虑“当前代码里哪些成员是宏生成出来的”“宏展开后的调用链要不要计入圈复杂度”“宏生成的代码能不能被自定义检查规则读取”这些问题。没有宏,DCM 的规则只需要关心源码这一层,做静态分析时不用额外跑一个解释器。这就是工具链的确定性优势。
我们自己团队最近接入了 DCM 的自定义规则:约定所有 public API 必须有 doc comment,所有异步方法不能被 unsafe 的as转换污染。这套规则在 CI 里自动打分,配合 analyze 一起跑,非常丝滑。如果换到宏场景,这个规则得先去理解宏展开的代码,复杂度高出一大截还不一定稳定。
3.3 真正缺的不是“宏”,而是“更好的代码生成体验”
Dart 团队并没有原地摆烂。他们在取消宏的同时,把重心转向了让 build_runner 更好用,这比硬啃宏要务实得多。
比如他们优化了 build_runner 的增量缓存,只在lib/目录下依赖变更时重新生成。又比如在 Dart 3.4 之后强化了@DataClass注解和 IDE 的联动(虽然这只是中间产物),还有 Kotlin 风格的扩展字段在整个分析器里的支持。
而且,宏方案里开发者的希望,大都会在“代码的代码”问题上绕不开一个核心难题:作为用户,你到底愿意为了“少写几行样板代码”,用多大的代价换取编译链路的复杂化?从社区反馈看,绝大多数人其实愿意选择先写手动代码,再在 CI 里检查质量。这与工程文化的务实倾向一致:追求可预测性和稳定性,而不是短期的语法糖。
4. “没有宏”让 Dart 在 AI 辅助编程时代显得特别从容
4.1 AI 生成代码和宏的定义,天然冲突
这两年的 AI 编程辅助工具(Copilot、Codeium 等)已经成了很多团队的工具链标配。这种背景下,宏的处境变得更尴尬了。
AI 学习的代码语料,是人类手写的代码、文档和开源项目中的实际代码。宏是“由编译器生成代码”的方案,它依赖的是一个外部的语义模型,AI 很难从现有语料中学会“在哪个场景下定义一个宏展开器”。AI 要想正确写出宏,需要推理源代码的 AST 结构、宏参数的类型、宏展开后可能会生成多少个嵌套语法节点。这超出了绝大多数代码补全模型的能力。
反过来,代码生成器(build_runner)生成的是普通 Dart 文件。AI 在训练时见过大量.g.dart文件,知道copyWith和toJson一般长什么样子。AI 生成的业务代码即使不合规,至少也停留在普通源码层面,更容易分析和修正。
Dart 取消宏,从 AI 工具链的兼容性角度看,等于避开了 AI 无法理解的高级抽象,保住了让 AI 能直接参与码字的基础盘。
4.2 手写代码的可预测性,还是交付安全的底线
我在几个合同项目里被迫使用过 Kotlin 的宏(编译器插件形态),印象最深的是:当宏展开出错,IDE 指向的是宏定义文件,和真实算法逻辑隔了一层,排查链路极长。而在 Dart 里,生成代码是显式的,任何 review 的人都能打开xxx.freezed.dart看到产出。
站在团队管理的角度,这一点对于保证交付安全太重要了。代码审查没法审查一个未来才会展开的 AST 变换,但可以审查已经展开的.g.dart。如果用了宏,团队成员犯错的成本和排查问题的成本都会上升,尤其是在跨项目的复杂依赖里,一个宏会影响出人意料的范围。
4.3 标准化样板代码的未来:交给模板,交给 DSL,而不是交给宏
在 JavaScript/TypeScript 世界里,样板代码的处理方式是模板字符串和代码生成脚本(如 hygen、plop);在 Dart 世界里,build_runner 天然扮演了这个角色。我们完全可以用 shellexec 和 mason 这样的管理工具,把freezed+json_serializable+riverpod组合起来,做成一个内部脚手架,新模块一键生成全套模板。
宏能让你写一个注解就展开出一整套 CRUD。但只要你做一个私有依赖注入库,封装一个@AutoInject,再配一个 build_runner 生成器,效果完全一样,而且更可控。这就是 Dart 生态的真实现状:不走宏,走生成器 + 模板 + DSL 的组合拳,反而让每个环节都可以替换和调试。
5. 我的个人判断:这波劝退,劝得越早,Dart 未来越稳
5.1 对比 Kotlin 和 Swift,Dart 的劣势在于团队规模和生态阶段
Kotlin 有 JetBrains 这种重兵投入 IDE 的公司做后盾,Swift 有苹果整个工具链部门在养。Dart 团队相对小而精,核心精力又长期集中在 Flutter 这个框架本身。对这样的团队来说,维护一个宏系统意味着全年无休的兼容性测试、编译器核心 bug 修复,还得面对一堆第三方宏框架的依赖炸弹。
更关键的是,Dart 生态不像 JVM 生态那样存在大量大型企业级框架,宏如果被滥用,会在顶层设计上引发很多风格不一致。没有宏,反而能保持语言本身的“幼稚”和干净——在一个生态还没完全成熟的阶段,这是宝贵的演进特权。
5.2 社区的真实反应:少数愤怒,大多数回归理性
宏要取消的消息放出后,少数开发者觉得受到了背叛。但仔细观察那些抱怨的人,多半是把宏和“代码生成”混为一谈,或者只是对样板代码烦躁,并没有真的主导过宏的详细设计。
反而是很多实际维护过大型 Flutter monorepo 的开发者,对取消宏持肯定态度。因为他们经历过 build_runner 生成中断导致整个 CI 全红、代码生成版本和源码不匹配、以及pub get后不同机器生成的代码不一致等痛苦,他们明白编译期的黑盒生成行为会带来更隐蔽的问题。而 DCM 等的实践,恰恰把静态分析和代码生成的职责分开,让各自领域的复杂度变得可控。
5.3 最后的实操建议:没有宏的日子,怎么用组合拳提升效率
如果你现在正焦虑“没有宏怎么办”,我的建议是把重心放到下面这套组合方案上:
- 数据类建模:
freezed(不可变 + copyWith + 可辨识联合) - JSON 序列化:
json_serializable(配合explicitToJson: true) - 状态管理模板:
riverpod+riverpod_generator - 数据库和本地缓存:
drift(代码生成器加迁移数据库) - 代码质量门禁:DCM 自定义规则 + dart analyze 标准规则
- 项目脚手架:mason 自定义模板,一键生成模块
这套组合拳没有一项需要语言级魔法,但每一样都可以在 CI 里独立验证,也可以被别人审查。工程上的确定性和可控性,往往比花哨的语法语义更能决定一个项目的长期健康度。
Dart 选择放弃宏,放弃的是在编译器层面引入一个长期复杂度的风险,换来的却是工具链生态的稳定、AI 编程的兼容以及语言演进的自由度。从这个角度回看,这确实是无比正确的决定。