Flutter 应用跑上鸿蒙之后我盯着构建产物体积发呆同样的业务代码HAP 包比 Android 版大了将近 20%。翻开依赖树和 Dart 源码一查问题根本不在引擎或中间层而是大量历史迭代遗留下来的无用类、废弃函数和“暂时注释但没删”的模块全部被打进了鸿蒙产物里。dead_code_analyzer 本来就是干这个活的可它默认按 Flutter 标准工程结构去扫描碰到鸿蒙的 hvigor 配置、entry/module 多模块结构、还有 .ets 侧对 Dart 的桥接调用直接跑偏甚至压根找不到入口。这篇就把我给 dead_code_analyzer 做鸿蒙化适配的过程完整拆开原理、改了什么、怎么集成、踩了哪些坑。适合正在做 Flutter 到鸿蒙迁移、并且想认真控制 HAP 包体积和工程纯净度的团队参考。1. dead_code_analyzer 的原理与鸿蒙化价值1.1 死代码是怎么混进 Flutter 工程的先说一个大家都有体感的场景App 从 1.0 做到 5.0业务线换过三波人老的活动页下线了但pages/old_activity整个目录还留在工程里曾经为了“以防万一”封装的network_deprecated.dart一直没人删第三方包依赖了foo库实际上只用了其中两个方法剩下的一堆工具类全被 AOT 编译进了快照。这些都是死代码。更隐蔽的是条件编译残留。很多人用if (Platform.isAndroid)写双端逻辑后来鸿蒙适配时又加了if (Platform.isHarmonyOS)但 Android 分支的旧代码并没有真正移除只在逻辑层面绕过去了。对运行时代码来说它确实没执行但对静态分析工具来说它仍然是“可达”的所以不会太高亮。这就需要一个能基于语义层面做引用分析的家伙来兜底。死代码的直接代价有三个方向一是 Dart AOT 快照尺寸变大因为快照里会包含所有编译单元里的代码二是 IDE 提示和全局搜索被污染重构的时候经常跳到“已经没人调用”的定义三是 review 效率下降新同学看老代码根本分不清哪些是真废弃、哪些是暂时没用。dead_code_analyzer 的价值就是把这堆问题显性化。1.2 dead_code_analyzer 为什么能精准找到它们这不是一个用正则匹配unused关键字的玩具工具。它底层基于package:analyzer也就是 Dart 官方分析器会对工程里的所有.dart文件做完整的文法分析和元素解析建立 class、function、field、top-level 变量之间的引用关系图。然后从一组“根部入口”出发做可达性遍历所有无法从入口图走到的声明就会被打上unused标签。与静态字符串搜索最大的区别在于它能区分“定义被引用”和“定义真的在运行路径里”。比如一个函数被另一个死函数调用搜索工具可能会误判为 “活跃”但 dead_code_analyzer 会沿着引用链一路回溯只要根入口没引用到整条链都会标记成死代码。对于 Dart 这种支持顶级函数、mixin、extension、factory 构造的语言这套语义模型比普通文本扫描可靠得多。它的输出也足够工程化。你可以拿到 JSON 或者纯文本报告里面包含文件路径、声明名称、类型、行列号以及“为什么认为是死代码”的简要信息。报告可以直接喂给 CI 脚本也可以生成 diff 视图看一次迭代到底新增了多少死代码。默认还会读取analysis_options.yaml里的自定义配置支持用 exclude、include 和 ignore 注释来降低误报。说白了它就是把 Android 生态里常见的 “Undeclared / Unused code checker” 思路搬到了 Dart 侧。1.3 鸿蒙化时清死代码的特殊意义鸿蒙的 HAP 包体积和 Android APK 还不完全是一回事。Flutter 应用跑在鸿蒙上最终产物里除了*.so引擎库和 assets还会带一份 Dart AOT 编译产物。正因为是 AOT编译期会把所有可达代码都做进快照死代码不会因为没运行就自动消失反而会被一起编进去直接挤占包体空间。很多团队优化半天图片和资源却忽略了 Dart 侧这一大块。另外鸿蒙上架对包体积也有隐性的体验门槛尤其面向政企或旧设备场景下载体积直接影响激活转化率。包体积每小一点引擎下发、热修复、灰度回滚都会更快。工程纯净度也很关键鸿蒙生态起步阶段官方 IDE 的分析工具对 Flutter 混合工程支持还不完善团队如果留着大量无引用文件DevEco 的索引和 Flutter 插件的补全都会变慢。dead_code_analyzer 在鸿蒙工程里跑起来后相当于给整个 Flutter 侧做了一次“代码审计”。2. 鸿蒙化适配的整体思路与技术难点2.1 先摸清鸿蒙 Flutter 工程的目录结构和依赖在动手改工具前得先搞清楚鸿蒙 Flutter 工程到底长什么样。一个典型的鸿蒙应用工程会分成entry、feature等多个 moduleFlutter 相关代码可能放在某个模块里作为 SDK 集成或者在entry/src/main/ets下通过桥接层调用。你会发现它和 Android/iOS 工程的目录约定完全不一样鸿蒙侧有module.json5、oh-package.json5、hvigorfile.ts还有src/main/ets/pages这类 ArkTS 页面目录。dead_code_analyzer 原本的设计假设是“Dart 入口都在lib/main.dart其他业务代码都收在lib/下”。但鸿蒙工程里Flutter 模块可能被打到oh_modules依赖里也可能被放到library_flutter/src/main/dart这种自定义位置。如果只按默认入口扫描第一轮就会得到“入口不存在”的报错。更麻烦的是鸿蒙侧 ArkTS 代码可以通过平台通道直接调用 Dart 侧暴露的方法。也就是说有些 Dart 类虽然没有任何 Dart 文件引用它但它其实是给.ets用的 API 层。如果适配工具没有把这些跨语言入口纳入根节点它们就会被误报成死代码严重时会导致清理脚本把线上正在使用的接口删掉。这是鸿蒙化适配和普通桌面/移动端 Flutter 工程最大的区别。2.2 适配的四个关键点入口发现、忽略规则、输出路径、平台桥接我总结下来鸿蒙化适配集中在四个点。第一点是入口发现。不能只认lib/main.dart要能从module.json5里面去解析入口页面然后再映射到对应的 Dart 入口文件。比如鸿蒙模块里配置了mainPage: pages/Index这里的 “Index” 可能对应 Flutter 侧暴露的一个 widget适配层就得把这个 widget 所在的 Dart 文件也当成根入口。第二点是忽略规则。鸿蒙工程里会有大量的生成代码和中间产物比如.arkui/、oh_modules/、build/、hvigorfile.js转译出来的临时文件这些都不能参与分析否则会把死代码报告刷爆。默认的 exclude 配置需要针对这些目录做一次全面补充。第三点是输出路径。鸿蒙构建系统用的是 hvigor默认构建产物路径可能是entry/build/default/cache、entry/build/default/outputs这种而不是 Android 的build/app/outputs。工具报告的保存位置和 CI 读取位置要跟着调整不然脚本跑完仍然找不到文件。第四点是平台桥接。需要维护一张“鸿蒙专属入口表”把被 ArkTS、JSI 插件或自定义通道调用的 Dart 符号加进去。另一个做法是支持配置文件里写external_entry让开发者手动声明外部入口。适配时一定要留着这个逃生舱否则后续误报规则很难控制。2.3 为什么不建议直接用原生版本硬跑我最初也想偷懒直接用原版在鸿蒙工程上执行结果是灾难现场入口解析失败导致所有页面文件全部被判成死代码Platform.isAndroid和Platform.isHarmonyOS的分支让分析器以为部分逻辑不可达还有跨 module 的依赖关系完全断裂A 模块引用的 B 模块代码被标成未使用。这种情况下你根本不敢跑清理动作因为报告的可信度太低了。所以鸿蒙化适配不是“加几个跳过目录”就完事它需要调整分析器的根节点发现策略、外部入口声明机制和输出约定。本质上你是在把一把原本为 Android/iOS Flutter 工程设计的尺子改成能适配 ArkTS 混合工程的量规。3. 鸿蒙化适配实操从拿到源码到编译通过3.1 准备环境与拉取源码适配之前先准备一套干净可复现的环境。我这里用的是Flutter SDK建议和鸿蒙侧的 Flutter SDK 保持同一版本基线至少主版本一致OpenHarmony 的 Flutter SDK 或华为提供的鸿蒙 Flutter 分支Dart SDK最好是 Flutter 自带的 Dart避免版本冲突git、DevEco Studio用来生成和验证鸿蒙工程dead_code_analyzer 我建议直接 fork 一份因为后面要改动它的入口解析逻辑所以主仓库保持同步自己维护一个harmony分支。拉下来后先执行dart pub get确认原生版本能通过自测再开始动刀。这里注意一个细节工具依赖的analyzer包版本要和你 Flutter SDK 内部的 Dart 分析器版本匹配否则会出现“无法分析 Flutter 框架代码”的诡异报错。最简单的判断方式是看pubspec.lock里analyzer的版本然后和flutter --version输出比对。3.2 修改入口发现逻辑让工具认识鸿蒙目录核心改动在入口发现。原先的代码通常类似这样ListString findEntryFiles(Directory projectRoot) { final candidates String[]; final libDir Directory(${projectRoot.path}/lib); if (libDir.existsSync()) { final mainFile File(${libDir.path}/main.dart); if (mainFile.existsSync()) { candidates.add(mainFile.path); } } return candidates; }鸿蒙化之后我把入口发现拆成两个阶段。第一阶段仍然保留lib/main.dart作为兜底第二阶段解析module.json5ListString findHarmonyEntryFiles(Directory projectRoot) { final entries findEntryFiles(projectRoot); final moduleFile File(${projectRoot.path}/entry/src/main/module.json5); if (!moduleFile.existsSync()) { return entries; } final config parseJson5(moduleFile.readAsStringSync()); final mainPage config?[module]?[mainPage]; if (mainPage ! null) { // 把 pages/Index 映射成 lib/pages/index.dart final dartEntry projectRoot.path /lib/ mainPage.replaceAll(., /) .dart; if (File(dartEntry).existsSync()) { entries.add(dartEntry); } } return entries; }这里有几层考虑module.json5里配的mainPage本质上是 ArkTS 页面路由但我们约定 Flutter 侧的入口页面文件放在lib/pages/下并且 Dart 文件名和路由名保持大小写一致。如果你的团队不是这个约定可以改成读取一个独立的harmony_entry.yaml手动维护映射关系。对于多模块 feature做法类似每个 feature 模块下找自己的module.json5再递归汇总入口。3.3 调整忽略规则与平台专用目录入口问题解决后下一步是让忽略规则适配鸿蒙工程目录结构。我在配置文件的exclude段加了这样一些模式analyzer: exclude: - **/oh_modules/** - **/.arkui/** - **/build/** - **/hvigor/** - **/*.g.dart - **/generated/**不要忽略所有.ets文件之前的 Dart 桥接文件因为有些桥接文件是纯 Dart 写的。更安全的做法是把这些桥接文件加入external_entry而不是直接排除。我习惯单独建一个dca_harmony.yaml把鸿蒙相关目录和普通业务目录分开管理保持默认配置干净。实际使用中还发现freezed、json_serializable生成的.g.dart文件也经常被误报为“死代码”——它们确实没有被显式引用而是通过part指令参与编译分析器在部分版本里对part的支持有缺陷。所以**/*.g.dart放进 exclude 非常必要。3.4 编译并验证代码改完后进入验证阶段。先跑一遍静态检查dart analyze lib test没问题后编译成可执行文件dart pub get dart compile exe bin/dead_code_analyzer.dart -o bin/dca然后在鸿蒙 Flutter demo 工程里创建一个测试目录故意放两个未使用的 dart 文件再执行./bin/dca --config dca_harmony.yaml --entry lib/main.dart --external-entry harmony_entries.json --output build/reports/dead_code.json如果报告里能正确标出那两个文件并且没有把.ets桥接的接口标红说明基础适配完成。我还习惯做一次反向验证把真正线上用到的 Dart API 加入 external-entry 临时清单跑完看报告里是否还误报以此来校验外部入口表的完整性。4. 集成到鸿蒙 Flutter 工程的日常研发流程4.1 命令行接入方式与 DevEco 快速运行适配不是跑一次就完事要融入日常。我建议维护一个tool/dca_check.sh脚本把配置、入口发现、输出路径都固化下来#!/usr/bin/env bash set -e PROJECT_ROOT${1:-.} OUTPUT_DIR${PROJECT_ROOT}/build/reports mkdir -p $OUTPUT_DIR bin/dca \ --config dca_harmony.yaml \ --entry $PROJECT_ROOT/lib/main.dart \ --external-entry harmony_entries.json \ --output $OUTPUT_DIR/dead_code.json \ --format json然后把这个脚本注册进 DevEco Studio 的 External Tools这样开发者在 IDE 里按一下快捷键就能跑。要注意的是DevEco 默认会对工程文件做索引大量死代码会让索引变得很慢所以快速运行命令也有额外价值能在索引卡死前把问题抛出来。4.2 在 CI 中自动检查死代码并输出差异报告更硬核的用法是绑定 CI。我这边用的是 GitLab CI直接在.gitlab-ci.yml里加一个 stagedead_code_check: stage: test script: - dart pub get - dart compile exe bin/dead_code_analyzer.dart -o bin/dca - bash tool/dca_check.sh - bash tool/check_dead_code_diff.sh --baseline build/reports/dead_code_baseline.json --current build/reports/dead_code.json --threshold 20 only: - merge_requestscheck_dead_code_diff.sh的逻辑是读当前分支和基线分支的报告计算出新增死代码数量超过阈值就让 MR 失败。阈值怎么定我建议从 0 开始给团队一周缓冲期之后固定 0 容忍。因为一旦工具集成到 CI开发者就能在提交前快速发现问题根本没有理由再制造新增死代码。报告不是给人看的是给 diff 看的。我后来加了--report-diff参数能输出“这次 MR 相比 main 分支多出了哪些死代码”这个信息比绝对数量有用得多。code review 的时候直接在 MR 评论里贴 diff 报告讨论非常具体。4.3 定制忽略规则避免频繁误报误报是死代码检查工具落地最大的阻力。我遇到过的情况有通过override但父类本身已死导致子类全部标红、用Function.apply反射调用的方法标红、还有从enum.values隐式引用的字段标红。解决方案不是把整个目录排除而是在代码里加// ignore: dead_code_analyzer并在工具里支持三种粒度的忽略方式文件级// dca-ignore-file声明级dcaIgnore目录级配置文件里的 exclude 通配符我比较推荐声明级和目录级结合。声明级适合那些被外部通道引用、但分析器看不到入口的 API目录级适合生成代码和第三方依赖。如果某个文件一周内被忽略了五次以上我会让脚本输出一个警告提醒团队要么补充外部入口要么真的删掉它。5. 实战中踩过的坑与排查技巧5.1 误报鸿蒙生命周期回调被当成死代码最典型的一个坑页面级 Dart 类会被 ArkTS 侧通过生命周期回调触发比如override void onPageShow()它不在任何 Dart 引用链里只在.ets里注册了回调。dead_code_analyzer 的默认根节点里没有这些符号于是报告显示“该类未被引用”实际上线上每次返回页面都会调它。排查思路是先看这个 Dart 类是否在external_entry里声明过。如果确认是被 ArkTS 引用的就在harmony_entries.json里补一条{ external_entries: [ { type: method, name: PageLifecycleHandler.onPageShow, reason: called from ArkTS page } ] }补完再跑就不会误报。这个文件最好由负责鸿蒙桥接的同事维护每次新增桥接调用时同步更新形成固定的走查项。5.2 鸿蒙多 Target 模块导致入口重复feature 模块和 entry 模块都有main.dart时工具可能把两套入口同时塞进根节点导致同一个类被分析两次报告数量翻倍。更麻烦的是有些类在 feature 里是死代码在 entry 里是活代码合并报告时不知道听谁的。我是这么处理的默认禁止跨模块传递入口每个模块单独出一份报告最后在 CI 层面叠加 diff。这样每个模块的代码纯净度是独立评价的不会因为 entry 活就掩盖 feature 的死代码。同时在入口发现逻辑里增加一个--module参数指定本次分析的是entry还是feature避免目录遍历到隔壁模块。5.3 性能问题大工程分析内存不足Flutter 工程变大后分析器要建立全量 AST内存峰值很可怕。我们一个中等体量的 App跑一次大概需要 6GB 内存CI 上偶尔直接 OOM。后来做了三件事限制并发目录数、关闭不必要的文件扫描、为工具增加--max-workers 2参数。效果很明显内存峰值降了大概 30%代价是分析时间从 3 分钟涨到 5 分钟这个取舍是值得的。如果工程真的有几十万行 Dart 代码我还建议只分析 modified 文件的依赖子图而不是全量。dead_code_analyzer 当前不支持增量分析但可以通过脚本记录上一次的文件清单只对有变化的文件夹跑然后连同上次的报告一起合并。对日常开发来说这个曲线方案比等全量跑完舒服太多。5.4 三张表常见错误码、报错原因和修复动作错误特征常见原因修复动作提示“main.dart not found”鸿蒙工程 Flutter 入口不在标准路径在配置里显式指定--entry大量.ets桥接方法标红外部入口表未更新把桥接方法加入external_entries.g.dart全部标红analyzer 对part指令识别问题exclude**/*.g.dart跨模块符号到处乱标入口根节点跨模块合并分模块独立跑配置--moduleOOM分析内存不足降低--max-workers、分目录分析输出 JSON 为空没有读取 hvigor 构建产物目录在配置里设置--output build/reports还有一个特别隐蔽的坑当工具运行在 Windows 上时文件路径用反斜杠\而配置里的通配符用的是正斜杠导致 exclude 完全不生效。统一在入口处把路径标准化成/后问题彻底消失。6. 实际效果与后续维护6.1 包体积和工程纯净度的量化变化以我们现有的鸿蒙 Flutter 工程为例第一次跑完整报告Dart 侧无用声明数量占比为 13.8%。清理后 HAP 体积从 22.3MB 降到 20.1MB缩减约 9.2%。这个数据不是固定的取决于工程历史包袱但方向一定是正的。包体积之外工程纯净度提升更明显。原来 IDE 里搜索一个页面类要翻好几屏的废弃代码清理后索引速度肉眼可见地变快。部分同事反馈之前因为担心删错所以不敢动的“历史遗留代码”现在有报告兜底重构胆量大了很多。这才是这个工具真正的价值——它把“说不清有没有用”变成“可控、可查、可回溯”。6.2 后续维护建议与生态展望工具适配到鸿蒙只是开始不是终点。我建议维护一个内部文档记录每个外部入口的引用路径这样鸿蒙桥接层一有改动就能快速判断是不是要同步更新harmony_entries.json。同时定期跟踪原作者仓库的更新如果上游新增了更好的 ignore 语法或增量分析能力可以 cherry-pick 过来。也可以考虑把 dead_code_analyzer 和资源清理工具配合使用。Dart 侧清完后再配合 iconfont 裁剪和图片无损压缩整个 HAP 包还能再瘦一圈。不过我不建议一开始就把所有指标调到最激进先清 Dart 侧死代码再动资源最后调整构建配置按这个顺序走回归风险最小。从我自己维护这套适配方案的经验来看最重要的一点是不要把这东西当成一次性清理工具。把它做成 CI 的一部分让新增死代码在 merge request 阶段就被拦截团队才会真正形成“不留无用代码”的肌肉记忆。最后分享一个小技巧跑完报告后把dead_code.json和上一次基线做一次 diff哪怕只有 5 行变化也值得在周会上花两分钟讲一讲很多隐藏的需求变更和架构腐化就是从这些细小的死代码缺口里露出来的。