上个月我梳理一个 OpenHarmony 平板上的 Flutter 工程时,被dart analyze的报错清单吓了一跳:presentation 层的页面直接 import 了 data 层的 Repository 实现类,domain 层的接口和 data 层的 DTO 互相引用,core 层里不知道什么时候混进了一个业务模块的入口。说白了,Dart 这门语言根本不关心你的import语句写得合不合理,模块边界拆得再漂亮,只要有人在代码里写了一条跨层导入,架构红线就形同虚设。
这次要写的这套import_rules鸿蒙适配指南,解决的正是这个问题。它本质上是挂在 Dart analyzer 插件机制下的规则包,能在flutter analyze阶段对每个 Dart 文件做包级导入依赖控制,让每一层只能 import 允许联通的那几个包,违规直接报错。文中涉及的配置、命令、踩坑记录,全来自我最近给公司平板项目从零落地这套规则的真实过程,适合正在做 Flutter 模块化改造、且打算把工程迁移到 OpenHarmony 的团队参考。
1. 包级依赖失控的两种典型现场:为什么必须上硬规则
1.1 现场一:剪不断理还乱的循环依赖
先说一个我见过无数次的循环依赖场景。假设你有chat和user两个 feature 包,chat里的MessageRepository需要调用user模块的UserApi拿当前用户信息;而user模块的UserProfileWidget又想展示消息未读数,于是调了chat的MessageUnreadProvider。第一次这么写的时候,两个包都能编译通过,flutter analyze也不会说半个不字。但两个月后你就知道疼了:改user的接口,chat要跟着动;改chat的模型,user又要发布新版本。
在普通 App 工程里,这种循环依赖还能靠"大家都小心点"维持。到了 OpenHarmony 这种要面对多种异构设备、需要按模组裁剪包体的场景,循环依赖就是灾难。鸿蒙侧的har包有严格的模块依赖声明,两个 har 互相依赖时构建系统会直接报错或者无限递归。你不可能靠开发自觉去约束这种问题,只能让静态分析在代码提交前拦截。
1.2 现场二:跨层导入,抽象接口形同虚设
比循环依赖更隐蔽的是跨层导入。分层架构里我们通常约定:presentation 只依赖 domain,domain 不依赖任何具体实现,data 依赖 domain 和 core。但实际代码里,你随手一搜就能看到这样的写法:
// 在 presentation/xxx_page.dart 里 import 'package:myapp/data/repository/user_repository_impl.dart'; class UserPage extends StatelessWidget { // 直接 new 了一个 data 层的实现类 final _repository = UserRepositoryImpl(); }这条代码在 IDE 里不会飘红,编译能过,单测能跑。但它把 domain 层定义的抽象接口完全架空了:以后你要换数据源实现,就得跑到 UI 层去改代码。你可能会说"代码评审的时候注意一下不就行了"。说实话,在团队超过五个人、迭代速度上来之后,人肉 review 这种跨层导入根本看不住,reviewer 不可能记住每个文件的归属层。这就是为什么必须让机器去盯。
1.3 import_rules 的能力边界:它管什么,不管什么
先给 import_rules 划个边界,免得你期待过高。
它管的是静态import语句:谁导入了谁、导入的是 barrel 出口还是 src 内部文件、导入是否跨了被禁止的包边界。它是基于 analyzer 的 AST 分析,你写下的每一条import 'package:xxx/yyy.dart'都会变成 AST 里的 ImportDirective 节点,规则引擎拿到这个节点里的 URI,再结合当前文件所在包,匹配预先定义的依赖矩阵。
它管不了的是运行时间接依赖。比如你用get_it这类 ServiceLocator,在 composition root 里注册了一堆实现类,Service 内部通过getIt.get<UserRepository>()拿对象——只要 register 的代码没有直接 import 类型(比如用了Type注册),静态分析就查不出来。这不算缺陷,反而是在倒逼你把"谁依赖谁"收敛到容器的注册表里,让依赖关系显式化。
只要理解了这条边界,后面配置规则的时候就不会产生"为什么我已经禁止了 A 包导入 B 包,代码还是跑通了"这种误解。
2. import_rules 的规则引擎拆解:从 analysis_options.yaml 到依赖矩阵
2.1 它是怎么在 flutter analyze 里“插一脚”的
import_rules 不是 Dart SDK 自带的 rule,它走的是 analyzer 插件机制。安装之后,你在analysis_options.yaml里通过analyzer: plugins:把它挂载上去,然后flutter analyze执行时,插件就会收到 analyzer 回调,逐个文件检查。
这里有个常见误区:很多人以为在pubspec.yaml里把包加进dev_dependencies就生效了。不是的,这只是装了依赖,真正激活它必须同步修改analysis_options.yaml。漏掉后半步的话,你会看到依赖装好了,但任何规则都不生效,也不报错,非常迷惑。
插件的加载还受 Dart SDK 版本约束。analyzer 插件的 API 在 5.x、6.x、7.x 之间是有差异的,import_rules 发布时通常声明了自己的 sdk 约束区间。你在鸿蒙的 flutter 分支上跑,Dart 版本往往滞后于官方主线,装完插件后发现flutter analyze直接抛"The import_rules plugin is not compatible"这类错误,多半就是版本没对齐。
2.2 两条核心规则:banned_imports 和 direct_barrel_only
import_rules 我最常用的两套规则是黑白名单和 barrel 直达控制。
banned_imports(黑名单)用来禁止特定路径的导入,配置格式类似于:
import_rules: banned_imports: - from: "package:myapp/domain/**" to: "package:myapp/data/**" - from: "package:myapp/data/**" to: "package:myapp/presentation/**"from是当前文件的路径模式,to是被导入文件路径的模式,**表示任意层级。配置左右两边都用package:前缀的 URI,而不是相对路径,是为了避免不同开发机上的绝对路径漂移。上面这个配置的意思很清楚:domain 层文件不得导入 data 层,data 层文件不得导入 presentation 层。
direct_barrel_only(直达控制)则更进一步,它强制"跨包导入必须走 barrel 文件",不允许直接 import 到某个包内部的src/目录。例如:
import_rules: direct_barrel_only: - package: "package:myapp/domain/**" allow_from: - "package:myapp/**" export_roots: - "package:myapp/domain/domain.dart"意思是所有想 import domain 内部文件的代码,只能通过domain.dart这个总出口。如果团队有人写import 'package:myapp/domain/src/entity/user.dart',即使这条导入不在黑名单里,也会触发直达控制报错。这招对保护包封装边界非常有效,但实施成本也高——你必须维护好每个包的 barrel 文件,否则等于逼着所有人从没定义的出口导入。
2.3 依赖矩阵:三层架构的典型配置模板
拿我们项目来举例,目录长这样:
lib/ core/ # 基础能力:网络、日志、工具 domain/ # 领域层:实体、接口抽象、用例 data/ # 数据层:仓储实现、DTO presentation/ # UI 层:页面、组件、状态依赖约定如下:
| 当前包 | 允许导入 | 禁止导入 |
|---|---|---|
| core | 仅 Dart/Flutter SDK 及已声明的三方库 | 业务模块全部禁止 |
| domain | core | data、presentation |
| data | domain、core | presentation |
| presentation | domain、core | data(若要数据,走 domain 接口) |
落到 import_rules 配置上,就是上面黑白名单模板的组合。这套矩阵几乎覆盖了团队里 99% 的违规导入场景。剩下 1% 是 test 目录和 generated 文件,我后面专门讲。
3. OpenHarmony 工程适配里最容易被忽略的四个差异点
3.1 从 pub.dev 到镜像源:package_config 路径变化对规则的影响
把 Flutter 工程迁移到 OpenHarmony 端,第一件事通常是换依赖镜像源。因为鸿蒙开发环境的网络策略和 pub.dev 直连不一定顺畅,很多团队会配置华为云镜像或者其他内部制品库。这本身没什么问题,但 import_rules 在解析规则时要读取.dart_tool/package_config.json这个文件,里面描述的是"包名 -> 实际路径"的映射。
诡异的地方在于:不同镜像源、不同操作系统上,这个映射里的rootUri前缀不一样。Windows 开发机上是file:///C:/Users/xxx/AppData/Local/Pub/Cache/hosted/pub.flutter-io.cn/...,Linux CI 构建机上可能是file:///home/runner/.pub-cache/...。如果你的规则里用了基于绝对路径的 exclude 或 include 模式,很容易出现"我本地 build 没问题,CI 上死活跑不通"的灵异事件。
我的建议是:所有路径模式一律用package:前缀的 URI,不要用file://。import_rules 的路径匹配是基于 package_config 解析后的 URI 做的,只要两边都用 package 形式,镜像源差异就不会影响规则判定。
3.2 Dart SDK 版本是硬约束:鸿蒙分支的 analyzer 兼容性
这是我在鸿蒙适配时踩得最重的一个坑。OpenHarmony 的 Flutter 分支通常不是官方 release,而是由 OpenHarmony SIG 维护的 fork,Dart SDK 版本会比官方主线滞后不少。
换句话说,官方 Flutter 已经到 3.24 甚至更高时,鸿蒙分支可能还停留在 3.7 左右的 Dart 版本。import_rules 作为第三方插件,它对 analyzer API 的版本要求比较敏感。版本不匹配时,flutter analyze启动后不会加载插件,但也不会有刺眼的报错,唯一的症状是:你故意写一条违规导入,它居然不报。
解决办法是在pubspec.yaml里锁版本:
dev_dependencies: import_rules: 1.2.x # 用你本机验证过的 minor 版本 analyzer: 6.5.0 # 与 import_rules 声明的依赖范围对齐必要时用dependency_overrides强制对齐 analyzer 版本。别小看这条,我见过不只一个团队,插件装了半天不生效,最后查了半天发现就是 analyzer 版本撞了。
3.3 ohos 目录与 dart 目录混编:规则只管 Dart 这一侧
OpenHarmony 的 Flutter 工程在结构上和 Android 类似,会有一个ohos/平台目录,里面是 ArkTS 代码、hvigor构建脚本、module.json5等。这意味着一个工程里同时存在 Dart 和 ArkTS 两种源码。
import_rules 本质上只分析 Dart 文件,它对ohos/目录下的.ets文件完全无感。万一有人在 ArkTS 侧做的依赖是反模式的,比如某个 Page 直接 import 了一个业务 SDK 的内部类,import_rules 不会帮你拦。
这不是规则缺陷,而是分工问题。OpenHarmony 平台侧的依赖控制应该交给鸿蒙自己的 lint 工具(ohos-lint)和hvigor的模块依赖声明去管。你在 CI 上应该两条检查并行:一条跑flutter analyze盯 Dart 侧,一条跑 ohos-lint 盯 ArkTS 侧。我在项目里就是这么配的,两条都过才能继续构建。
3.4 别让 lint 堵住构建:与 hvigor 构建流程的时序配合
提一个容易忽略的配合细节。OpenHarmony 的构建链路是hvigor主导的,它负责把 ArkTS 编译、资源打包、har 依赖解析最终生成 hap。Flutter 侧的构建实际是作为 hvigor 的一个 task 被调起来的。
如果你把flutter analyze(带 import_rules)挂在flutter build hap之前执行,那你一定要想清楚一个事:analyze 报错时会终止构建,但它的运行环境是 Flutter SDK 自己的 Dart VM,不是 hvigor 的环境。这意味着 CI 上要先 ensure Flutter SDK 已被正确配置(flutter 命令可用、pub get 已跑),再执行 analyze,最后才轮到 hvigor。
正确顺序是:
flutter pub get flutter analyze --no-pub hvigorw assembleHap --mode module -p product=default先分析后构建。这样 import 违规会在构建之前被拦下,而不是等 hvigor 跑了一半才报一个莫名其妙的依赖错误。
4. 从零到一落地:一套可在 CI 上运行的完整配置
4.1 环境准备与版本锁定
我落地这套规则时用的环境大致是这样(具体版本以你本机flutter doctor为准):
- OpenHarmony 的 flutter fork,
flutter --version输出里 Dart 版本 3.x - import_rules 锁在 1.2.x
- 工程使用单仓多包结构,包名是
myapp
准备阶段不要省事。先跑一次flutter pub get,然后盯一眼.dart_tool/package_config.json,确认 import_rules 确实在这个文件里被解析出来了。如果 package_config 里没有这个包,后面一切配置都是白搭。
4.2 分析配置文件的完整写法
下面这份配置是我在项目里实际用过的简化版,可以直接抄。放在工程根目录的analysis_options.yaml里:
analyzer: plugins: - import_rules language: strict-casts: true import_rules: prefer_package_imports: true banned_imports: - from: "package:myapp/core/**" to: "package:myapp/presentation/**" - from: "package:myapp/core/**" to: "package:myapp/data/**" - from: "package:myapp/core/**" to: "package:myapp/domain/**" - from: "package:myapp/domain/**" to: "package:myapp/data/**" - from: "package:myapp/domain/**" to: "package:myapp/presentation/**" - from: "package:myapp/data/**" to: "package:myapp/presentation/**" direct_barrel_only: - package: "package:myapp/domain/**" allow_from: ["package:myapp/lib/**"] export_roots: ["package:myapp/domain/domain.dart"] - package: "package:myapp/core/**" allow_from: ["package:myapp/lib/**"] export_roots: ["package:myapp/core/core.dart"] excluded_paths: - "**/*.g.dart" - "test/**" - "integration_test/**"几个值得展开说的地方:
prefer_package_imports是强制用package:导入,禁止相对路径导入(import '../data/xxx.dart')。相对路径在重构时最容易漏改,特别是在多个 feature 包之间移动文件时,IDE 会因为相对路径变化而飘红,但你根本不知道是该改路径还是该改 import。用 package 导入后,文件移动基本不影响 import。
excluded_paths是规避误报的关键。*.g.dart是 json_serializable、freezed 等生成的文件,它们内部自动生成的 import 不该受业务规则约束。test/和integration_test/放开,是为了让集成测试能随意 import 各层来做端到端验证,这个后面细说。
4.3 故意写一条违规代码验证规则生效
配置写完之后,最重要的动作是验证它真的生效。我在初次接入时总会专门做一次负向测试——故意在 presentation 层写一条 import data 层实现类的代码:
// lib/presentation/pages/login_page.dart import 'package:myapp/data/repository/user_repository_impl.dart'; class LoginPage extends StatelessWidget { // 这里故意违规,用来验证规则 }然后执行:
flutter analyze --no-pub预期输出应该类似:
info • lib/presentation/pages/login_page.dart:1:1 • The package 'myapp/data' is not allowed to be imported from this file. banned_imports • import_rules如果你的 import_rules 配置了error级别,这条会直接以 error 形式中断 analyze,退出码非 0。看到报错后,把刚才的违规代码删掉,再跑一次确认干净。这个正向+反向的验证动作不要省略,它能帮你确定规则是在工作的,而不是"假装在工作"。
4.4 把检查接进 GitLab CI / 本地脚本
CI 脚本我用的比较简单,核心就三步。在.gitlab-ci.yml里:
flutter_analyze: stage: test script: - flutter pub get - flutter analyze --no-pub建议再叠加一个本地检查脚本tools/check_imports.sh,方便开发者在 push 之前自查:
#!/bin/bash set -e cd "$(dirname "$0")/.." flutter pub get flutter analyze --no-pub git diff --exit-code -- '*.dart' ':!**/*.g.dart'第二行git diff --exit-code是为了顺带检查格式化,防止有人提交了没有跑过dart format的代码。这个习惯在多人协作里特别管用,能把"我本地能跑"和"CI 能跑"之间的偏差提前暴露。
鸿蒙工程和普通 Flutter 工程在 CI 上的最大区别是构建时长。hap 的打包比 apk 慢不少,analyze 放在测试阶段跑,能在构建之前快速失败,省下的不只是一次构建时间,还有排查"到底是谁改坏了模块依赖"的沟通成本。
5. 排查 lint 报错的三板斧:误报、漏报与规则误伤
5.1 误报:generated 文件怎么豁免
json_serializable 生成的文件自动 import 了package:json_annotation/json_annotation.dart。如果这个包在你的 banned 名单里(比如你禁止 core 层之外的包导入某个内部库),.g.dart文件就会被误伤。
我在 4.2 节用了excluded_paths来豁免全部*.g.dart,这是最省力的方式。但有个细节要留意:excluded_paths的匹配是按文件名 glob 做的,如果某个团队的代码生成目标路径不统一(比如有人把生成文件输出到.dart_tool/build/),这两类路径都要写进排除列表。
另一种场景是你不想整体豁免某一个生成文件,只想豁免某条规则。import_rules 通常支持行内 ignore 注释:
// ignore: banned_imports import 'package:some_pkg/src/internal.dart';我个人非常不建议滥用 ignore 注释。它会让规则出现漏洞,而且 review 时看不到上下文,无法判断这个豁免是否合理。能用路径统一豁免的,就不要再给开发者留手动 ignore 的口子。
5.2 漏报:动态条件导入很容易看漏
Dart 支持条件导入:
import 'adapter_stub.dart' if (dart.library.io) 'adapter_io.dart' if (dart.library.js) 'adapter_web.dart';import_rules 在分析这类语句时,会把每个分支的 URI 都当作一个独立的 import 来匹配规则。容易漏的是你把adapter_stub.dart放进了白名单,却忘了adapter_io.dart和adapter_web.dart也在导入名单里。
鸿蒙端做平台适配时这个场景特别常见。很多插件为了兼容 Android/iOS/Web,会写条件导入;迁移到 OpenHarmony 时,如果有人新增了一个adapter_ohos.dart分支,而 import_rules 配置里的白名单没有同步更新,那这条新增分支就会变成漏网之鱼。
排查技巧:在 CI 上跑一次dart analyze后,把输出里的 warning 列表人工过一遍,重点看有没有和条件导入相关的提示。养成习惯后,配白名单时你自然会想到"这个包在条件导入里出现过没有"。
5.3 规则误伤:测试目录要不要放开
我见过不少团队把 test 目录也纳入严格规则,结果就是单测代码里到处都是 ignore 注释。测试代码的价值恰恰在于它可以访问各层内部来验证行为,你把它和业务代码用同一套黑名单约束,其实是给自己找麻烦。
我的策略是测试目录整体豁免规则,但在单独的analysis_options.yaml里保留 Dart 自带的核心 linter,这样测试代码的规范性靠通用 linter 兜底,包级依赖的严格约束只针对lib/下的业务代码。
在工程里可以用子配置实现:
# test/analysis_options.yaml include: ../analysis_options.yaml import_rules: enabled: false注意子配置的 include 机制在不同 analyzer 版本下的行为略有差异,如果遇到"include 了之后仍报 banned_imports"的情况,检查一下 import_rules 是否支持在子配置里整体 disabled;不支持的话,就继续用excluded_paths的方式整体跳过test/**。
5.4 我踩过的一个坑:Windows 大小写不一致导致本地与 CI 结果不一致
这个坑非常隐蔽,值得单独写出来。Windows 文件系统默认大小写不敏感,你在 Windows 上写import 'package:MyApp/domain/domain.dart',Dart 分析器能找到文件,一切正常。但 Linux CI 上文件系统大小写敏感,MyApp和myapp是两个不同的包路径,analyze 直接报Target of URI doesn't exist。
import_rules 的匹配基于包名,包名来自 pubspec 里的name字段,大小写不对时它可能根本没匹配到任何规则,规则等于被架空。这个问题在日常开发里很难察觉,因为本地总是绿的。
我的习惯是:约定所有 import 一律小写包名,并在 CI 上跑dart format --set-exit-if-changed .。格式化工具会强制统一 import 排序和包名形式,把大小写问题直接消灭在格式检查阶段,不会拖到 analyze 才暴露。
把规则写得严一点,不如分阶段落地
最后说说我在实际接入这套规则后的体会。import_rules 这类包级依赖控制工具,最大的价值不是"把违规全杀光",而是把架构约束从口头约定变成可自动执行的检查项。鸿蒙端的工程结构天然比 Android 更强调模块边界,har 的依赖声明、XTS 认证对包体积和权限的最小化要求,都逼着你在 Flutter 侧也必须把分层做干净。
如果你准备在团队里推这套规则,我建议分三步:第一周先把规则设成 warning 级别,让所有人看到违规提示但不阻塞构建;同时导出一次全量违规清单,把存量问题逐条评审,能修的修,暂时不能修的用excluded_paths或路径豁免收拢;第二周再把flutter analyze挂进 CI,警告数量降不下来就 continue-on-failure;第三周改成 error 级别,正式把红线焊死。我试过一上来就 error 级别,结果团队怨声载道,每天光处理历史遗留违规就花掉大量时间,反而推进不下去。
一个小技巧是:在 pubspec 里把import_rules锁到 minor 版本,不要用 caret 范围放开到下一个大版本,因为 analyzer 插件 API 一旦更新,规则行为可能会有细微变化。等鸿蒙的 flutter fork 升级 Dart SDK 后,再手动评估新版本兼容性,逐版升级。这套工具配合好之后,每次flutter analyze跑完,看着满屏的绿色,那种"依赖再也不会乱掉"的确定性,是真的让人睡得踏实。