接手一套存量不小的 Flutter 工程,最怕的不是功能写不出来,而是代码"哪儿都能长"。业务层偷偷 import 了底层通道,UI 文件里塞满了状态流转,新人的 PR 里一个功能横跨六个模块——这些问题是常规 Lint 规则查不出来的,因为不是格式问题,是结构问题。团队今年把整套 App 往鸿蒙方向做适配,这个问题被彻底放大了。鸿蒙侧的 Flutter 生态还没那么厚,封装边界一旦失守,后面改起来真要命。
这篇想聊的,是我们内部沉淀的一套 Lint 规则集 rexios_lints 在鸿蒙适配中的完整落地过程。它不是什么新框架,而是把"架构规范"变成"编译期强制性检查"的一组工具链组合:一半是声明式规则,另一半是自定义插件规则。它解决的问题很具体:在 Dart 编译阶段直接拦截跨层依赖、非法 API 调用、边界封装绕过等架构违规,让不合规的代码根本走不到合入那一步。如果你也在做 Flutter 鸿蒙适配,或者单纯想给团队代码库上一道"工艺纪律"的防线,这篇的配置方法和踩坑记录应该都能直接用上。
1. 为什么需要一套"会咬人"的 Lint 规则集
1.1 存量代码失控的五个典型信号
先说我们当时看到的现状。团队规模不大,但仓库不小,几个业务模块加起来有几十万行。做过多年 Flutter 的同学对这类问题应该不陌生。
第一个信号是分层边界形同虚设。按规范,UI 层只允许依赖服务层,服务层才能访问基础设施。但在存量代码里,随意跨层导入的情况到处都是,某个页面文件直接去调底层数据库封装,中间跳过了整个业务服务层。这会让改动一个底层接口时牵扯出几十个调用点。
第二个信号是"啥都能干"的全局工具类。项目里有个 common 目录,里面什么都有:工具函数、网络实例、埋点封装、路由跳转,甚至还有几个常量定义。新同学进来第一周,大概率会把新代码塞进这个"万能筐"里。它就像一个城市的中央广场,所有道路都往这儿汇,一旦要改它的签名,全城堵车。
第三个信号是渠道能力绕过统一封装。当时我们接入鸿蒙推送、支付等能力,社区方案不统一,不同厂商的原生调用方式各有差异。如果没有约束,业务代码里直接铺开一堆 platform channel 调用,后期升级 SDK 或切换厂商就是一次灾难。
第四个信号是异常的静默吞掉。在 UI 层到处能看到catch (e) {}这种空捕获,或者只打印一行 log 就假装处理完了。这类问题在 CR 里很难一条条盯住,因为每次看都不一样。
第五个信号是命名与文件组织的随意性。同一类能力,有人放 utils,有人放 helper,有人放 service,再配合拼音缩写文件名,索引一个模块的代码往往要翻半天。
这些信号单拎出来都不致命,但它们累积在一起,代码库就像一个没有交通规则的城市,所有人都能走,所有人都走得慢。以前靠 Code Review 来拦截这些,但 CR 本来就有幸存者偏差——盯得紧的模块相对干净,盯不紧的地方就会失控。尤其鸿蒙适配这种需要全局铺开的工作,光靠人盯绝对来不及。
1.2 Lint 从"风格约束"到"架构防线"的三层递进
很多人对 Lint 的理解停留在"字符串必须单引号""缩进必须两个空格"这类风格约束上。实际上,静态分析工具的能力远不止于此,它完全可以承担架构治理的工作。我习惯把 Lint 的用途分成三层。
第一层是风格统一层。这一层解决的是"看起来一致":命名风格、导入顺序、空行规则、格式化。规则属于低级劳动,交给机器去跑就行,人的精力不应该花在这上面。
第二层是正确性兜底层。这一层解决的是"是不是写错了":例如使用已废弃的 API、空安全违规、无效的BuildContext跨异步使用、错误参数类型等。官方提供的flutter_lints大部分规则落在这一层。
第三层是架构治理层。这一层约束的是"什么地方能碰什么地方",也就是跨层依赖、模块边界、API 使用范围。这才是 Lint 规则集真正的价值所在。举个例子,"禁止 UI 页面直接导入平台通道封装"这条规则,靠 CR 盯一百个 PR 就会有漏网的,但靠静态分析工具,每次flutter analyze都会准确地指出是哪一行出了问题。
rexios_lints 的设计思路,就是把这套三层体系做成一套可复用的规则集。它不是简单地include一份配置就完事,而是把架构层的约束编写成自定义插件规则,在编译阶段强制执行。这样规则就成了"会咬人"的防线——你不碰它,它不咬你;你一违规,它当场就不放行。
1.3 鸿蒙适配的特殊性:千疮百孔的边界
有人可能会问,鸿蒙适配和 Lint 规则有什么关系?不就是在 CI 上多跑一个 target 嘛。实际做下来完全不是这回事。
鸿蒙侧的 Flutter 工程通常是基于社区维护的分支 SDK 来构建的,这意味着你用的 analyzer 版本、SDK 路径、可用的平台通道封装,都和官方 Flutter 有差异。最常见的情况是:官方 Flutter 的某个 API 被标记为废弃,但鸿蒙分支上还没有对应的替代品;反过来,鸿蒙分支引入了一些自有扩展 API,而官方规则并不知道这些 API 的存在,不仅不会拦,甚至可能给出错误提示。
更麻烦的是边界问题。鸿蒙适配阶段,团队通常会做一个"能力适配层"或者叫"平台桥接层",把原生能力统一封装成 Dart 接口。这个封装层本身是合理的,但它的存在也带来了新的架构诱惑:业务代码为了图省事,完全绕过适配层,直接去调用底层的 platform view 或 method channel。这种绕过在官方 Flutter 时代也存在,但在鸿蒙适配期会更严重,因为很多能力只有原生侧才有,开发者一着急就会直接往底层钻。
如果不靠 Lint 规则把"必须走封装层"这条边界卡死,等适配结束后你会发现,所谓的适配层只是个摆设,真正调用原生能力的代码散落在各个业务模块里,后续升级 SDK 时每一个调用点都可能出错。这就是我们决定把 rexios_lints 完整引入鸿蒙工程的核心原因。
2. rexios_lints 规则体系:从宣言到代码
2.1 规则集的四象限分类
rexios_lints 在规则设计上没有做"大而全",而是针对代码库的痛点做了一套四象限分类,对应四类不同的治理目标。这样分类的好处是:规则之间不重叠,出现问题时可以快速定位到规则源头。
第一类是命名与组织规范。这一类基本继承了flutter_lints和dart_lint的合理部分,再补充团队自身的命名偏好。比如禁止拼音命名、统一服务类后缀、强制 Dart 私有成员带下划线等。它们在规则集中属于基础盘。
第二类是生命周期与异常处理规范。包括禁止空 catch、禁止在build方法里做耗时操作、禁止在State.dispose之后访问状态、禁止在异步回调中无保护地使用mounted等。这类规则是崩溃率的重要守门员,尤其是鸿蒙适配期,不少稳定性问题都出在异步生命周期错配上。
第三类是跨层依赖约束。这是规则集的核心,也是自定义插件规则发挥主要作用的地方。我们会给工程目录定义层级角色:ui、application/service、infrastructure、platform_bridge。规则会检查文件实际所在目录与它导入的模块层级是否匹配,一旦发现 UI 层直接导入了 infrastructure 层的类,直接报 Error。
第四类是鸿蒙能力使用约束。这类规则是适配期新加的,专门约束平台通道相关代码的写法和调用路径。例如禁止业务代码直接使用MethodChannel、强制使用统一的适配层入口、限制原生插件注册的位置等。
把规则分成四类之后,工程里每个文件的"责任边界"就被描述清楚了。审查代码的时候,不需要再靠人肉判断"这段代码该不该出现在这里",工具会替你把边界擦亮。
2.2 从 AST 层面拦截违规导入:一段可运行的规则示例
自定义 Lint 规则的原理并不复杂:用 Dart 的 analyzer 库解析源码,生成 AST,然后遍历 AST 里的节点,按规则条件去匹配并报告问题。我写一个我们在 rexios_lints 里用到的规则精简版,用来禁止 UI 层直接导入基础设施层。
import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart'; class AvoidInfrastructureImportInUiRule extends DartLintRule { AvoidInfrastructureImportInUiRule() : super(code: _code); static const _code = LintCode( name: 'avoid_infrastructure_import_in_ui', problemMessage: 'UI 层禁止直接导入基础设施层,请通过服务层访问。', errorSeverity: ErrorSeverity.ERROR, ); @override void run( CustomLintResolver resolver, ErrorReporter reporter, CustomLintContext context, ) { context.registry.addCompilationUnit((unit) { final path = unit.declaredElement?.source.uri.path ?? ''; final isUiFile = path.contains('/ui/') || path.contains('/pages/'); if (!isUiFile) return; for (final directive in unit.directives) { if (directive is! ImportDirective) continue; final uri = directive.uri.stringValue ?? ''; if (uri.contains('/infrastructure/') || uri.startsWith('package:xxx/infrastructure/')) { reporter.reportErrorForNode(_code, directive); } } }); } }这段代码做的事情很简单:找到所有被分析的 Dart 文件,判断它是不是 UI 层的文件;如果是,就再看它导入了哪些模块;一旦发现infrastructure路径,就报告一个 Error 级别的诊断。
在custom_lint里注册规则也很直接,入口文件里把规则实例加进去就行:
List<DartLintRule> getLintRules(DartLintRuleConfig config) => [ AvoidInfrastructureImportInUiRule(), // 其他规则... ];当然,真实工程里的判断逻辑会比这个复杂得多。比如infrastructure在某些业务模块里路径会带参数,或者一些公共类型定义放在共享目录里,需要加白名单。规则如果写得太粗暴,会带来大量误报,反而降低团队对规则集的信任。这一点后面单独展开说。
2.3 规则等级设计:Warning 与 Error 的取舍
自定义规则发布时,每条规则要明确自己的定位:是 Warning 还是 Error。这个选择直接影响团队的使用体验。
我们的原则是:可以立刻改、影响面小的问题设为 Warning,架构边界类、蛰伏期长、后期修复成本高的问题直接设 Error。举个例子,"命名不规范""日志必须带 tag"这类规则设 Warning,因为即便暂时不改,也不会造成系统性问题;而"UI 层直接导基础设施""绕过平台桥接层调用原生能力"这类规则必须设 Error,因为它们一旦上生产,后期迁移改造的成本是整个模块级别。
在 rexios_lints 里,我们同时使用了两套机制来处理等级。声明式规则(基于linter的规则)通过analysis_options.yaml里的errors字段来控制告警等级;自定义插件规则则在LintCode里直接定义errorSeverity。两条路都通向同一个结果:flutter analyze的退出码会带上这些告警的严重程度,CI 就可以据此决定是否拦截。
等级设计里还有一个细节值得提一下:存量代码和新代码要区别对待。架构类规则如果一开始就全面设 Error,存量代码的报错可能上千条,团队会直接被淹没在错误里。我们的做法是分两阶段推进,存量先按 Warning 观察、按模块逐步清理,新代码从一开始就走 Error 流程。过渡期大概持续了两周,等存量收口到可控范围内,再统一提高到 Error。这个渐进式收口策略,比一次性宣布"所有不规范都不许合入"要顺利得多。
3. 鸿蒙工程接入全流程
3.1 在 Flutter 鸿蒙分支工程中引入 rexios_lints
鸿蒙 Flutter 工程本质上还是一个 Flutter 工程,目录结构、依赖管理方式与官方版本基本一致,所以引入 Lint 规则集的流程也通用。只是有一点要注意:鸿蒙分支的 SDK 版本可能落后于官方,analyzer API 也可能有差异,引入前最好先确认规则包中使用的 analyzer 版本与工程能够兼容。
第一步是在工程的pubspec.yaml里添加依赖:
dev_dependencies: rexios_lints: ^1.0.0 custom_lint: ^0.6.0第二步是在工程根目录的analysis_options.yaml里引入规则集并启用插件:
include: package:rexios_lints/analysis_options.yaml analyzer: plugins: - custom_lint这里的include会把规则包内预置的声明式规则全部带进来,而plugins则负责加载自定义插件规则。两者缺一不可。
配置完成后,执行:
flutter pub get flutter analyze如果一切正常,你会看到flutter analyze的日志里多出rexios_lints相关的诊断信息。没有看到的话,可以执行dart run custom_lint来单独验证自定义插件是否被正确加载。这一步是最容易出问题的位置,下文排查部分会细讲。
3.2 把架构规则升级为编译期 Error
默认情况下,include进来的规则可能有部分只是 Warning,甚至 Info 级别。要让架构规则真正变成"防线",需要在analysis_options.yaml里把关键规则的等级调高。
以我们工程为例子,下面这段配置把几个核心规则全部提升为 ERROR:
analyzer: errors: avoid_infrastructure_import_in_ui: error avoid_platform_channel_in_business: error avoid_empty_catch_block: error prefer_service_layer_access: error配置完之后,任何一条规则被触发,flutter analyze的退出码都会是非零,后续 CI 就可以据此直接阻断合入。
这里有个操作细节要提醒一下:flutter analyze默认情况下某些 WARNING 不会导致非零退出码,但一旦你把它提升为error,它就会进入致命错误列表。所以"升级 Error"这个动作一定要配合 CI 的--fatal-infos --fatal-warnings参数使用,才能确保退出码严格反映诊断结果。
还有一个细节:规则等级修改后,本地 IDE 的提示也会同步更新。在 VSCode 或者 Android Studio 里打开文件,你会发现违规行直接标红,而不是原来的黄色下划线。这样开发者在写代码的当下就能看到问题,不必等到 CI 跑完才返工。
3.3 鸿蒙 SDK 与多目录工程的细节配置
鸿蒙 Flutter 工程的目录结构和官方工程有一个明显差异:除了lib/之外,还多了一层鸿蒙原生工程的目录,通常叫harmony或entry。analyzer 默认只会扫描lib/目录下由pubspec.yaml指定的部分,但你大概率会有一些脚本、工具类、或者测试目录需要纳入检查。
我们工程的做法是,在analysis_options.yaml中显式声明分析范围:
analyzer: exclude: - harmony/** - build/** errors: # ... 规则等级配置harmony/**是鸿蒙原生侧代码,由 ArkTS 编译器负责检查,不该让 Dart analyzer 去碰。如果不排除,analyzer 会因为无法解析.ets文件而产生大量虚假诊断,CI 上会跑出莫名其妙的红点。
还有一类路径需要注意:部分鸿蒙适配工程会把平台桥接代码放到了lib/platform/ohos/这类目录下,而通用业务代码仍然在lib/features/。这种多目录结构下,自定义规则里对路径的判断要能涵盖所有场景,不能只写死一个pages目录。我们的做法是,把目录角色定义抽成了一份公共配置文件,规则实现里统一读取,方便后续新增业务模块时只需要改配置,不需要改规则代码。
鸿蒙 SDK 相关的环境变量也要在 CI 机器上提前配好。常见的是DEVECO_SDK_HOME指向鸿蒙 SDK 的安装目录,以及 Flutter 鸿蒙分支 SDK 的路径配置。analyzer 虽然不直接依赖这些变量,但flutter pub get在解析某些鸿蒙特有依赖时会读取它们。这一块我踩过几次坑,稍后一起写在排查清单里。
4. 在 CI 流水线中构建合规防线
4.1 本地门禁:提交前跑一遍
架构防线不应该只在 CI 上存在,本地开发阶段就要先拦一道。我们团队的统一流程是:提交代码前跑一次flutter analyze,把输出贴在 commit message 里。这条规矩光靠自觉不现实,所以我们在 Git 的 pre-commit 钩子上做了自动化。
钩子脚本的逻辑很简单:
#!/bin/sh echo "Running rexios_lints..." flutter analyze --fatal-infos --fatal-warnings if [ $? -ne 0 ]; then echo "❌ 存在代码规范/架构违规,请先修复再提交。" exit 1 fi这个钩子一开始用的是dart run custom_lint,但实测下来在大型工程里耗时偏长,后来改成了flutter analyze。因为flutter analyze本身会加载analysis_options.yaml中启用的所有检查,包括自定义插件,退出码也会如实反映 Error 级别的问题。钩子只要判断退出码非零就拒绝提交,简单粗暴,但有效。
唯一的问题是,在存量代码还没清理完的那些天,这个钩子会让不少同事抓狂。我们的处理办法是,在钩子脚本里加了一个--baseline参数支持,允许存量代码的已知违规记录在文件中豁免,新出现的违规不会豁免。等存量收口完成,再移除豁免文件。这个方案相对平滑,团队接受度也高。
4.2 服务端门禁:让不合规代码无法合并
本地钩子防君子不防小人,真正的防线必须设在服务端。我们的合并请求流水线里加了一个静态分析阶段,核心命令就三条:
fvm flutter pub get fvm flutter analyze --fatal-infos --fatal-warnings dart run custom_lint三条命令的执行顺序有讲究。pub get用来解析依赖并锁定版本;flutter analyze同时覆盖声明式规则和自定义插件规则;custom_lint单独跑一遍主要是为了拿到更详细的规则报告输出,方便在流水线页面上展示问题列表。如果flutter analyze失败,流水线直接中断,这条合并请求就没法继续往下走了。
对于流水线产物,我们还会顺手生成一份 JSON 格式的分析结果,作为合并请求的附件。这样评审人不用在日志里翻,直接看规则报告就能判断遗留问题是什么。数据也能汇总起来,按周统计各个模块的违规数量变化趋势,用来向团队展示治理进展。这个统计本身也能反哺规则设计——哪类问题每周都在涨,说明现行规则的约束力度还不够。
4.3 存量代码的渐进式治理
存量代码的治理可能是整个项目里最需要耐心的一环。我们的策略分三步走。
第一步,摸底。规则集刚接入时,先把所有问题跑出来,按模块分类统计。比如某个订单模块有 200 条违规,其中 120 条是跨层依赖,60 条是异常处理不规范。把数字摆在桌面上,团队才知道从哪里下手。
第二步,定优先级。架构类规则优先收口,因为这一类影响最大、越晚改越难改;命名、注释、格式化这类问题可以往后放,甚至留给代码格式化工具去处理。每个模块认领自己的存量问题,估算工作量,按迭代分批推进。
第三步,冻结增量。从规则集生效那天起,新增代码必须零违规。存量可以慢慢还,但新增代码不允许再产生新的架构债。这一步一旦被团队认可,存量会在两三轮迭代内快速下降,因为重构过程中顺手就把旧问题也修掉了。
渐进式治理最怕的是"急着清零"。如果强行要求团队在同一周内把上千条违规全部清完,那几乎注定会导致规则被绕过或废弃。慢一点,稳一点,每个迭代清一批,两个月后回头看,效果会非常明显。
5. 落地一个月:实测效果与排查实录
5.1 常见问题速查表
把规则集接入鸿蒙工程后,团队陆续遇到了一些问题。这些问题很多都是有一定共性的,我整理成一张速查表,方便大家直接对照排查。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
添加自定义规则后flutter analyze无任何反应 | pubspec.yaml中未声明custom_lint插件,或analysis_options.yaml里没加plugins | 确认dev_dependencies里有custom_lint,并检查analyzer.plugins配置 |
| 规则包升级后报 analyzer API 不兼容 | 鸿蒙分支 SDK 的 analyzer 版本与规则包存在的版本冲突 | 对齐 SDK 版本,或使用dependency_overrides临时固定 analyzer 版本 |
| 自定义规则在 IDE 有提示但 CI 不报错 | CI 上用的 Flutter SDK 与本地不一致 | CI 统一使用 fvm 锁定 Flutter 鸿蒙分支版本 |
| 规则误报,路径判断覆盖不全 | 自定义规则内的路径匹配写死了某个目录名 | 将目录角色配置抽成公共文件,规则统一读取 |
harmony/**目录产生大量虚假诊断 | analyzer 尝试解析 ArkTS 文件 | 在exclude中排除鸿蒙原生目录 |
| 规则太严导致本地 hook 频繁拦截 | 存量大、等级设置过激进 | 使用 baseline 机制豁免存量,增量从严 |
每一条问题都不是玄学,基本都能在配置层面找到原因。排查时有一个通用的调试姿势:先跑dart run custom_lint,看插件是否加载成功;再跑flutter analyze -v,看分析器实际读了哪些配置文件和目录。这两步能过滤掉八成的问题。
5.2 调试自定义 Lint 规则的两个实用技巧
自定义 Lint 规则是 Dart 代码,调试起来和调普通 Dart 代码完全一样,可以用 IDE 的断点调试。但要真正快速调好规则,有两个技巧值得分享。
第一个技巧:善用最小的复现工程。不要在大仓库里反复跑全量分析来验证规则,那样又慢又难定位。单独建一个只有几十行代码的小工程,专门用来触发目标规则。在这个小工程里改规则代码,跑dart run custom_lint,看到效果后再把规则搬回正式工程。这样一次验证的耗时从分钟级降到秒级,调试体验完全不同。
第二个技巧:把规则里的判断逻辑全部抽成纯函数。比如"判断文件是否属于 UI 层""判断导入路径是否命中基础设施目录",这些逻辑抽成独立的函数后,可以直接写单元测试。规则在正式环境出错时,也能只跑对应的单测来定位是匹配逻辑的问题,还是 AST 节点拿法的问题。我们的规则包里有不少测试用例就是这个思路,实测下来对回归保护非常有效。
5.3 实际收益与体验
规则集上线运行一个多月后,最直观的变化是:合并请求里的架构争议少了。以前评审时,一半的精力都花在争论"这个依赖该不该放这儿"这种边界问题上;现在有规则兜底,评审人只需要关注实现逻辑本身,边界问题交给工具去判定。团队的新人也能通过规则报告快速了解项目的架构约定——与其背诵架构文档,不如让工具在写代码时直接告诉你哪里越界了。
鸿蒙适配期间,平台通道的调用路径是我们盯得最紧的。应用规则之后,业务代码直接MethodChannel的场景基本绝迹,所有原生能力调用都收敛到了适配层。这个收敛效果不是靠几条规章制度实现的,而是靠每次本地分析、每次 CI 检查在编译期就把问题拦在了外面。如果说把代码写规范是"工艺",那这套规则集就是一套"工艺纪律",让团队在不自觉间保持一致的架构口吻。
回头再看这个过程,我最大的体会是:架构规范只有进入工具链,才真正具备了约束力。文档里的架构约定写得再漂亮,也不如一次编译报错来得有效。鸿蒙适配不是一个另起炉灶的项目,恰恰是这类工程最需要"编译期防线"的时候——因为边界最容易被突破,也最容易在事后付出代价。