)
做 Flutter 工程質(zhì)量治理這些年我越來越確信一件事覆蓋率報告里最坑人的不是數(shù)字低而是數(shù)字“看起來還行”。辛辛苦苦把flutter test --coverage的輸出整理成報表結(jié)果一打開coverage/lcov.info滿屏都是*.g.dart、*.freezed.dart、自動生成的路由表甚至還有平臺構(gòu)建產(chǎn)物。這些文件不是人寫的也不會有人去評審但它們實打?qū)嵦稍诜帜咐锇?80% 多的真實業(yè)務(wù)覆蓋率拖到 70% 出頭。remove_from_coverage這個 Dart 三方庫就是專門處理這個問題的按 glob 規(guī)則從 lcov.info 里剝離指定文件的記錄段只留下真正需要審計的業(yè)務(wù)代碼覆蓋數(shù)據(jù)。最近我們在把一套 Flutter 基礎(chǔ)設(shè)施往鴻蒙側(cè)遷移順手就把remove_from_coverage做了一次鴻蒙化適配。原版工具在標(biāo)準(zhǔn) Flutter 工程里很好使但放到鴻蒙工程里路勁格式、自動生成目錄、工具鏈版本全變了。適配過程中踩了不少坑也把覆蓋率工具鏈從“單數(shù)字展示”升級成了質(zhì)量審計治理的一環(huán)。這篇記錄就是這次適配的完整過程包括原理拆解、補丁思路、CI 門禁集成和排查實錄適合正在做鴻蒙 Flutter 工程收尾、打算給流水線加覆蓋率門檻的團隊參考。1. 項目背景覆蓋率報告里的“數(shù)字泡沫”到底怎么來的1.1 一張 lcov 報告兩套數(shù)字先還原一下最典型的場景。Flutter 工程里執(zhí)行flutter test --coverage會在coverage/下生成lcov.info這是標(biāo)準(zhǔn)的 lcov 格式一行一個記錄段描述每個源文件被覆蓋到的行數(shù)。本地開發(fā)時 IDE 插件直接解析它CI 里則把它喂給靜態(tài)分析平臺或者自研的報表服務(wù)。問題在于lcov.info 里不只有l(wèi)ib/下的業(yè)務(wù)代碼。只要項目引入了 json_serializable、freezed、build_runner 這類代碼生成鏈路lib/**/*.g.dart和lib/**/*.freezed.dart就會大量出現(xiàn)。這些代碼在編譯期生成邏輯重復(fù)、特征明顯測試用例不會刻意去測它們但它們的 DA 記錄實實在在地進入覆蓋率統(tǒng)計。舉一個我處理過的真實口徑。假設(shè)工程總可執(zhí)行行數(shù)是 20000 行其中業(yè)務(wù)手寫代碼 16000 行自動生成和依賴注入代碼 4000 行。某次測試跑完總共覆蓋了 14000 行但這 14000 行里包含了 1000 行生成代碼的覆蓋記錄。統(tǒng)計口徑分母覆蓋行數(shù)覆蓋率原始總覆蓋率200001400070%排除生成代碼后的業(yè)務(wù)覆蓋率160001300081.25%同一批測試兩個數(shù)字差了 11.25 個百分點。哪個更能代表工程質(zhì)量顯然是后者。前者就是“數(shù)字泡沫”它讓你以為業(yè)務(wù)代碼測試得不夠?qū)嶋H上可能是分母被無關(guān)文件撐大了。1.2 為什么審計團隊需要一個“干凈”的覆蓋率如果覆蓋率只有一個總數(shù)字質(zhì)量審計就會很尷尬。管理層看的是趨勢團隊看的是門檻但如果 CI 里只展示 70% 這個總覆蓋率團隊的注意力就會從“業(yè)務(wù)邏輯有沒有測”轉(zhuǎn)移到“怎么把分母縮小”。有人會去給自動生成代碼寫冗余測試有人會把排除規(guī)則寫得過于寬松甚至有人直接把不達標(biāo)的分支摘出統(tǒng)計范圍。這些動作的本質(zhì)都是在修飾數(shù)字不是提升質(zhì)量。審計需要的是“可解釋的覆蓋率”。所謂可解釋是指任何人拿到這份報告都能說清楚覆蓋率的分母由哪些文件構(gòu)成、為什么這些文件被排除、排除后還剩多少核心業(yè)務(wù)代碼。總覆蓋率適合做宏觀趨勢觀察核心業(yè)務(wù)覆蓋率才適合做發(fā)布門禁和 MR 評審依據(jù)。我們做鴻蒙化適配本質(zhì)上就是在把remove_from_coverage從“一個過濾工具”變成“一套可配置、可追溯、可納入治理流程的質(zhì)量底座”。2. remove_from_coverage 原理拆解它不是正則腳本是 lcov 過濾引擎2.1 核心行為按 SF 段裁剪 lcov 記錄要適配一個工具先得吃透它怎么工作。remove_from_coverage做的事情并不復(fù)雜它逐段讀取 lcov.info每一段以SF:開頭標(biāo)記一個源文件路徑后面跟著DA:行覆蓋數(shù)據(jù)、LF:可執(zhí)行行總數(shù)、LH:覆蓋行總數(shù)最后以end_of_record結(jié)束。工具拿到用戶提供的 glob 規(guī)則后對每個文件段的SF:路徑做匹配。如果匹配上整段直接丟棄不寫入輸出文件沒匹配上則原樣保留。處理完之后輸出一份新的 lcov 文件。這里有個容易被忽略的關(guān)鍵點lcov 的不同文件段之間是相互獨立的。刪除一個文件段不會影響其他段里的DA:記錄也不需要重排行號因為每個DA:記錄都自帶行號。所以remove_from_coverage不需要像源碼級覆蓋率工具那樣維護行號映射它只要做好“段級別過濾”就夠了。理解這一點后面調(diào)試誤刪問題時能少走很多彎路。2.2 glob 匹配規(guī)則怎么用才不踩邊界工具的核心參數(shù)是-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在大多數(shù) glob 實現(xiàn)里語義相近都表示lib/下任意層級遞歸匹配。但規(guī)則寫起來有幾個容易踩的邊界第一路徑分隔符。lcov 里的SF:路徑通常用正斜杠/但 Windows 本地開發(fā)時某些工具鏈會輸出反斜杠\。glob 匹配默認(rèn)按/處理一旦混入反斜杠規(guī)則就失效。第二相對路徑前綴。有的工程SF:是lib/foo.dart有的卻是./lib/foo.dart。多一個./lib/**就匹配不上。適配時最好統(tǒng)一做歸一化而不是靠規(guī)則去猜。第三規(guī)則范圍。**/*.g.dart會把test/下 mock 產(chǎn)生的 g.dart 也過濾掉如果這些 mock 文件本身是你想統(tǒng)計的對象就會誤傷。建議規(guī)則盡量指向明確的頂層目錄比如lib/**.g.dart而不是到處匹配。2.3 自己寫腳本過濾與直接 fork 的三點權(quán)衡有人會問lcov 過濾這么簡單為什么不自己寫個 Python 腳本我一開始也是這么想的但評估下來有三點權(quán)衡第一lcov 格式的邊界情況比想象中多。文件路徑里可能出現(xiàn)空格、括號DA:行可能有逗號帶執(zhí)行次數(shù)記錄段之間偶爾會有空行腳本解析時這些都要處理干凈否則線上報告會莫名少數(shù)據(jù)。第二glob 規(guī)則看起來簡單真要做到跨平臺、遞歸匹配、排除和保留語義清晰直接用成熟庫比自己實現(xiàn)可靠得多。remove_from_coverage已經(jīng)把這些邏輯封裝好了拿過來改比從零寫省力。第三后續(xù)維護問題。覆蓋率工具不是寫完就完團隊會不斷往排除規(guī)則里加目錄CI 里還要支持配置化。用開源工具做底座fork 一份維護比每個人手里一份隨手腳本更可控。所以在“自己寫 10 行腳本”和“直接 fork 適配”之間我選了后者。3. 鴻蒙化適配要跨過的三個坑路徑、產(chǎn)物目錄和工具鏈3.1 路徑風(fēng)格與相對根目錄先觀察 SF 記錄長什么樣鴻蒙 Flutter 工程的目錄結(jié)構(gòu)和標(biāo)準(zhǔn) Flutter 工程有一個明顯差異工程根目錄下除了lib/、test/還有entry/、oh_modules/、.hvigor/、build/這些鴻蒙側(cè)目錄。flutter test --coverage生成的 lcov.info 中SF:路徑仍然以工程根為相對根但文件來源會混入不同模塊。適配第一步不是寫代碼而是先拿到一份鴻蒙工程的 lcov.info把前幾十條SF:記錄打出來看grep ^SF: coverage/lcov.info | head -50我拿到手的結(jié)果大概是這個畫風(fēng)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 # 理論上不應(yīng)出現(xiàn)在 dart 覆蓋率里看到oh_modules和.hvigor的時候基本就能確定直接用原版remove_from_coverage是搞不定的。它沒有內(nèi)置這些鴻蒙路徑的識別能力而且如果SF:里混入了絕對路徑或者./前綴規(guī)則匹配也會靜默失敗。3.2 自動產(chǎn)物目錄識別不是只有 .g.dart 需要過濾標(biāo)準(zhǔn) Flutter 項目的臟數(shù)據(jù)主要是*.g.dart、*.freezed.dart、.dart_tool下的工具生成代碼。鴻蒙工程多出來的幾類“新面孔”更陰間一類是oh_modules/。鴻蒙側(cè)的三方依賴通常會解壓到工程根的oh_modules/目錄個別的 Dart 源碼也會被拷貝到這里。它們是依賴包不是業(yè)務(wù)代碼測試用例跑到的覆蓋記錄計入分母會嚴(yán)重拉低真實覆蓋率。另一類是.hvigor/。鴻蒙構(gòu)建系統(tǒng)的緩存和臨時文件目錄里偶爾會出現(xiàn)腳手架自動生成 Dart bridge 文件。這些文件每次構(gòu)建都可能變化如果把它們計入覆蓋率報告的可復(fù)現(xiàn)性就很差。還有build/、entry/build/這類產(chǎn)物目錄。理論上 lcov 不應(yīng)該收錄它們但在多模塊工程里偶爾會有漏網(wǎng)之魚。我建議默認(rèn)內(nèi)置一組鴻蒙排除目錄這些規(guī)則應(yīng)該經(jīng)過團隊評審而不是一個人說了算。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 版本可能和標(biāo)準(zhǔn) Flutter SDK 不完全一致。如果 fork 下來直接pub get可能遇到 SDK 約束沖突。解決方式有兩種。第一種是改 fork 版本里pubspec.yaml的environment.sdk約束放寬到當(dāng)前 SDK 能接受的范圍。第二種是把執(zhí)行入口切到鴻蒙 SDK 自帶的 dart 可執(zhí)行文件例如$FLUTTER_HOME/bin/dart run ...確保運行環(huán)境和構(gòu)建環(huán)境一致。這里有個小建議適配版不要改原版的命令行參數(shù)結(jié)構(gòu)否則 CI 腳本遷移成本會變大。兼容原參數(shù)再增加新參數(shù)是最穩(wěn)妥的做法。4. 適配實操從 fork 到 CI 門禁一次講透4.1 源碼獲取與本地環(huán)境準(zhǔn)備適配的第一步是把源碼拉到本地。我們在代碼托管平臺上 fork 了一份remove_from_coverageclone 到工作目錄然后切到一個獨立的適配分支。環(huán)境準(zhǔn)備方面需要三樣?xùn)|西鴻蒙 Flutter SDK、對應(yīng)的 Dart SDK、一份能跑通flutter test --coverage的模擬鴻蒙工程。沒有真實工程也沒關(guān)系可以先造一個最小樣例但一定要包含oh_modules、.hvigor這類目錄名否則自測覆蓋不到關(guān)鍵適配點。mkdir remove_from_coverage_harmony cd remove_from_coverage_harmony git clone 本地 fork 地址 cd remove_from_coverage flutter pub getpub get成功不代表運行沒問題一定要在鴻蒙工程的根目錄下用真實的 lcov.info 執(zhí)行一次原版工具確認(rèn)原版在哪個環(huán)節(jié)失效。4.2 路徑規(guī)范化補丁把所有路徑統(tǒng)一成正斜杠 去掉 ./ 前綴原版工具里SF:路徑進了匹配邏輯后是直接和 glob 規(guī)則比對的。鴻蒙工程里最容易翻車的就是路徑風(fēng)格。我加了一個歸一化函數(shù)在讀取每一行的路徑后立刻處理String normalizeLcovPath(String rawPath) { var path rawPath.replaceAll(\\, /); while (path.startsWith(./)) { path path.substring(2); } return path; }這段邏輯不復(fù)雜但解決了兩類問題。第一Windows 本地產(chǎn)生的lib\core\http.dart會被轉(zhuǎn)成lib/core/http.dart規(guī)則統(tǒng)一用正斜杠書寫。第二./lib/foo.dart變成lib/foo.dart不需要用戶為前綴差異寫兩套規(guī)則。此外如果SF:出現(xiàn)了絕對路徑我會判斷它是否以當(dāng)前工程根開頭如果是就裁剪成相對路徑如果不是則原樣保留。這一步能避免用戶規(guī)則里寫絕對路徑前綴的尷尬。4.3 內(nèi)置鴻蒙默認(rèn)排除規(guī)則新增 --harmony 開關(guān)鴻蒙化適配不能只靠用戶每次手動寫一堆oh_modules/**容易漏。我加了一個--harmony開關(guān)開啟后自動追加默認(rèn)排除規(guī)則const harmonyDefaultGlobs [ oh_modules/**, **/oh_modules/**, .hvigor/**, **/.hvigor/**, **/build/**, **/.dart_tool/**, ];這些規(guī)則和用戶的-r規(guī)則是并集關(guān)系不是互斥關(guān)系。也就是說只要任一規(guī)則命中該文件段就會被移除。命令行使用方式變成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注意原版參數(shù)一個都沒動--harmony只是追加能力。這樣做的好處是團隊切換到鴻蒙環(huán)境時CI 腳本只需要增加一個參數(shù)不需要重寫規(guī)則集。4.4 單元自測與真實工程驗證適配代碼改完不能直接上 CI得先自測。我寫了兩類驗證第一類是用臨時構(gòu)造的 lcov 樣本跑單元測試。樣本里故意放這些文件段SF:lib/core/util.dart應(yīng)該保留SF:lib/generated/api.g.dart應(yīng)該移除SF:oh_modules/foo/lib/foo.dart開啟--harmony后應(yīng)該移除SF:.hvigor/cache/generated_dart/a.dart應(yīng)該移除SF:entry/src/main/ets/demo.ets因為不是 Dart 文件理論上不會出現(xiàn)但我也驗證了規(guī)則不會誤傷第二類是在模擬鴻蒙工程里跑真實流程。先執(zhí)行flutter test --coverage拿到原始coverage/lcov.info。然后跑適配版對比清理前后的記錄段數(shù)量和數(shù)據(jù)。我們當(dāng)時的結(jié)果是原始 lcov.info 有 132 條SF:記錄適配版清理后剩 86 條剝離了 46 個生成代碼或依賴文件段。覆蓋率表現(xiàn)從 70% 提升到 81.25%。這個提升幅度是合理的因為這些被剝離的文件段本身貢獻了覆蓋行但它們不應(yīng)該被算進業(yè)務(wù)質(zhì)量指標(biāo)。4.5 集成 CI兩個覆蓋率指標(biāo)加一道質(zhì)量門禁本地驗證通過之后我把適配版接到了 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得到兩個覆蓋率指標(biāo) # total_coverage原始總覆蓋率用于宏觀趨勢 # core_coverage清理后的業(yè)務(wù)覆蓋率用于發(fā)布門禁門禁規(guī)則我建議這樣設(shè)core_coverage低于閾值時構(gòu)建失敗total_coverage不設(shè)硬門檻但出現(xiàn)單次下降超過 2 個百分點時自動給團隊發(fā)告警。閾值不能拍腦袋定先跑兩周看團隊歷史的 70 分位和 90 分位再確定一個“跳一跳夠得著”的值。lcov.clean.info 還要繼續(xù)往上報。靜態(tài)分析平臺直接解析它展示的就是團隊真正關(guān)心的業(yè)務(wù)覆蓋率。原始 lcov.info 保留在構(gòu)建產(chǎn)物里審計時如果需要排查“為什么數(shù)字變了”還能回看原始數(shù)據(jù)。5. 常見問題與排查技巧實錄5.1 排除后覆蓋率紋絲不動八成是路徑?jīng)]對上適配版剛上線時有同事反饋“我加了oh_modules/**怎么覆蓋率一點沒變”我讓他先把輸出文件打開看一眼發(fā)現(xiàn)SF:條數(shù)跟輸入完全一樣說明規(guī)則一條都沒命中。排查方法很簡單先確認(rèn) lcov.info 里的實際路徑格式grep ^SF: coverage/lcov.info | head -20常見的坑有三個。第一路徑帶了./前綴規(guī)則卻寫的是oh_modules/**第二Windows 下路徑是反斜杠規(guī)則里還是正斜杠第三路徑是絕對路徑規(guī)則寫的是相對路徑。我的經(jīng)驗是不要憑記憶寫規(guī)則一定先看真實路徑。提示所有規(guī)則修改完先用--output生成一份臨時文件對比 SF 條數(shù)變化確認(rèn)命中了再跑全量。5.2 誤傷業(yè)務(wù)代碼規(guī)則太寬差點刪掉核心模塊有一次我把**/generated/**加進排除規(guī)則結(jié)果覆蓋率不升反降。查了半天發(fā)現(xiàn)項目里有個lib/repository/generated_api/目錄里面不是自動生成文件而是開發(fā)手寫的 API 客戶端代碼。這條寬規(guī)則把這些真實業(yè)務(wù)代碼段全濾掉了分母一下子少了覆蓋行也少了最終業(yè)務(wù)覆蓋率反而崩了。這件事給我的教訓(xùn)是排除規(guī)則必須有強特征不要只憑目錄名猜測。.g.dart、.freezed.dart這種后綴特征就很好因為它們只可能來自代碼生成器。目錄名排除盡量限定在oh_modules/、.hvigor/、.dart_tool/這類根級目錄不要用**/generated/**這種打中一切的名字。如果工具支持打印匹配文件清單建議先跑一遍確認(rèn)dart run remove_from_coverage --list-files -f coverage/lcov.info --harmony把命中的文件列表拉出來人工掃一眼再正式接入門禁。5.3 Windows 開發(fā)機與 Linux CI 的路徑分隔符差異適配完第一版后我在本地 Windows 上跑得好好的推到 CI 后 Linux 節(jié)點上規(guī)則全部失效。查了很久才發(fā)現(xiàn)部分依賴工具在 Windows 環(huán)境下會把路徑寫成反斜杠而我的歸一化邏輯只處理了讀取SF:時的路徑?jīng)]有處理規(guī)則本身。后來我統(tǒng)一了三處策略讀取路徑后轉(zhuǎn)正斜杠、規(guī)則書寫要求一律正斜杠、對比時大小寫不敏感但保留原始路徑輸出。如果團隊里有 macOS 或 Windows 混合開發(fā)環(huán)境這三個統(tǒng)一能省掉大量莫名其妙的“本地好了 CI 掛了”問題。5.4 大倉庫性能問題glob 展開和規(guī)則順序適配版上線后跑了兩個星期發(fā)現(xiàn)處理時間偶爾會從幾百毫秒漲到幾秒。查了一下是某次評審時往規(guī)則里加了一條特別寬的**/bridge/**這個規(guī)則會遞歸展開大量子目錄匹配開銷明顯增加。lcov 記錄段少則幾千多則幾萬。每條規(guī)則都做一次遞歸 glob 展開性能自然往下掉。我建議規(guī)則里能用lib/**.g.dart就用這種帶頂層目錄限制的寫法少用裸**開頭的規(guī)則同時把精確規(guī)則放在前面寬泛規(guī)則放在后面。如果實現(xiàn)一次命中即跳過精確規(guī)則先命中就能減少后續(xù)匹配次數(shù)。實測下來幾千條記錄、十條以內(nèi)的規(guī)則處理時間基本可以保持在 1 秒以內(nèi)感知不到性能問題。6. 從“報告精簡”到“質(zhì)量審計治理”的延伸實踐6.1 雙層指標(biāo)模型總覆蓋率作為參考業(yè)務(wù)覆蓋率作為門禁適配完成之后我們重新設(shè)計了覆蓋率指標(biāo)體系。原來的指標(biāo)只有一個“所有文件覆蓋率”看起來簡單實則管理成本很高?,F(xiàn)在拆成兩層指標(biāo)數(shù)據(jù)來源用途建議閾值原始總覆蓋率lcov.info宏觀趨勢、環(huán)境變化告警70% 以上核心業(yè)務(wù)覆蓋率lcov.clean.info發(fā)布門禁、MR 評審80% 以上總覆蓋率是用來“看趨勢”的它如果突然大幅下降說明有新的大塊代碼未覆蓋值得關(guān)注。核心業(yè)務(wù)覆蓋率是用來“卡發(fā)布”的它直接反映手寫業(yè)務(wù)代碼的測試充分度。兩個指標(biāo)同時看才不會顧此失彼。6.2 增量覆蓋率怎么和 remove_from_coverage 配合整體覆蓋率有一個滯后問題這次改動了 200 行新代碼但分母是 16000 行的業(yè)務(wù)代碼指標(biāo)幾乎無感知。發(fā)布門禁只卡整體覆蓋率新代碼仍然可能裸奔。我建議在 CI 里增加增量覆蓋率統(tǒng)計。思路是先用git diff --name-only HEAD~1提取本次變更文件再從lcov.clean.info中過濾出這些文件的覆蓋記錄單獨計算增量覆蓋率。這個過程本質(zhì)上還是“按文件段過濾 lcov”和remove_from_coverage的底層邏輯一致完全可以復(fù)用同一套代碼。增量覆蓋率可以作為 MR 機器人的評論內(nèi)容每次提交自動發(fā)一條“本次新增代碼覆蓋率 xx%低于閾值不可合并”。團隊對增量的感知會比整體指標(biāo)敏銳得多。6.3 把排除規(guī)則當(dāng)成資產(chǎn)而不是一次性參數(shù)覆蓋率治理最怕的是規(guī)則靠口口相傳。今天 A 同學(xué)在 CI 里加一條規(guī)則明天 B 同學(xué)因為同樣的問題又加一條最后排除規(guī)則變成了一鍋粥誰都不知道哪些文件被拿掉了。我把規(guī)則從命令行參數(shù)里挪了出來維護成工程根目錄下的coverage_exclude.yamlharmony: enabled: true default_excludes: true excludes: - lib/**.g.dart - lib/**.freezed.dart - **/.dart_tool/** - oh_modules/** - .hvigor/**適配版增加了一個--config參數(shù)CI 腳本只需要傳配置文件路徑不再需要一長串的-r。規(guī)則變更走代碼評審歷史可追溯新同事也容易理解“分母是什么、為什么排除這些文件”。我個人的體會是覆蓋率治理做到這個程度才算真正從“報告精簡”走到了“質(zhì)量審計治理”。remove_from_coverage的鴻蒙化適配只是個起點真正有價值的是背后那套可解釋、可配置、可持續(xù)改進的質(zhì)量閉環(huán)。后面如果再遇到新平臺適配這套指標(biāo)模型和配置管理方式可以直接復(fù)用不需要再從頭趟一遍。