ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter鸿蒙迁移实战:基于codemod与AST的批量代码重构

Flutter鸿蒙迁移实战:基于codemod与AST的批量代码重构 去年下半年我们团队接到一个很难受的活把一套已经跑了两年的 Flutter 工程往鸿蒙生态迁移。一开始我以为是换换依赖、改改窗口配置真正开工才知道最消耗人的根本不是架构设计而是几千处“看起来差不多但又不能统一替换”的重复代码修改。那段时间我天天在改三类东西三方库 API 调用、页面导出方式、组件通信写法。改到第三天我就意识到手工改下去肯定不行必须找一套能基于 AST 做精准变更的自动化工具。后来我在 Dart 生态里翻到了 codemod配合 analyzer 的 AST 解析可以把“找到某种代码模式 → 生成替换补丁 → 批量应用到整个仓库”的全流程变成一套可复用的流水线。这篇文章就是把我在鸿蒙化适配中实践的 codemod 用法、设计思路、踩坑记录完整写出来。内容偏实操适合正在做 Flutter/鸿蒙跨端迁移的团队也适合想给大型 Dart 工程做规范化的同学参考。如果你只会写线性脚本的正则替换看完这篇你大概会明白为什么“基于代码结构的批量修改”才是大规模重构的正确姿势。1. 项目背景与方案设计思路1.1 鸿蒙化适配究竟在“适配”什么先说背景。我们的项目是 Flutter 为主的技术栈要跑上鸿蒙设备第一步是引用鸿蒙分支的 Flutter 引擎。这里不讨论引擎移植细节只说一个事实引擎换了但代码不会自己适配。平台通道、路由入口、窗口生命周期都可能有差异。我们实际碰到的重复劳动大致三类三方库 API 迁移某个旧 package 在鸿蒙运行环境下不可用需要切到兼容实现调用点分散在几十个文件里。页面注册改动鸿蒙侧的窗口加载方式Stage 模型下的 windowStage.loadContent要求入口文件结构变化涉及的模板代码需要批量统一。组件通信改造之前用自研的事件总线做页面间通信现在要收敛到统一的跨端通信封装每个 send/listen 调用都要改。这类工作有一个共同特点模式可识别、改动量大、单点逻辑极其简单。手工做容易漏正则做容易误伤。我试过用 sed 和 perl 脚本处理第一次跑完全仓库就冒出一堆问题——字符串里的示例代码被改了、注释里的旧 API 介绍被改了、不该动的生成文件也被改了。这也是我决定转向 AST 方案的根本原因。1.2 为什么 codemod 而不是手写遍历脚本不少同学会问AST 方案我用 analyzer 自己写个脚本不行吗为什么要引入 codemod老实说纯自己写完全可行但 codemod 帮我解决了几个从头写会比较麻烦的问题。文件遍历与调度它知道怎么按目录找 .dart 文件、怎么跳过 .g.dart不用自己拼递归。Patch 的冲突处理当你对同一文件产生多个补丁时重叠补丁的检测和合并策略是现成的。命令行交互codemod 的主入口支持交互式确认 diff也支持 CI 里用的 --yes、--dry-run 模式。组合能力多个 suggestor 可以串联执行先统一 import再改调用最后补格式化一个流程下来做到位。除此之外codemod 的定位就是“助手”它的输出是补丁真正吃进 AST 和产出逻辑的是你自己的 suggestor。这个模型非常符合工程化改造的场景你只要能描述“找什么、改成什么”它负责安全和高效地落地。1.3 整体方案架构我们最终跑通的结构是这样一条流水线git 仓库全量文件 → 按扩展名过滤 → 进入 codemod 的 suggestor 管道 → analyzer 解析出 AST → 节点匹配 sourceSpan 定位 → 生成 Patch 补丁 → 应用补丁 → dart format 统一格式 → git diff 人工复核 → commit。这条链路里最重要的一点是所有修改都发生在原始文本的 offset 上而不是直接改 AST 对象。AST 只负责“找到要动的区间”真正的替换内容仍以源码字符串片段为准。这样能最大程度保留注释、空行和原有格式diff 也便于 review。方案确定后我们按三个场景分别落实API 调用替换、类级标记注入、组件通信封装改造。下一篇开始拆具体技术点代码模型和 AST 的匹配逻辑是整个方案的根基。2. 核心技术拆解codemod 工作机制与 AST 变换原理2.1 codemod 的核心 APISuggestor、Patch、Aggregator先快速过一遍 codemod 的概念模型。整个框架围绕三个角色转Suggestor你的改造逻辑。输入是某个文件的上下文输出是一组补丁描述“把哪段换成什么”。Patch一次最小修改包含起始偏移、结束偏移、替换文本。runCodemod入口函数负责遍历文件、调用 suggestor、收集补丁并应用。一个最简单的 suggestor 骨架长这样import dart:io; import package:codemod/codemod.dart; import package:analyzer/dart/analysis/utilities.dart; import package:analyzer/dart/ast/ast.dart; class ApiReplaceSuggestor extends Suggestor { override bool shouldSkip(FileContext context) { // 跳过生成文件和第三方源码 return context.path.endsWith(.g.dart) || context.path.contains(/.dart_tool/); } override IterablePatch generatePatches(FileContext context) sync* { final source File(context.path).readAsStringSync(); final result parseFile( path: context.path, content: source, ); final unit result.unit; unit.visitChildren(_Visitor((node) { yield Patch(node.offset, node.end, replacementText); })); } }代码里省掉了 Visitor 的具体实现但核心结构就是这样先读取源码再解析 AST然后遍历节点遇到符合模式的节点就按源码位置产出 Patch。最后在 main 里调用runCodemod(findFiles(*.dart, lib/), ApiReplaceSuggestor())就能跑起来。具体的 API 形式随 codemod 版本略有差异但 Suggestor 产出 Patch 这个模型一直没变过。2.2 AST 解析到补丁生成背后发生了什么理解 AST 之前先理解一个直觉问题为什么不能直接按字符串替换因为源码是一个“有结构”的文本。foo(bar())这段字符串人在读的时候一眼就知道 foo 是一个函数调用、bar 是它的参数、那对括号是配对的。但字符串匹配不知道这些。你拿正则去搜foo(可能搜到 import、注释、字符串字面量还可能在多层嵌套时匹配错边界。AST 做的事情就是把源码解析成树形结构。拿 Dart 的 analyzer 来说会把源码拆成 token再按语法规则组装成节点。比如一个方法调用oldApi.load()在 AST 里对应一个 MethodInvocation 节点它的 target 是 SimpleIdentifier(oldApi)methodName 是 SimpleIdentifier(load)argumentList 是空的。我们匹配时只需要判断“target 的 name 是不是 oldApimethodName 是不是 load”完全不用担心它在注释里出现还是嵌在字符串里。找到节点后怎么把改动翻译成文本修改每个 AST 节点都带 sourceSpan记录了它在原始源码中的起始偏移和结束偏移。Patch 就是这两个偏移加替换文本的三元组。codemod 拿到所有补丁后统一在源码上做区间替换再输出最终文件。这里有个我特别在意的工程细节补丁替换的是文本区间但 AST 本身是从原始源码解析出来的“快照”。如果你的 suggestor 在 yield Patch 之后还想去读这个节点的子节点没问题但如果你想迭代式地修改同一份 AST 再重新生成源码就会踩坑。实际方案里每个 suggestor 保持“只读分析、偏移定位、文本替换”三步职责单一。2.3 我总结的四个设计原则这套东西跑多了以后我慢慢总结出四个原则基本能避免 80% 的坑。第一先用文本规则做粗筛再用 AST 确认。直接对每个 AST 节点做复杂匹配会很慢尤其大仓库。我们通常先生成一份“需要在意的源文件路径列表”或小子串判断命中后才走进遍历。第二补丁要尽量小。只替换需要变化的那一小段不要整个方法体输出。替换范围越大diff 就越难 review越容易掩盖意外修改。第三必须幂等。同一个 suggestor 对已经改过的文件再跑一遍结果应该和原文件完全一致。如果不一致说明补丁没有写好重复执行就重复改是自动化重构的大忌。我们后来把它写进了 CI 校验。第四所有改动必须能 diff。任何 codemod 流程的第一步永远是 dry-run拿补丁生成一个临时 diff人眼扫一遍再决定是否应用。自动化不是不要人工 review而是把人工 review 从“逐文件改”变成“看 diff”。3. 实操过程与核心环节实现3.1 环境准备这套方案不需要太复杂的环境。我用的是独立 Dart 工程来承接 codemod避免污染业务源码工程。具体步骤新建一个纯 Dart 项目比如 toolchain/codemod_runner。在 pubspec.yaml 里加依赖codemod、analyzer、source_span、dart_style。建 bin/ 目录放各场景的入口脚本。把业务工程的路径通过命令行参数传进来不在代码里写死。这里顺带提一句做鸿蒙相关调试时我会同时开 DevEco Studio 和本机 Flutter 环境用真机做最终验证。模拟器能省很多编译时间但 windowStage.loadContent 这类窗口生命周期行为真机和模拟器差异不小别省最后一步。目录结构和依赖装好后就可以开始写第一个 suggestor 了。我们的三个场景中API 调用替换是最好入门、也是收益最直接的下面就用它作为完整案例。3.2 场景一全局替换三方库 API 调用背景是这样工程里大量使用old_pkg的OldService.start()来启动某些能力鸿蒙环境下这个包不可用团队决定统一改走内部兼容层compat_service。要求有三个只改调用点、不改任何注释和字符串、自动补 import。实现分四步。第一步遍历 AST 找到需要改动的位置。我们关注两类节点ImportDirective用于后续判断 import 是否存在和 MethodInvocation用于找到OldService.start()。第二步判断是否命中。判断逻辑不复杂MethodInvocation 的 target 如果是个 SimpleIdentifier它的 name 是 OldServicemethodName 是 start就命中。这里还要落实到源码文本里再确认一遍目的就是防 AST 和文本不一致的极端情况。第三步生成 Patch。命中的调用点我们直接把整个 MethodInvocation 节点对应的区间替换成CompatService.start()。注意替换文本里不需要保留原来的链路对象因为新 API 本来就是静态入口。第四步补 import。如果整个文件里出现了命中但源码头部没有import compat_service.dart;就额外产生一个 Patch插在最后一个 import 之后。这个操作比较讲究见第 5 节的问题排查。核心伪代码如下class OldServiceToCompatSuggestor extends Suggestor { override IterablePatch generatePatches(FileContext context) sync* { final source File(context.path).readAsStringSync(); final result parseFile(path: context.path, content: source); final unit result.unit; var changed false; final visitor _OldServiceVisitor(); unit.accept(visitor); for (final call in visitor.calls) { final snippet source.substring(call.offset, call.end); if (!snippet.contains(OldService.start()) continue; yield Patch(call.offset, call.end, CompatService.start()); changed true; } if (changed !source.contains(import compat_service.dart;)) { final lastImport _findLastImport(unit); if (lastImport ! null) { yield Patch(lastImport.end, lastImport.end, \nimport compat_service.dart;); } } } }这段代码没有把所有边缘情况都处理干净比如 OldService 被导入时带了 prefix、文件本身没有 import 等真实版本里都要补。但骨架就是这样的找到命中 → 产出替换 Patch → 有改动且缺 import 时再产出插入 Patch。跑完执行器后codemod 会把所有文件的补丁应用到磁盘。我们会先跑一遍--dry-run把生成的改动刷成 diff 检查一眼确认没有破坏注释中的示例再正式应用最后统一dart format。3.3 场景二批量给页面类加迁移标记第二个场景和鸿蒙适配关系很直接为了统一追踪还有哪些页面没完成鸿蒙兼容我们需要给所有 Page 的 State 类加一个标记注释和 mixin。手工加是几十个文件的事但漏一个就白干所以也写成了 suggestor。思路是找 ClassDeclaration。判断规则类名以 PageState 结尾且没有混入 CompatPageMixin就在类声明行之前插入一行// 鸿蒙适配标记并在 extends 或 with 位置加 mixin。这个场景重点演示的是如何“改结构但不破坏原格式”。AST 里 ClassDeclaration 有一个 extendsClause 字段可以直接拿到父类信息withClause 会列出 mixin 列表。我们用classNode.withClause null或检查现有 mixin 列表来避免重复插入。跟 API 替换不同的是这里的 Patch 需要“插入文本”偏移就用类声明的起始位置替换文本留空即可。这个场景的坑主要是有的 State 类用了with AutomaticKeepAliveClientMixin如果你硬把新 mixin 插到 with 列表最前面会把原有 mixin 顺序打乱。稳妥做法是把 CompatPageMixin 追加在现有 mixin 列表末尾。基于 AST 你可以准确拿到 withClause 的最后一个标识符的 end再决定插入点。3.4 场景三组件通信方式统一改造第三个场景对应工程里最常见的“组件通信”问题。我们旧工程里组件间通信喜欢直接 new 一个 EventBus 再全局持有鸿蒙化后要切到统一的跨端通信封装收发调用的 API 签名变了。改动本质就是把EventBus().emit(xxx)改成ChannelBus.send(xxx)EventBus().on(xxx)改成ChannelBus.listen(xxx)。用 codemod 实现时对 MethodInvocation 的 methodName 做映射即可。相比场景一这个场景特有的问题是参数列表可能变化旧 API 的 on 回调签名是(String, Object)新 API 是(Map)。如果只做 API 名称替换编译会挂。所以我们的 suggestor 在处理 on 时策略更复杂先识别on(xxx)的调用然后在回调体的开头插入一行参数解构代码。这已经不是“点对点替换”而是“一个调用点对应多处补丁”。codemod 的 Patch 模型处理这种场景很自然同一个 suggestor 里 yield 多个 Patch分别落在 methodName 区间和回调体区间。这类改造跑完后我们还会借助 analyzer 再做一次未解析引用的扫描确保没有遗漏的旧 EventBus。这也是自动化改造的闭环关键codemod 负责“改”静态检查负责“验”。3.5 CI 集成把 codemod 变成工程规范守卫实操中codemod 不只用于“一次性迁移”它还能当规范守卫用。我们在 CI 里加了一个 job拉最新代码后跑一套“规范化 suggestor”但执行模式是 dry-run然后对比 git diff。如果仓库里存在应该被改造但没被改造的文件diff 非空job 直接失败开发者本地跑一次 codemod 并提交后job 就绿了。这套“工具兜底规范”的玩法在鸿蒙化适配中特别好用因为它把“记住要改哪些 API”从人脑里搬到了工具里。新写的代码如果又用了旧 APICI 会立刻拦下来而不是等联调时才炸。4. 大规模鸿蒙工程规范化经验4.1 按目录与模块分批别一口气全跑工程超过一定规模后一次性全量跑 codemod 的压力不小而且 review 也麻烦。我们的经验是按模块分批先跑公共核心库再跑业务页面最后再跑工具类代码。每一批独立 commitdiff 控制在几百行以内review 压力小回滚也容易定位。分批跑的操作很简单runCodemod 的 paths 参数只传对应模块目录即可。比如lib/features/order/、lib/features/user/分别执行。这样还能利用 shouldSkip 更精细地控制范围。4.2 排除生成代码与第三方包规范化的前提是知道哪些文件不能动。我们的排除清单列了三类.g.dart/.freezed.dart等代码生成产物。build/、.dart_tool/下所有内容。第三方包的源码即使它们也在 git 仓库里。排除逻辑统一放在 shouldSkip 里。这里有个经验不要在文件遍历的正则里做排除因为某些生成文件可能命名不规律shouldSkip 里做路径/名称判断更可靠。4.3 与 ArkTS 侧工程联动的对照思路鸿蒙化的范围不止 Dart 侧。原生鸿蒙工程里的 ArkTS/TS 代码也需要规范化比如 Stage 模型下 windowStage.loadContent、UIAbility 的 export 写法布局里 RelativeContainer、Flex、Tabs 的用法约束。Dart 侧我们用了 codemodArkTS 侧则可以使用 TypeScript 生态的 AST 工具比如 ts-morph 或 jscodeshift。分析思路完全一致解析源码成 AST按节点类型做匹配生成文本补丁。两套工具链跑在同一个流水线里时关键是统一“什么时候改 Dart、什么时候改 ArkTS”的边界。我们按目录约定lib/下是 Flutter/Dartentry/src/main/ets/下是 ArkTS。CI 里两个工具各自扫描自己的目录输出统一格式的 diff 报告提交信息里也能快速看出这次改动影响的是哪一端。4.4 规范化跑出来的实际收益这里给一组我们项目里的数据不追求精确只呈现量级。一个 30 万行 Dart 的仓库纯人工改 API 调用按每人每天 200 处算需要 5 个人干两周。codemod 跑完一次大约 20 分钟diff 出来后两个人 review 半天加上修边界 case 的调试时间整个过程一个工作日以内结束。更值钱的是后续CI 规范守卫上线后新引入的旧 API 调用在提交阶段就会暴露而不是两周后的联调阶段。这个收益曲线很典型一次性迁移节省人力长期规范提升质量。如果你只是拿 codemod 跑一次脚本那是浪费了它最核心的工程价值。5. 常见问题与排查技巧实录5.1 补丁偏移错位与重叠最常见的问题一个 suggestor 对同一文件产出了多个 Patch但 Patch 之间的偏移互相影响。codemod 内部会检测重叠但如果你对同一个 AST 节点同时生成两个部分重叠的 Patch轻则报错重则应用出乱码。我的排查经验是对一个文件里可能多次命中的场景不要依赖“边遍历边改”的直觉先把所有命中位置收集成列表再统一去重、按偏移排序最后再 yield。如果还是出错就在 yield 前打印start/end和实际文本肉眼对一下就知道是不是跨了节点边界。5.2 import 重复与幂等性失败自动补 import 是最容易写坏的逻辑。第一次跑完了没加 import第二次跑发现缺了又补一次结果出现两行相同 import。问题在于 shouldSkip 或源码判断没挡住。标准做法是先检查整份源码的文本里是否已经包含目标 import 语句再决定要不要插入。另一个保险是给项目加一个“双跑校验”同一个 suggestor 连跑两次第二次输出必须和第一次完全一致否则 CI 直接拦。幂等性不是加分项是基线要求。5.3 匹配范围过宽误伤注释与字符串即使基于 AST也可能误伤。比如方法名匹配时如果只判断 methodName 的字符串没判断 target那foo.emit()、other.emit()都会被命中。更隐蔽的是字符串插值里的调用${EventBus().emit(x)}在 AST 里也是一个 MethodInvocation 节点但它出现在字符串里改不改需要业务判断。我们的处理策略是双保险先看 AST 结构再把原始文本 substring 出来做一次包含校验。文本校验不通过就不改。还有一个技巧把“命中”的日志输出成 CSV包括文件、行号、上下文代码review 时可以快速排查。5.4 大仓库执行速度慢解析 AST 是耗时大头。30 万行代码跑一次 20 分钟主要时间都花在 parseFile 上。优化手段有几招只传实际要改的文件路径不要全仓库递归。先用 git diff --name-only 或 git log 筛选最近变更文件。轻量文本预筛如果文件里完全没出现目标 API 的特征字符串直接 shouldSkip。这几招叠加后我们的执行时间从 20 分钟降到了 3 分钟左右效果非常明显。下面把高频问题整理成速查表现象原因排查建议Patch 应用后源码乱码偏移重叠或跨节点替换统一收集再排序按区间校验去重同一 import 出现两行补 import 逻辑缺少幂等判断先查源码再插入双跑校验注释里的示例代码被改匹配逻辑没限定 target/source 语境AST 判断 文本 substring 双保险跑得非常慢解析了不必要的大文件缩小文件范围加上子串预筛格式化全乱补丁替换后未跑 dart format每次应用补丁后统一执行 format认为该改的没变化shouldSkip 误判路径打印 shouldSkip 的日志确认CI 检查失败但本地通过本地和 CI 的路径/版本不一致固定 Dart SDK 和 codemod 版本5.5 格式化与版本锁定的细节最后必须强调版本锁定的问题。codemod 和 analyzer 的 AST 节点形态在不同版本间会变如果本地和 CI 用的版本不一致补丁结果就可能不同。我们的做法是把 toolchain 工程写死最低版本约束并在 CI 里固定 Dart SDK 版本。这个细节看着小实际上救过我好几次。我自己的习惯是所有 codemod 改造流程都先跑 dry-run 产出临时 diff看完再决定是否 apply。特别是大批量规范化的场景宁可多花 5 分钟在 commit 前看 diff也不要等合并后半夜收到线上告警。工具能自动改但工具不能自动背锅改完的每行代码都值得被人工过一遍。如果你接下来也要做 Flutter 工程的鸿蒙化适配我的建议是先别急着写工具把你自己的替换规则写清楚哪些 API 需要替换、哪些 import 需要补齐、哪些目录必须动、哪些目录绝对不能动。规则越清晰codemod 的 suggestor 就越好写。等到规则稳定下来你甚至会想给更多规范场景也配上这套自动化因为一旦上手你就很难再回到“打开文件、逐个 CtrlH”的日子里去了。
返回列表