鸿蒙化Flutter工程依赖治理:dart_depcheck适配实践
前阵子把一个 Flutter 项目迁到鸿蒙化工程结构时依赖管理这块差点让我破防。pubspec.yaml 里明明躺着十几个看似还在用的包实际代码里早就没人 import 了两个功能相近的图片库同时存在版本各自为政也没人管最离谱的是 CI 里谁都没发现某个基础库被悄悄回退过一版直到线上问题爆出来才顺着 lockfile 一步步查出来。这种依赖失控在双端工程里太容易发生了光靠 code review 根本看不住。后来我把 dart_depcheck 捡起来针对鸿蒙化工程做了适配才真正把依赖健康检查、冗余包识别、版本冲突预警从口号变成了流水线上能自动跑的东西。这篇文章就围绕这个适配过程展开先说清楚 dart_depcheck 到底是怎么工作的、鸿蒙化工程里哪些环节会让它失灵再讲我做的解析链路改造最后给你一份可以直接抄的验证清单和踩坑记录。适合正在做 Flutter 鸿蒙化迁移的客户端同学以及所有不想被依赖问题半夜叫醒的 Flutter 工程负责人。1. dart_depcheck 的原始工作逻辑它凭什么能发现冗余包和版本冲突1.1 依赖健康检查到底在检查什么很多团队的依赖管理还停留在能 build 通过就行的阶段。但 build 通过只能说明依赖约束被满足了说明不了依赖是否被真正使用、是否存在版本漂移、有没有多个版本被同时解析进来。依赖健康检查做的是另外三件事冗余识别、冲突预警、锁定漂移感知。冗余识别是找那些声明了但没人用的包。版本冲突预警是检测依赖树上同一包名被解析出多个版本或者约束区间互相打架的情况。锁定漂移感知则是对比 pubspec.lock 与 pubspec.yaml 的声明是否一致防止有人绕过正常流程改了依赖却没重新生成 lockfile。dart_depcheck 在纯 Dart/Flutter 工程里干的活基本就是把这三种检查自动化。它通过解析 pubspec.yaml 拿到声明依赖集合再通过源码 import 分析拿到真实依赖集合两个集合一对比冗余包就显形了。它的价值不在于单个检查项有多复杂而在于能把这件事变成一次命令就能跑完的常规动作。1.2 从 pubspec 到依赖图的解析流程要理解鸿蒙化适配的难点就得先理解 dart_depcheck 拿到一个项目后做了什么。第一步读取 pubspec.yaml 和 pubspec.lock提取 direct_dependencies、dev_dependencies 以及锁定版本信息。第二步通过 analyzer 的解析 API 扫描 lib/ 目录下的 Dart 文件收集所有package:开头的 import 来源同时也处理 export 和 part 语句。第三步把声明的依赖集合与扫描到的引用集合求差集配上当前依赖的版本号、依赖来源hosted/git/path/sdk输出一份未使用依赖清单。需要注意这个工具不是简单地做字符串匹配。它解析的是 Dart 的 AST所以对import package:a/a.dart as a、import package:a/a.dart show Foo、export package:b/b.dart这几种形式都能正确处理。如果是动态构造 import 路径或者依赖通过dart:mirrors做反射调用那它就无能为力了这也是后面误报的主要来源之一。1.3 版本冲突预警的判断依据dart_depcheck 的版本冲突预警严格来说分两个层次。第一层是显式冲突依赖树里同一个包名同时出现两个以上互不兼容的约束来源比如包 A 要求foo: ^1.2.0包 B 要求foo: ^2.0.0此时 pub 工具大概率会在 resolve 阶段就报错工具这边更像是在做兜底校验。更常见的是第二层约束能够满足但实际锁定版本发生了迁移。比如^1.2.0在 dev 环境被解析成 1.2.3在 release 环境因 pub-cache 不同被解析成 1.2.9行为可能产生细微不一致。HN 化项目在这块更敏感因为原生侧往往还有一份 oh-package.json5 的依赖声明与 pub 侧的依赖树并不是一一对应关系。如果只查 pub 侧原生侧的版本漂移完全照不到。2. 鸿蒙化 Flutter 工程里的解析断链原始工具跑不起来的三个原因2.1 鸿蒙化工程目录与原生侧依赖元数据带来的差异把 Flutter 工程鸿蒙化之后目录结构会明显变化。除了原先的 lib/、android/、ios/还会多出 entry/、hvigorfile.ts、oh-package.json5 这样的鸿蒙侧文件。Dart 代码往往生成或拷贝到entry/src/main/ets/侧的相关模块里原生逻辑则挂在 ohos 目录下。这里第一个断链点出现了dart_depcheck 默认只认 pub 侧的依赖体系对 oh-package.json5 里的 dependencies 完全不感知。如果某个包只在鸿蒙侧被原生模块引用、Dart 层没有直接 import工具就会把它判定为冗余包。反过来某段原生跳转逻辑依赖一个 HarmonyOS 桥接包Dart 源码扫描不到真实依赖反而被漏掉。第二个断链点在于 import 语句的形态。鸿蒙工程里大量使用.ets和.ts文件引入模块用的是import xxx from ohos/xxx这类语句和 Dart 的package:语法完全不是一套。analyzer 默认不解析这些文件dart_depcheck 的源码扫描范围没有覆盖它们。2.2 原始 dart_depcheck 在鸿蒙工程里的具体异常我在一个中等规模的鸿蒙化 Flutter 项目上直接跑原版 dart_depcheck得到的报告基本没法直接看。问题一误报率夸张。ohos/xxx这类依赖被扫成未识别引用同时 oh-package.json5 里声明的原生依赖又被标记为疑似冗余。问题二解析中断。某个包引用到的路径在 pubspec 中解析不到工具直接抛异常退出整个工程一刀切的检查流程在这里断掉。问题三性能不可控。工程里如果有一个遍历式扫描逻辑把.ets、.ts、.json5全部当源码文件处理单个仓库的检查时间会从几秒膨胀到几分钟。这些问题本质上都是同一个原因工具理解的依赖模型是pubspec 声明 Dart import 引用 lockfile 版本锁定三元组而鸿蒙化工程的实际模型是pubspec 与 oh-package 双声明 Dart/ETS/TS 多语言引用 lockfile 与 oh-package-lock 双锁定。模型不匹配所有下游能力全部失真。2.3 为什么不能靠扫描一切文件来绕过问题最容易想到的适配方式是扩大扫描范围把所有文件都当成源码去扫把 oh-package.json5 里的依赖全部导入然后交给工具自行比对。实际操作下来会发现这条路走不通。扩大文件范围必然导致假引用膨胀。鸿蒙工程里有大量资源文件、build-profile.json5、代码生成目录这些文件里的字符串可能恰好和目标包名同名但并不是真实代码依赖。如果把它们当成引用来源冗余包识别等于失效。另一个问题是依赖归属权不清晰oh-package.json5 的依赖并不一定都能在 pub 源站上找到两者去重和归一化需要一个中间层处理。所以正确的做法不是扫得越多越好而是建立一个能把 Dart 侧、HarmonyOS 侧两套依赖元数据统一归并的模型再用这个统一模型去驱动检查。这个思路就是鸿蒙化适配的核心先做模型层适配再做扫描层适配。3. 适配方案落地统一依赖解析器与双端元数据归并3.1 确定适配边界哪些检查项必做、哪些可以放弃动手前必须想清楚边界不然很容易在某个无关紧要的功能上耗掉大量时间。我的取舍是必做项冗余包识别且识别范围覆盖 pubspec 与 oh-package 双声明版本冲突预警覆盖 pub 侧依赖树和 oh-package-lock 的版本对照锁定漂移检查主要看 pubspec.lock 与 pubspec.yaml 的语义版本约束是否满足。可不做项对构建产物的依赖分析、对动态反射调用的依赖追踪、对远端私有源依赖的实时解析。这三个场景要么收益低要么维护成本高。反射依赖根本没有静态解私有源依赖解析需要额外引入认证机制得不偿失。边界方法可以参考所有扫描依据源码静态引用 配置文件声明两个事实凡是依赖真实确定的、可以重复复现的就纳入规则凡是现象级依赖、不确定归属的先跳过并用 whitelist 机制管理。3.2 自定义依赖解析器以 dart_depcheck 扩展点为核心做一层适配我不打算 fork 并改完整个 dart_depcheck。更文明的做法是依赖 dart_depcheck 的扩展机制替换它内部的依赖解析器与扫描器。以 dart_depcheck 当前的主流程来说它对外暴露的核心概念是依赖模型一个包名、一个版本区间、一个来源声明、一组引用位置。我们实现一个兼容此模型的自定义解析器即可思路如下// 简化版适配层伪代码核心是给 dart_depcheck 的 resolver 换成鸿蒙感知版本 class HarmonyAwareResolver implements PackageResolver { final PubspecParser _pubParser; final OhPackageParser _ohParser; final DartImportScanner _dartScanner; final EtsImportScanner _etsScanner; override FutureDependencyGraph resolve(DependencyContext context) async { // 1. 先拿到 pub 侧声明依赖与 lockfile 锁版 final pubDeps await _pubParser.parse(context.pubspecPath); final lockDeps await _pubParser.parseLock(context.lockPath); // 2. 解析鸿蒙侧 oh-package.json5 与 oh-package-lock.json5 final ohDeps await _ohParser.parse(context.ohPackagePath); final ohLockDeps await _ohParser.parseLock(context.ohLockPath); // 3. 扫描 Dart 与 ETS/TS 两类源码文件 final dartRefs await _dartScanner.scan(context.libDirs); final etsRefs await _etsScanner.scan(context.etsDirs); // 4. 把两个来源的声明与引用合并生成统一依赖图 return DependencyGraph.merge( declarations: [pubDeps, ohDeps], locks: [lockDeps, ohLockDeps], references: [dartRefs, etsRefs], ); } }注意不同版本的 dart_depcheck 对扩展点的命名和形态会有差异。关键是理解这条链路声明解析、锁定解析、源码扫描、引用比对四段都要做双通道合并。3.3 双依赖元数据归并的三条归并规则双通道合并不是说把两份数据倒进同一个数组就完事而是要按规则归一化。第一条规则是身份归一。同一个包在 pubspec 里可能叫image_cropper在 oh-package.json5 里对应的原生实现可能叫ohos/image_cropper_binding。这俩不是同一个东西但有关联关系。我们需要在适配层维护一个双向别名表保证 Dart 引用的package:image_cropper不会和ohos/image_cropper_binding被当成两个互不相干的依赖。第二条规则是冗余判定优先级。判定某个依赖是否冗余时应优先看其是否在任何 Dart import 或任何 ETS import 中被引用都没有的时候再看是否为另一个依赖的传递依赖最后才允许降级为疑似冗余并进入人工复核清单。第三条规则是版本冲突比较基准。pub 侧比较用语义化版本约束oh 侧比较用同为语义化版本但可能带ohos/前缀的字段比较之前统一去掉前缀。跨端同名包冲突时以高主版本所列约束为准但要单独提示用户。这三条规则整理成表格适配时对照使用归并规则处理前处理后目的身份归一image_cropper / ohos/image_cropper_binding 两个包映射为逻辑依赖项标记绑定关系避免冗余误判冗余判定优先级按代码引用顺序直接判定Dart 引用优先、ETS 引用次之、传递依赖兜底准确率提升版本比较基准两端各自比较互不联通去掉前缀后统一比较跨端冲突预警3.4 让扫描层认识 ETS/TS 文件并正确抽取引用解析器换了扫描层也要跟着动。dart_depcheck 默认的 analyzer 只认 Dart 文件我需要给它扩展出对.ets和.ts文件的基本 import 抽取能力。不需要完整编译 ETS那工程量太大了。只需要一条简单的正则加字符串处理链路找到import ... from ...和import ...两种形态取出模块路径与别名然后和 oh-package.json5 里的包名做匹配。核心代码可以抽象成如下逻辑// 在 dart_depcheck 的扫描器之外补充一个轻量 ETS 引用扫描器 function extractEtsImports(fileContent: string): string[] { const importRegex /import\s(?:[\w\s{},*]*?\sfrom\s)?[]([^])[]/g; const matches []; let m; while ((m importRegex.exec(fileContent)) ! null) { matches.push(m[1]); } return matches; }这里有个容易踩的坑.ets文件资源路径和模块路径混在一起有些import引的是图片、json 或相对路径必须过滤掉相对路径与资源后缀才送进依赖比对。4. 适配后的验证冗余识别与冲突预警到底准不准4.1 用一个小型鸿蒙化 Flutter 工程做基准验证适配完成之后先用一个规模可控的小工程验证效果千万别一上来就拿大仓库试。我的基准工程结构harmony_demo/ ├── pubspec.yaml ├── pubspec.lock ├── oh-package.json5 ├── lib/ │ └── main.dart ├── entry/src/main/ets/ │ ├── entryability/ │ └── pages/ │ └── Index.ets造数据时我有意安排了几个典型情况一个声明但完全未引用的包http一个 Dart 引用但 oh-package 声明错误版本的包json_annotation一个只被 ETS 引用、Dart 层无感但在 oh-package 中声明的原生依赖ohos/sensor。最终报告应该准确地把http标记为冗余把json_annotation标记为版本约束警告并保证ohos/sensor不被误报为冗余。实测下来三条行为全部符合预期。这验证了双通道合并 双重引用扫描这条链路本身是可行的。4.2 版本冲突预警的一个真实抓取案例适配完成后不久这个工具就立了一功。某个鸿蒙化 Flutter 工程里shared_preferences在 pubspec 中被声明为^2.2.0但 oh-package.json5 中对应的一个原生存储桥接包被另一个业务线拉到了依赖约束之外的高版本。由于 Dart 层走的是 MethodChannel 调用编译期不会暴露问题运行时则可能出现偶发参数类型不匹配。运行适配版 dart_depcheck 后版本冲突预警直接把这两条记录并排推了出来左侧是 pub 侧锁定 2.2.2右侧是 oh 侧二进制构建时拉取到 2.3.1两条记录主版本一致但次版本不一致。这种横向对比原版工具根本做不出来。4.3 误报的三个来源与抑制策略真正的工程里没有完美报告误报无法杜绝能做到的是把误报控制在可接受范围。目前遇到的误报主要有三类。第一类是动态构建 import 路径比如import package:${prefix}/x.dart这类代码静态扫描不可能知道运行时拼出来的包名是什么。这类直接加入 ignore 列表并在注释里标明。第二类是仅用于代码生成或生命周期 hooks 的包像build_runner、flutter_launcher_icons这类只在开发态使用运行时源码里不会有引用。简单粗暴地不报冗余会让真实冗余逃掉更好的做法是对 dev_dependencies 单独折叠折叠规则为仅当开发依赖中连续两次完整构建都没有被使用才标记为冗余。第三类是桥接层依赖。鸿蒙侧很多能力是通过原生模块暴露的Dart 源码里没有任何直接引用但 oh-package.json5 里必须声明。这类依赖要靠双端扫描后的绑定关系表消除误报。这三类误报的来源和处理方式比较典型我把排查时的判断依据整理一下误报类型出现场景处理方式动态 import字符串拼接出的依赖路径ignore 列表 注释说明开发期工具依赖build_runner 等按 dev_dependencies 折叠规则处理桥接层原生依赖仅通过 MethodChannel 原生侧使用依赖绑定关系表消除5. 踩坑记录与维护建议适配完成后还要防住这些问题5.1 坑一解析顺序不同导致依赖图漏边适配层把 pubspec 和 oh-package 两路解析并行处理时如果代码里没有显式等待两路解析完成就进行 merge会出现部分依赖边丢失的偶发问题。表现就是同一份代码第一次跑报告正常第二次报告里莫名其妙少了一个包。解决方式是给整个 merge 操作加同步屏障并统一在内存里构建依赖图。我的做法是构造一个DependencyMergeSession只有等到pubParsed与ohParsed两个 future 都 complete才允许执行buildGraph()。这个小改动把偶发漏报直接清零了。5.2 坑二oh_modules 目录误扫导致性能和内存问题鸿蒙工程初始化依赖后oh_modules 目录下会有大量第三方包源码和构建中间产物。如果扫描范围没有正确排除整个检查时间会翻很多倍同时内存占用会高到本地开发机卡顿。适配时坚持两个原则只扫源码目录不扫产物目录只扫声明的 build 目标集不扫全仓库所有文件。实际配置里我固定排除了oh_modules/、.hvigor/、build/三个目录同时把扫描范围精确卡在lib/与entry/src/main/ets/两个区域。需要在文档中醒目提示的坑我会单独标出这是第一次跑全量扫描时几乎必踩的坑优先级很高。5.3 坑三别名映射表漂移长期维护是重点身份归一依赖的别名映射表是最费心维护的部分。鸿蒙生态的包更新节奏快一个原生模块可能改名、升级、拆分。如果映射表不及时更新会出现两种情况一是原本有绑定关系的两个包突然失联后续大量误报二是两个包被错误绑定冗余包被偷偷放过去。我的维护策略是每周定期跑一次全量扫描对比上一次报告差异逐条确认新出现或消失的绑定条目。版本冲突预警结果同步到依赖变更 review 的 MR 里让每次依赖变更都有迹可查。对于映射表提供机器可读的配置文件避免团队成员手改代码时产生冲突。5.4 什么时候不必自己适配务必承认不是每个项目都需要这个适配层。如果项目只是少量接入鸿蒙没有复杂的原生侧依赖直接在 pubspec 里管理好依赖不引入 oh-package 复杂依赖的话用原版 dart_depcheck 加上简单的剔除规则也够用。反过来如果项目已经进入 HarmonyOS NEXT 全量适配状态、依赖超过 30 个、同时存在大量import ohos/...引用那自定义解析器带来的收益会非常明显。判断标准就一条原版工具输出的误报率是否超过 50%超过就别硬用直接考虑适配层。6. 嵌进 CI 和日常流程让依赖健康检查真正发挥作用适配工具只完成了一半工作另一半是把结果送进真正能触达人的流程。我把这个适配版 dart_depcheck 的检查流程嵌到了本地预提交、MR Pipeline 两个节点。本地预提交阶段只跑增量扫描检测当前改动涉及的 Dart/ETS 文件是否引入了新的未声明依赖或删除了原有引用但未清理 pubspec 声明。核心思想是让问题在本地暴露成本最低。MR Pipeline 阶段跑全量健康检查全量状态下的依赖冗余清单、版本冲突预警、锁定漂移报告全部作为 MR 的检查项之一带有阈值过滤不因为一个次要警告阻塞整个合并流程。但 CI 嵌入了之后还有一个细节容易被忽略缓存。CI 里如果每次都全新拉取依赖再跑扫描时间开销很大。建议把 pub-cache 和 oh_modules 的解析结果做一层缓存只有 pubspec.lock 或 oh-package-lock 变化时才重新解析依赖图。我实际配置的 CI 阶段全量扫描时间稳定在 30 秒以内增量扫描 8 秒左右完全可接受。完整流程跑通后的另一个收获是依赖变更的 review 会轻松很多。每次 MR 里如果带着 pubspec 或 oh-package 的变更报告里就能直接看到它对整体依赖健康度的影响新增了几个包、移除几个包、有没有引入新的冲突。这种信息在代码 review 界面里直接呈现比单独打开终端跑一遍命令直观得多。7. 最后的经验与后续扩展这次鸿蒙化适配让我有了几个挺重要的体会。第一个是适配工具前一定要先弄清工具的依赖模型而不是急着改扫描逻辑。dart_depcheck 原本的模型是pubspec 声明 Dart 引用 lockfile 锁定三元组鸿蒙化之后变成了两套声明、两类源码、两个锁定文件的组合模型不统一任何扫描层的修补都是治标不治本。第二个是双端依赖元数据归并时身份归一永远是最优先要解决的问题。别名映射表虽然维护起来不轻松但它是整个准报率的基础。后续我还打算把这个适配层单独收一个小工具库把 ohos 的依赖绑定、路径扫描、缓存策略都做成配置驱动。这样可以减少团队成员手写解析逻辑的维护成本让依赖健康检查成为一个真正开箱即用的基础能力。如果你也在做同类迁移建议从最小闭环开始先跑通小工程的双通道解析再逐步放开扫描范围最后接入 CI。依赖健康这件事真是越早自动化后面越省心。