上个月在迁一套 Flutter 大型工程到鸿蒙环境时,我几乎被依赖关系整懵了:模块越拆越多,flutter analyze不报错,一跑构建就提示循环依赖,定位问题全靠肉眼扫import。后来翻了半天工具链,发现 layerlens 这个项目能直接分析 Dart 包的依赖关系、自动生成 Mermaid 图,甚至可以把循环依赖单独摘出来。但它默认只认纯 Flutter 工程结构,放到鸿蒙工程里根本跑不动。折腾了一周,我把这条鸿蒙化适配路线完整跑通了,这篇文章就把整个过程、脚本逻辑和踩过的坑一次性讲清楚。
先说结论:layerlens 本身不复杂,真正的难点在于“鸿蒙工程的 Flutter 模块往往被 DevEco Studio 包了一层,pubspec.yaml 位置、Dart 缓存路径、文件扫描范围全都变了”。只要把这几处适配掉,你就能在鸿蒙工程里持续得到可视化依赖图,大工程的循环依赖基本可以做到“从图上一眼揪出来”。
1. layerlens 的价值先搞清楚:它到底能解决鸿蒙工程里的什么问题
1.1 layerlens 是什么:把 Flutter 代码架构变成一张能自动生成的 Mermaid 依赖图
layerlens 是 Flutter 生态里的一个依赖分析工具,核心功能很纯粹:扫描工程里的pubspec.yaml和 Dart 源码的import关系,然后输出结构化的依赖数据。它能干三件实际的事:
- 生成 package 级别的依赖图,也就是“哪个 package 依赖了哪个 package”。
- 生成文件级别的依赖图,精确到
lib/目录下每一个 Dart 文件之间的引用关系。 - 专门标记出循环依赖的链路,并且用 Mermaid 语法输出可渲染的依赖图文本。
举一个最简单的例子:你的工程里有app、core、feature_login三个模块,core是整个底层基础库。layerlens 扫完之后,输出 Mermaid 格式的flowchart文本,放到支持 Mermaid 的编辑器或者 git 平台的 Markdown 预览里,立刻就能看到一张结构图。
为什么这对鸿蒙工程特别重要?因为鸿蒙侧的 Flutter 应用往往不是单个 package,而是由一个原生壳工程加一个 Flutter 业务模块构成的。业务模块内部如果再拆成多个本地 package,依赖关系就会迅速膨胀。人工维护架构图根本没有可行性,layerlens 这类工具的价值在于“让架构透明化”,而且是自动化、随时可再生的透明化。
1.2 鸿蒙大工程的循环依赖痛点:为什么必须做适配
做鸿蒙开发的人都知道,现阶段鸿蒙工程和传统 Flutter 工程在目录组织上有一些明显的差别。最常见的工程布局长这样:
HarmonyOSProject/ ├── AppScope/ ├── entry/ ├── hvigor/ ├── oh-package.json5 └── flutter_module/ ├── pubspec.yaml ├── lib/ └── ...Flutter 业务代码被放在一个独立的模块目录里,pubspec.yaml不在工程根目录,而在flutter_module子目录下。layerlens 默认从当前目录向上找pubspec.yaml,如果你在鸿蒙工程根目录直接执行flutter pub run layerlens,它要么找不到入口,要么扫出来的依赖范围完全不对。
更要命的是循环依赖。鸿蒙 Flutter 工程因为模块化起步较晚,很多团队习惯性地把公共组件、网络层、工具类塞进一个shared包,结果业务模块和shared互相引用,箭头绕了一圈又回到起点。这类循环依赖在日常开发里并不容易暴露,因为 Dart 语言允许你 import 循环引用的文件,只要不出现顶层初始化互斥,编译期不一定会报错。但一旦工程规模变大、并行编译开启,简单的循环依赖就可能导致构建失败,或者热重载时出现诡异状态。
layerlens 的强大之处在于它会把所有绕圈子的路径找出来。根据我的使用经验,它对“A 依赖 B、B 依赖 A”这种直接环,以及“A 依赖 B、B 依赖 C、C 依赖 A”这种间接环都识别得比较准。把这个能力搬到鸿蒙工程里,等于给架构治理装上了一台 X 光机。
2. 鸿蒙化适配前的准备:环境差异与适配思路
2.1 环境差异对照表:鸿蒙工程和原生 Flutter 工程的结构差异
在动手之前,我先把两种工程环境的差异做成了一张对照表,后面所有适配工作都是围绕这张表展开的:
| 维度 | 原生 Flutter 工程 | 鸿蒙工程(含 Flutter 模块) |
|---|---|---|
| pubspec.yaml 位置 | 工程根目录 | 通常在flutter_module或类似子目录 |
| 原生依赖管理 | pubspec.yaml 直接管理 | 同时存在 oh-package.json5,两者可互不影响 |
| Dart 缓存路径 | ~/.pub-cache或全局 PUB_CACHE | 与原生一致,但鸿蒙 IDE 可能自定义 SDK 路径 |
| 构建系统 | flutter build | hvigor 驱动,Flutter 模块仍由 flutter 工具链构建 |
| lib 目录 | 根目录 lib/ | 在 Flutter 模块内的 lib/ |
| 常用 IDE | Android Studio / VS Code | DevEco Studio |
这个对照表的意义在于:layerlens 需要读的两个核心输入,一个是pubspec.yaml所在的模块根目录,另一个就是要扫描的lib目录路径。只要把这两条路径指对,其他都是体力活。
另外一点容易被忽略的是PUB_CACHE环境变量。在鸿蒙开发机上,尤其是一些统一配置了 CI 环境的团队,PUB_CACHE 通常不是默认位置。layerlens 在解析依赖时需要定位真实缓存的包内容,如果缓存路径不对,分析结果可能缺包少包。适配脚本里必须显式处理。
2.2 适配方案选型:为什么我选择脚本包装而不是改源码
刚开始我考虑过两条路线:一是 fork layerlens 源码,在它的解析逻辑里硬编码鸿蒙路径;二是写一套包装脚本,在鸿蒙工程下临时构造一个“类 Flutter 工程视图”,让 layerlens 误以为自己在原生 Flutter 工程里运行。
最终我选了第二种方案。原因有三个:
- layerlens 本身更新频率不高,但如果 fork 源码,以后上游升级合并会很痛苦,维护成本全部转移到自己身上。
- 鸿蒙工程里 Flutter 模块的目录结构其实是固定的,包装脚本只需要做几件事:定位
pubspec.yaml、指定lib目录、设置正确的输出路径。 - 包装脚本是纯 Dart 或 Shell 实现的,不依赖鸿蒙 IDE 的内部 API,即使鸿蒙 IDE 升级,脚本也能继续用。
简单说,方案选型的原则是“能不碰源码就不碰源码”。工具是别人的,适配逻辑是自己的,分离得越干净,后续越省心。
3. 实操过程:把 layerlens 跑在鸿蒙 Flutter 模块上
3.1 第一步:确认 Flutter 模块位置与依赖注入方式
先看你的鸿蒙工程里的 Flutter 模块到底叫什么名字。有的工程叫flutter_module,有的叫flutter,还有的干脆叫app。不管叫什么,你只要找到那个同时包含pubspec.yaml和lib/的目录就是切入点。
我建议直接在模块目录下打开终端,先跑一次常规命令验证基本环境:
cd flutter_module flutter --version flutter pub getflutter pub get很重要,它会保证依赖解析结果写入pubspec.lock,layerlens 分析依赖时以实际解析结果为准。如果这一步就报错,先解决网络源或者镜像配置的问题,再继续。
接下来把 layerlens 加入dev_dependencies。注意它只需要在开发环境使用,不要加到dependencies里:
dev_dependencies: layerlens: ^0.2.1然后再次执行:
flutter pub get这一步会拉取 layerlens 及其依赖的下游包。如果公司内部有 pub 镜像,这个动作会在镜像库完成。
3.2 第二步:理解 layerlens 的三种输出模式
layerlens 常见的启动方式是flutter pub run layerlens,具体子命令可以在 package 文档里查。我实际用下来,最常用的是三个输出能力:
| 输出类型 | 命令 / 入口 | 实际用途 |
|---|---|---|
| package 依赖图 | layerlens graph | 显示各 package 之间的依赖关系 |
| 文件依赖图 | layerlens graph --lib | 显示 lib 目录内文件的 import 关系 |
| 循环依赖报告 | layerlens cycles | 单独列出构成环的节点和链路段 |
需要说明的是,我在实际操作中使用了dart run作为替代启动方式。在较新的 Flutter SDK 里,flutter pub run和dart run都可以,但是dart run对参数传递更友好。当然不同版本的 layerlens 子命令命名可能不同,能跑通为准。
真正要花心思的是参数传入。在鸿蒙工程里,因为当前目录下pubspec.yaml位于子模块,layerlens 的默认入口会找不到根。包装脚本的核心逻辑在这里统一处理。
3.3 第三步:写适配脚本,解决路径识别和缓存定位
我写了一个 Dart 入口脚本,放在鸿蒙工程根目录下的tool/文件夹里,不污染 Flutter 模块。它的核心工作分四个步骤。
第一步,定位模块目录:
import 'dart:io'; String findFlutterModule(String workingDir) { final candidates = ['flutter_module', 'flutter', 'app']; for (final name in candidates) { final abs = Directory('$workingDir/$name'); if (abs.existsSync() && File('$workingDir/$name/pubspec.yaml').existsSync()) { return abs.path; } } return workingDir; // 找不到就回退到当前目录 }第二步,设置PUB_CACHE。这一步是为 CI 环境准备的。鸿蒙工程的流水机上可能设置了独立的缓存目录,我们读出环境变量,找不到再回落默认值:
String resolvePubCache() { final envValue = Platform.environment['PUB_CACHE']; if (envValue != null && envValue.isNotEmpty) { return envValue; } if (Platform.isWindows) { return '${Platform.environment['LOCALAPPDATA']}\\Pub\\Cache'; } return '$home/.pub-cache'; }第三步,拼接 layerlens 命令行参数。
ProcessResult runLayerlens(String modulePath, List<String> args) { return Process.runSync( 'flutter', ['pub', 'run', 'layerlens', ...args], workingDirectory: modulePath, environment: { 'PUB_CACHE': resolvePubCache(), 'PUB_HOSTED_URL': Platform.environment['PUB_HOSTED_URL'] ?? '', }, ); }第四步,把输出重定向到鸿蒙工程根目录的docs/dependency-graph/下。这样生成物不会散落在 Flutter 模块里,也不会被 DevEco Studio 误当作工程资源。
void saveOutput(String content, String fileName) { final outDir = Directory('docs/dependency-graph'); if (!outDir.existsSync()) { outDir.createSync(recursive: true); } File('${outDir.path}/$fileName').writeAsStringSync(content); }这套脚本实际跑一次之后,核心价值就体现出来了:你不再需要记忆复杂的 layerlens 参数,只要在鸿蒙工程根目录执行一个命令,就能拿到最新的依赖图。
3.4 第四步:生成依赖图并确认关键链路
脚本写完,我在一个测试模块上跑了一次。生成出来的文件内容大致长这样:
flowchart TD app --> core app --> feature_login feature_login --> core feature_login --> shared shared --> core core --> shared }注意看core --> shared和shared --> core这条双向边,这就是典型的循环依赖。layerlens 的循环报告也会用文字形式列出来:
core -> shared -> core把这份文本放到任意支持 Mermaid 的编辑器里,渲染出来的图会把绕圈子的那两条线显示得很清楚。然后把这份文本提交到 git 仓库,每次代码变更后重新生成,团队成员在 Review MR 的时候就能直接看到依赖关系变化。这一步对鸿蒙大工程的架构收敛至关重要。
4. 循环依赖治理实战:从图到架构整改
4.1 从 Mermaid 依赖图定位循环依赖的三个步骤
图生成之后,如果只拿来“看看”就浪费了。我整理了一套实用的读图方法:
第一步,先看双向箭头。依赖图里如果有两个节点互相指向对方,这就是最低级的循环依赖,也是最应该最先消灭的。
第二步,沿着叶子节点反向推。从没有出边的包开始反向梳理依赖,观察哪些路径绕了一圈回到起点。这类间接环比直接环隐蔽得多。
第三步,对照 cycles 报告的文本链路段,逐段确认依赖方向。有时候一个环可能跨四五个模块,比如feature_a依赖feature_b,feature_b依赖shared,shared又依赖feature_a。这种链路在图上很清晰,但在代码里很难一眼发现。
来看一个我实际处理过的案例。原工程里存在这样一段环:
| 依赖路径 | 造成原因 |
|---|---|
| feature_a -> feature_b | feature_a 需要调用 feature_b 的某个列表页组件 |
| feature_b -> shared | feature_b 使用了 shared 里的统一网络库 |
| shared -> feature_a | shared 里的某个工具类引用了 feature_a 的类型定义 |
这种环的荒谬之处在于,shared本来应该是被所有业务模块依赖的底层包,结果反向依赖了业务模块。定位到具体代码后,发现只是shared里的一个常量文件引用了feature_a的枚举类型。解决办法是把那个枚举挪到shared,或者单独抽一个core_enum包。
4.2 治理策略:依赖倒置、Provider 解耦、抽公共模块
清理循环依赖不是单纯地把 import 删掉,而是要用正确的架构手段把环切断。我实际操作下来,最有效的三种策略分别是依赖倒置、状态提升和模块下沉。
依赖倒置的典型做法:如果shared包需要某种回调能力,但回调类型定义在业务包,那么不要让shared直接依赖业务包,而是在shared里定义一个抽象接口,业务包去实现它。放一段简单的示例说明:
// shared 包里的定义 abstract class UserInfoProvider { String get displayName; } // feature_a 包里的实现 class FeatureAUserInfoProvider implements UserInfoProvider { @override String get displayName => '来自 feature_a 的用户'; }业务包实现底层包的接口,依赖方向就变成了feature_a -> shared,环被直接切断。这种做法在鸿蒙的 Flutter 工程里尤其重要,因为鸿蒙侧经常通过 native 通道获取用户信息,这类回调用不好就会把底层包和服务层绑死。
第二种思路是状态提升。很多循环依赖其实是因为两个模块共享了某个运行时状态,比如登录状态、主题配置。与其让两个模块互相依赖,不如把状态放到更上层的模块,由更上层统一管理。这也正好契合 Flutter 社区常用的 Provider 模式:业务组件不需要互相 import,只需要从共同的 Provider 获取状态。
下面是一个用 Provider 切断循环依赖的例子。原本feature_a直接 import 了feature_b的某个状态类,导致两者绑定;改完之后,状态定义被提升到core包,feature_a和feature_b各自通过 Provider 读取,互不 import:
// core 包 class SessionState extends ChangeNotifier { bool isLoggedIn = false; } // feature_a 里不再 import feature_b 的任何文件 final session = context.watch<SessionState>();这种做法的收益在鸿蒙工程里会被放大,因为鸿蒙原生侧的状态回传往往是一个全局事件,业务模块本来就需要跨模块共享同一份状态,用 Provider 做解耦比互相引用的方式干净得多。
第三种策略是模块下沉。如果feature_a和feature_b都依赖一个公共类型,而这个类型目前恰好定义在其中一个模块里,那就把它下沉到shared或core。下沉之后,依赖关系从互相引用变成了单向依赖,环自然消失。
实际操作中,我建议每一次重构都重新跑一遍 layerlens,对比图里环的数量。如果环减少了,说明治理有效;如果新增了环,也能第一时间发现。把依赖图分析纳入日常开发节奏,比一个月后统一治理要轻松得多。
5. 常见问题与排查实录:跑不起来、图形错乱、漏检全在这
5.1 常见问题排查速查表
适配过程中我整理了下面这张速查表,按“现象 -> 原因 -> 处置”的思路直接照着查:
| 现象 | 常见原因 | 处置方式 |
|---|---|---|
| 在鸿蒙工程根目录运行 layerlens 提示找不到 pubspec.yaml | 模块目录没被识别 | 检查脚本里候选目录名,改成实际模块名 |
| 依赖图缺少部分本地包 | pubspec.yaml用了path依赖但路径解析失败 | 确认 path 路径是相对模块目录还是相对工程根目录 |
| 生成的文件在 Mermaid 渲染时报语法错误 | 节点名包含特殊字符或中文路径 | 在 Mermaid 节点文本外层加引号,或做转义 |
| cycles 报告为空但实际存在环 | 扫描范围没有包含lib/src等自定义目录 | 手动加参数指定额外扫描目录 |
| flutter pub run 找不到 layerlens 命令 | dev_dependencies 写错位置或没有 pub get | 检查 pubspec.yaml 缩进,重新 pub get |
| 在 DevEco Studio 里执行脚本,输出目录被 IDE 缓存 | 生成物写入了 Flutter 模块非标准目录 | 重定向到工程根目录 docs/dependency-graph |
| 分析结果和 CI 环境不一致 | PUB_CACHE 路径不一致 | 脚本里显式设置环境变量 |
最容易被忽视的就是 pubspec.yaml 里的path依赖。原生 Flutter 工程通常会写成../shared,而鸿蒙工程里因为 Flutter 模块本身已经处于子目录,这个相对路径很可能跑偏。layerlens 扫描的时候用的是模块根目录作为基准,路径不对就会漏包。
5.2 我踩过的三个坑:细说卡脖子细节
第一个坑是 DevEco Studio 的终端环境和系统终端的差异。DevEco Studio 内置终端默认会加载一些额外的环境变量,包括可能被设置过的PUB_CACHE。如果我用系统终端跑脚本能出图,换到 IDE 内置终端就跑不动,十有八九是环境变量被改了。解决方式很简单:在脚本里强制设置PUB_CACHE,不依赖外部传入。
第二个坑是 Mermaid 节点名里的点号。Dart 包名和路径里经常出现点号,比如shared.network.model,直接放进 Mermaid 文本会被当成类名的一部分,某些渲染器会报错。处理方式是给节点名加方括号,或者统一做一层名称映射。layerlens 本身的设计是做了处理的,但适配脚本如果二次加工了文本,就必须再处理一次。
第三个坑是鸿蒙工程里同时存在多个 Flutter 模块。有些大型工程会把业务拆成多个 Flutter 模块嵌在同一个鸿蒙壳里,每个模块都有自己的pubspec.yaml。layerlens 一次只能分析一个模块,如果只跑一次就以为看到了全局,那是误判。我的做法是写一个循环,把每个 Flutter 模块都跑一遍,把多个 Mermaid 文件合并进同一份报告。合并的时候注意节点重名问题,建议给每个模块的节点加前缀。
5.3 独门技巧:把 layerlens 接进 CI,让架构图自动留在主仓库
适配脚本跑通之后,我用它做了最后一件事:接入 CI。鸿蒙工程的 CI 里可以挂一个阶段,在每次 MR 合入主干前自动执行依赖图生成脚本,并把产物提交到docs/dependency-graph目录。如果检测到新增循环依赖,脚本直接以非零状态退出,阻断合入。
大致的伪代码流程是这样的:
flutter pub get dart run tool/gen_dependency_graph.dart dart run tool/check_cycles.dartcheck_cycles.dart里的核心逻辑很简单:解析 layerlens 的 cycles 输出,统计环的数量,超过阈值就抛异常。我见过不少团队用 eslint 靠规则约束代码风格,却没有人约束模块之间的依赖方向。接上这个检查之后,循环依赖基本失去了进入主仓库的机会。
一点务实建议
如果你要在一个规模较大的鸿蒙 Flutter 工程里推这个方案,我建议别一上来就做全员工具培训,先自己在本地跑通脚本,生成当前工程的依赖图,把可观的循环依赖列表整理出来后,再带着数据去和架构组对齐。有了图和报告,讨论“怎么拆”就变得非常具体,而不是靠感觉。layerlens 的鸿蒙化适配并不复杂,但它的价值不在于“能跑”,而在于让团队重新看见依赖关系。架构治理的第一步永远是看清楚现状,这个工具能把这一步的成本降到最低。