
做 Flutter 工程质量治理这些年我越来越确信一件事覆盖率报告里最坑人的不是数字低而是数字“看起来还行”。辛辛苦苦把flutter test --coverage的输出整理成报表结果一打开coverage/lcov.info满屏都是*.g.dart、*.freezed.dart、自动生成的路由表甚至还有平台构建产物。这些文件不是人写的也不会有人去评审但它们实打实躺在分母里把 80% 多的真实业务覆盖率拖到 70% 出头。remove_from_coverage这个 Dart 三方库就是专门处理这个问题的按 glob 规则从 lcov.info 里剥离指定文件的记录段只留下真正需要审计的业务代码覆盖数据。最近我们在把一套 Flutter 基础设施往鸿蒙侧迁移顺手就把remove_from_coverage做了一次鸿蒙化适配。原版工具在标准 Flutter 工程里很好使但放到鸿蒙工程里路劲格式、自动生成目录、工具链版本全变了。适配过程中踩了不少坑也把覆盖率工具链从“单数字展示”升级成了质量审计治理的一环。这篇记录就是这次适配的完整过程包括原理拆解、补丁思路、CI 门禁集成和排查实录适合正在做鸿蒙 Flutter 工程收尾、打算给流水线加覆盖率门槛的团队参考。1. 项目背景覆盖率报告里的“数字泡沫”到底怎么来的1.1 一张 lcov 报告两套数字先还原一下最典型的场景。Flutter 工程里执行flutter test --coverage会在coverage/下生成lcov.info这是标准的 lcov 格式一行一个记录段描述每个源文件被覆盖到的行数。本地开发时 IDE 插件直接解析它CI 里则把它喂给静态分析平台或者自研的报表服务。问题在于lcov.info 里不只有lib/下的业务代码。只要项目引入了 json_serializable、freezed、build_runner 这类代码生成链路lib/**/*.g.dart和lib/**/*.freezed.dart就会大量出现。这些代码在编译期生成逻辑重复、特征明显测试用例不会刻意去测它们但它们的 DA 记录实实在在地进入覆盖率统计。举一个我处理过的真实口径。假设工程总可执行行数是 20000 行其中业务手写代码 16000 行自动生成和依赖注入代码 4000 行。某次测试跑完总共覆盖了 14000 行但这 14000 行里包含了 1000 行生成代码的覆盖记录。统计口径分母覆盖行数覆盖率原始总覆盖率200001400070%排除生成代码后的业务覆盖率160001300081.25%同一批测试两个数字差了 11.25 个百分点。哪个更能代表工程质量显然是后者。前者就是“数字泡沫”它让你以为业务代码测试得不够实际上可能是分母被无关文件撑大了。1.2 为什么审计团队需要一个“干净”的覆盖率如果覆盖率只有一个总数字质量审计就会很尴尬。管理层看的是趋势团队看的是门槛但如果 CI 里只展示 70% 这个总覆盖率团队的注意力就会从“业务逻辑有没有测”转移到“怎么把分母缩小”。有人会去给自动生成代码写冗余测试有人会把排除规则写得过于宽松甚至有人直接把不达标的分支摘出统计范围。这些动作的本质都是在修饰数字不是提升质量。审计需要的是“可解释的覆盖率”。所谓可解释是指任何人拿到这份报告都能说清楚覆盖率的分母由哪些文件构成、为什么这些文件被排除、排除后还剩多少核心业务代码。总覆盖率适合做宏观趋势观察核心业务覆盖率才适合做发布门禁和 MR 评审依据。我们做鸿蒙化适配本质上就是在把remove_from_coverage从“一个过滤工具”变成“一套可配置、可追溯、可纳入治理流程的质量底座”。2. remove_from_coverage 原理拆解它不是正则脚本是 lcov 过滤引擎2.1 核心行为按 SF 段裁剪 lcov 记录要适配一个工具先得吃透它怎么工作。remove_from_coverage做的事情并不复杂它逐段读取 lcov.info每一段以SF:开头标记一个源文件路径后面跟着DA:行覆盖数据、LF:可执行行总数、LH:覆盖行总数最后以end_of_record结束。工具拿到用户提供的 glob 规则后对每个文件段的SF:路径做匹配。如果匹配上整段直接丢弃不写入输出文件没匹配上则原样保留。处理完之后输出一份新的 lcov 文件。这里有个容易被忽略的关键点lcov 的不同文件段之间是相互独立的。删除一个文件段不会影响其他段里的DA:记录也不需要重排行号因为每个DA:记录都自带行号。所以remove_from_coverage不需要像源码级覆盖率工具那样维护行号映射它只要做好“段级别过滤”就够了。理解这一点后面调试误删问题时能少走很多弯路。2.2 glob 匹配规则怎么用才不踩边界工具的核心参数是-r可以传多次。常见用法是dart run remove_from_coverage \ -f coverage/lcov.info \ -r lib/**.g.dart \ -r lib/**.freezed.dart \ -o coverage/lcov.clean.infolib/**.g.dart和lib/**/*.g.dart在大多数 glob 实现里语义相近都表示lib/下任意层级递归匹配。但规则写起来有几个容易踩的边界第一路径分隔符。lcov 里的SF:路径通常用正斜杠/但 Windows 本地开发时某些工具链会输出反斜杠\。glob 匹配默认按/处理一旦混入反斜杠规则就失效。第二相对路径前缀。有的工程SF:是lib/foo.dart有的却是./lib/foo.dart。多一个./lib/**就匹配不上。适配时最好统一做归一化而不是靠规则去猜。第三规则范围。**/*.g.dart会把test/下 mock 产生的 g.dart 也过滤掉如果这些 mock 文件本身是你想统计的对象就会误伤。建议规则尽量指向明确的顶层目录比如lib/**.g.dart而不是到处匹配。2.3 自己写脚本过滤与直接 fork 的三点权衡有人会问lcov 过滤这么简单为什么不自己写个 Python 脚本我一开始也是这么想的但评估下来有三点权衡第一lcov 格式的边界情况比想象中多。文件路径里可能出现空格、括号DA:行可能有逗号带执行次数记录段之间偶尔会有空行脚本解析时这些都要处理干净否则线上报告会莫名少数据。第二glob 规则看起来简单真要做到跨平台、递归匹配、排除和保留语义清晰直接用成熟库比自己实现可靠得多。remove_from_coverage已经把这些逻辑封装好了拿过来改比从零写省力。第三后续维护问题。覆盖率工具不是写完就完团队会不断往排除规则里加目录CI 里还要支持配置化。用开源工具做底座fork 一份维护比每个人手里一份随手脚本更可控。所以在“自己写 10 行脚本”和“直接 fork 适配”之间我选了后者。3. 鸿蒙化适配要跨过的三个坑路径、产物目录和工具链3.1 路径风格与相对根目录先观察 SF 记录长什么样鸿蒙 Flutter 工程的目录结构和标准 Flutter 工程有一个明显差异工程根目录下除了lib/、test/还有entry/、oh_modules/、.hvigor/、build/这些鸿蒙侧目录。flutter test --coverage生成的 lcov.info 中SF:路径仍然以工程根为相对根但文件来源会混入不同模块。适配第一步不是写代码而是先拿到一份鸿蒙工程的 lcov.info把前几十条SF:记录打出来看grep ^SF: coverage/lcov.info | head -50我拿到手的结果大概是这个画风SF:lib/core/network/http_client.dart SF:lib/generated/route/route_table.g.dart SF:oh_modules/.tpc_pkg/foo/lib/src/foo_api.dart SF:.hvigor/cache/generated_dart/entry/bridge_generated.dart SF:entry/src/main/ets/model/xx.ets # 理论上不应出现在 dart 覆盖率里看到oh_modules和.hvigor的时候基本就能确定直接用原版remove_from_coverage是搞不定的。它没有内置这些鸿蒙路径的识别能力而且如果SF:里混入了绝对路径或者./前缀规则匹配也会静默失败。3.2 自动产物目录识别不是只有 .g.dart 需要过滤标准 Flutter 项目的脏数据主要是*.g.dart、*.freezed.dart、.dart_tool下的工具生成代码。鸿蒙工程多出来的几类“新面孔”更阴间一类是oh_modules/。鸿蒙侧的三方依赖通常会解压到工程根的oh_modules/目录个别的 Dart 源码也会被拷贝到这里。它们是依赖包不是业务代码测试用例跑到的覆盖记录计入分母会严重拉低真实覆盖率。另一类是.hvigor/。鸿蒙构建系统的缓存和临时文件目录里偶尔会出现脚手架自动生成 Dart bridge 文件。这些文件每次构建都可能变化如果把它们计入覆盖率报告的可复现性就很差。还有build/、entry/build/这类产物目录。理论上 lcov 不应该收录它们但在多模块工程里偶尔会有漏网之鱼。我建议默认内置一组鸿蒙排除目录这些规则应该经过团队评审而不是一个人说了算。oh_modules/** **/oh_modules/** .hvigor/** **/.hvigor/** **/build/** **/.dart_tool/**3.3 工具链版本在鸿蒙 Flutter SDK 下跑 dart 入口remove_from_coverage是 Dart package运行方式是dart run remove_from_coverage。鸿蒙 Flutter 工程用的往往是带鸿蒙适配分支的 Flutter SDKDart 版本可能和标准 Flutter SDK 不完全一致。如果 fork 下来直接pub get可能遇到 SDK 约束冲突。解决方式有两种。第一种是改 fork 版本里pubspec.yaml的environment.sdk约束放宽到当前 SDK 能接受的范围。第二种是把执行入口切到鸿蒙 SDK 自带的 dart 可执行文件例如$FLUTTER_HOME/bin/dart run ...确保运行环境和构建环境一致。这里有个小建议适配版不要改原版的命令行参数结构否则 CI 脚本迁移成本会变大。兼容原参数再增加新参数是最稳妥的做法。4. 适配实操从 fork 到 CI 门禁一次讲透4.1 源码获取与本地环境准备适配的第一步是把源码拉到本地。我们在代码托管平台上 fork 了一份remove_from_coverageclone 到工作目录然后切到一个独立的适配分支。环境准备方面需要三样东西鸿蒙 Flutter SDK、对应的 Dart SDK、一份能跑通flutter test --coverage的模拟鸿蒙工程。没有真实工程也没关系可以先造一个最小样例但一定要包含oh_modules、.hvigor这类目录名否则自测覆盖不到关键适配点。mkdir remove_from_coverage_harmony cd remove_from_coverage_harmony git clone 本地 fork 地址 cd remove_from_coverage flutter pub getpub get成功不代表运行没问题一定要在鸿蒙工程的根目录下用真实的 lcov.info 执行一次原版工具确认原版在哪个环节失效。4.2 路径规范化补丁把所有路径统一成正斜杠 去掉 ./ 前缀原版工具里SF:路径进了匹配逻辑后是直接和 glob 规则比对的。鸿蒙工程里最容易翻车的就是路径风格。我加了一个归一化函数在读取每一行的路径后立刻处理String normalizeLcovPath(String rawPath) { var path rawPath.replaceAll(\\, /); while (path.startsWith(./)) { path path.substring(2); } return path; }这段逻辑不复杂但解决了两类问题。第一Windows 本地产生的lib\core\http.dart会被转成lib/core/http.dart规则统一用正斜杠书写。第二./lib/foo.dart变成lib/foo.dart不需要用户为前缀差异写两套规则。此外如果SF:出现了绝对路径我会判断它是否以当前工程根开头如果是就裁剪成相对路径如果不是则原样保留。这一步能避免用户规则里写绝对路径前缀的尴尬。4.3 内置鸿蒙默认排除规则新增 --harmony 开关鸿蒙化适配不能只靠用户每次手动写一堆oh_modules/**容易漏。我加了一个--harmony开关开启后自动追加默认排除规则const harmonyDefaultGlobs [ oh_modules/**, **/oh_modules/**, .hvigor/**, **/.hvigor/**, **/build/**, **/.dart_tool/**, ];这些规则和用户的-r规则是并集关系不是互斥关系。也就是说只要任一规则命中该文件段就会被移除。命令行使用方式变成dart run bin/remove_from_coverage.dart \ -f coverage/lcov.info \ --harmony \ -r lib/**.g.dart \ -r lib/**.freezed.dart \ -o coverage/lcov.clean.info注意原版参数一个都没动--harmony只是追加能力。这样做的好处是团队切换到鸿蒙环境时CI 脚本只需要增加一个参数不需要重写规则集。4.4 单元自测与真实工程验证适配代码改完不能直接上 CI得先自测。我写了两类验证第一类是用临时构造的 lcov 样本跑单元测试。样本里故意放这些文件段SF:lib/core/util.dart应该保留SF:lib/generated/api.g.dart应该移除SF:oh_modules/foo/lib/foo.dart开启--harmony后应该移除SF:.hvigor/cache/generated_dart/a.dart应该移除SF:entry/src/main/ets/demo.ets因为不是 Dart 文件理论上不会出现但我也验证了规则不会误伤第二类是在模拟鸿蒙工程里跑真实流程。先执行flutter test --coverage拿到原始coverage/lcov.info。然后跑适配版对比清理前后的记录段数量和数据。我们当时的结果是原始 lcov.info 有 132 条SF:记录适配版清理后剩 86 条剥离了 46 个生成代码或依赖文件段。覆盖率表现从 70% 提升到 81.25%。这个提升幅度是合理的因为这些被剥离的文件段本身贡献了覆盖行但它们不应该被算进业务质量指标。4.5 集成 CI两个覆盖率指标加一道质量门禁本地验证通过之后我把适配版接到了 CI 流水线。整个流程分四步flutter test --coverage dart run remove_from_coverage \ -f coverage/lcov.info \ --harmony \ -r lib/**.g.dart \ -r lib/**.freezed.dart \ -o coverage/lcov.clean.info # 分别解析 lcov.info 和 lcov.clean.info得到两个覆盖率指标 # total_coverage原始总覆盖率用于宏观趋势 # core_coverage清理后的业务覆盖率用于发布门禁门禁规则我建议这样设core_coverage低于阈值时构建失败total_coverage不设硬门槛但出现单次下降超过 2 个百分点时自动给团队发告警。阈值不能拍脑袋定先跑两周看团队历史的 70 分位和 90 分位再确定一个“跳一跳够得着”的值。lcov.clean.info 还要继续往上报。静态分析平台直接解析它展示的就是团队真正关心的业务覆盖率。原始 lcov.info 保留在构建产物里审计时如果需要排查“为什么数字变了”还能回看原始数据。5. 常见问题与排查技巧实录5.1 排除后覆盖率纹丝不动八成是路径没对上适配版刚上线时有同事反馈“我加了oh_modules/**怎么覆盖率一点没变”我让他先把输出文件打开看一眼发现SF:条数跟输入完全一样说明规则一条都没命中。排查方法很简单先确认 lcov.info 里的实际路径格式grep ^SF: coverage/lcov.info | head -20常见的坑有三个。第一路径带了./前缀规则却写的是oh_modules/**第二Windows 下路径是反斜杠规则里还是正斜杠第三路径是绝对路径规则写的是相对路径。我的经验是不要凭记忆写规则一定先看真实路径。提示所有规则修改完先用--output生成一份临时文件对比 SF 条数变化确认命中了再跑全量。5.2 误伤业务代码规则太宽差点删掉核心模块有一次我把**/generated/**加进排除规则结果覆盖率不升反降。查了半天发现项目里有个lib/repository/generated_api/目录里面不是自动生成文件而是开发手写的 API 客户端代码。这条宽规则把这些真实业务代码段全滤掉了分母一下子少了覆盖行也少了最终业务覆盖率反而崩了。这件事给我的教训是排除规则必须有强特征不要只凭目录名猜测。.g.dart、.freezed.dart这种后缀特征就很好因为它们只可能来自代码生成器。目录名排除尽量限定在oh_modules/、.hvigor/、.dart_tool/这类根级目录不要用**/generated/**这种打中一切的名字。如果工具支持打印匹配文件清单建议先跑一遍确认dart run remove_from_coverage --list-files -f coverage/lcov.info --harmony把命中的文件列表拉出来人工扫一眼再正式接入门禁。5.3 Windows 开发机与 Linux CI 的路径分隔符差异适配完第一版后我在本地 Windows 上跑得好好的推到 CI 后 Linux 节点上规则全部失效。查了很久才发现部分依赖工具在 Windows 环境下会把路径写成反斜杠而我的归一化逻辑只处理了读取SF:时的路径没有处理规则本身。后来我统一了三处策略读取路径后转正斜杠、规则书写要求一律正斜杠、对比时大小写不敏感但保留原始路径输出。如果团队里有 macOS 或 Windows 混合开发环境这三个统一能省掉大量莫名其妙的“本地好了 CI 挂了”问题。5.4 大仓库性能问题glob 展开和规则顺序适配版上线后跑了两个星期发现处理时间偶尔会从几百毫秒涨到几秒。查了一下是某次评审时往规则里加了一条特别宽的**/bridge/**这个规则会递归展开大量子目录匹配开销明显增加。lcov 记录段少则几千多则几万。每条规则都做一次递归 glob 展开性能自然往下掉。我建议规则里能用lib/**.g.dart就用这种带顶层目录限制的写法少用裸**开头的规则同时把精确规则放在前面宽泛规则放在后面。如果实现一次命中即跳过精确规则先命中就能减少后续匹配次数。实测下来几千条记录、十条以内的规则处理时间基本可以保持在 1 秒以内感知不到性能问题。6. 从“报告精简”到“质量审计治理”的延伸实践6.1 双层指标模型总覆盖率作为参考业务覆盖率作为门禁适配完成之后我们重新设计了覆盖率指标体系。原来的指标只有一个“所有文件覆盖率”看起来简单实则管理成本很高。现在拆成两层指标数据来源用途建议阈值原始总覆盖率lcov.info宏观趋势、环境变化告警70% 以上核心业务覆盖率lcov.clean.info发布门禁、MR 评审80% 以上总覆盖率是用来“看趋势”的它如果突然大幅下降说明有新的大块代码未覆盖值得关注。核心业务覆盖率是用来“卡发布”的它直接反映手写业务代码的测试充分度。两个指标同时看才不会顾此失彼。6.2 增量覆盖率怎么和 remove_from_coverage 配合整体覆盖率有一个滞后问题这次改动了 200 行新代码但分母是 16000 行的业务代码指标几乎无感知。发布门禁只卡整体覆盖率新代码仍然可能裸奔。我建议在 CI 里增加增量覆盖率统计。思路是先用git diff --name-only HEAD~1提取本次变更文件再从lcov.clean.info中过滤出这些文件的覆盖记录单独计算增量覆盖率。这个过程本质上还是“按文件段过滤 lcov”和remove_from_coverage的底层逻辑一致完全可以复用同一套代码。增量覆盖率可以作为 MR 机器人的评论内容每次提交自动发一条“本次新增代码覆盖率 xx%低于阈值不可合并”。团队对增量的感知会比整体指标敏锐得多。6.3 把排除规则当成资产而不是一次性参数覆盖率治理最怕的是规则靠口口相传。今天 A 同学在 CI 里加一条规则明天 B 同学因为同样的问题又加一条最后排除规则变成了一锅粥谁都不知道哪些文件被拿掉了。我把规则从命令行参数里挪了出来维护成工程根目录下的coverage_exclude.yamlharmony: enabled: true default_excludes: true excludes: - lib/**.g.dart - lib/**.freezed.dart - **/.dart_tool/** - oh_modules/** - .hvigor/**适配版增加了一个--config参数CI 脚本只需要传配置文件路径不再需要一长串的-r。规则变更走代码评审历史可追溯新同事也容易理解“分母是什么、为什么排除这些文件”。我个人的体会是覆盖率治理做到这个程度才算真正从“报告精简”走到了“质量审计治理”。remove_from_coverage的鸿蒙化适配只是个起点真正有价值的是背后那套可解释、可配置、可持续改进的质量闭环。后面如果再遇到新平台适配这套指标模型和配置管理方式可以直接复用不需要再从头趟一遍。