iOS 覆蓋率檢測原理與增量程式碼測試覆蓋率工具實現

美團技術團隊發表於2018-12-28

背景

對蘋果開發者而言,由於平臺稽核週期較長,客戶端程式碼導致的線上問題影響時間往往比較久。如果在開發、測試階段能夠提前暴露問題,就有助於避免線上事故的發生。程式碼覆蓋率檢測正是幫助開發、測試同學提前發現問題,保證程式碼質量的好幫手。

對於開發者而言,程式碼覆蓋率可以反饋兩方面資訊:

  1. 自測的充分程度。
  2. 程式碼設計的冗餘程度。

儘管程式碼覆蓋率對程式碼質量有著上述好處,但在 iOS 開發中卻使用的不多。我們調研了市場上常用的 iOS 覆蓋率檢測工具,這些工具主要存在以下四個問題:

  1. 第三方工具有時生成的檢測報告檔案會出錯甚至會失敗,開發者對覆蓋率生成原理不瞭解,遇到這類問題容易棄用工具。
  2. 第三方工具每次展示全量的覆蓋率報告,會分散開發者的很多精力在未修改部分。而在絕大多數情況下,開發者的關注重點在本次新增和修改的部分。
  3. Xcode 自帶的覆蓋率檢測只適用於單元測試場景,由於需求變更頻繁,業務團隊開發單元測試的成本很高
  4. 已有工具很難和現有開發流程結合起來,需要額外進行測試,執行覆蓋率指令碼才能獲取報告檔案。

為了解決上述問題,我們深入調研了覆蓋率報告的生成邏輯,並結合團隊的開發流程,開發了一套嵌入在程式碼提交流程中、基於單次程式碼提交(git commit)生成報告、對開發者透明的增量程式碼測試覆蓋率工具。開發者只需要正常開發,通過模擬器測試開發程式碼,commit 本次程式碼(commit 和測試順序可交換),推送(git push)到遠端,就可以在本地看到這次提交程式碼的詳細覆蓋率報告了。

本文分為兩部分,先從介紹通用覆蓋率檢測的原理出發,讓讀者對覆蓋率的收集、解析有直觀的認識。之後介紹我們增量程式碼測試覆蓋率工具的實現。

覆蓋率檢測原理

生成覆蓋率報告,首先需要在 Xcode 中配置編譯選項,編譯後會為每個可執行檔案生成對應的 .gcno 檔案;之後在程式碼中呼叫覆蓋率分發函式,會生成對應的 .gcda 檔案。

其中,.gcno 包含了程式碼計數器和原始碼的對映關係, .gcda 記錄了每段程式碼具體的執行次數。覆蓋率解析工具需要結合這兩個檔案給出最後的檢測報表。接下來先看看 .gcno 的生成邏輯。

.gcno

利用 Clang 分別生成原始檔的 AST 和 IR 檔案,對比發現,AST 中不存在計數指令,而 IR 中存在用來記錄執行次數的程式碼。搜尋 LLVM 原始碼可以找到覆蓋率對映關係生成原始碼。覆蓋率對映關係生成原始碼是 LLVM 的一個 Pass,(下文簡稱 GCOVPass)用來向 IR 中插入計數程式碼並生成 .gcno 檔案(關聯計數指令和原始檔)。

下面分別介紹IR插樁邏輯和 .gcno 檔案結構。

IR 插樁邏輯

程式碼行是否執行到,需要在執行中統計,這就需要對程式碼本身做一些修改,LLVM 通過修改 IR 插入了計數程式碼,因此我們不需要改動任何原始檔,僅需在編譯階段增加編譯器選項,就能實現覆蓋率檢測了。

從編譯器角度看,基本塊(Basic Block,下文簡稱 BB)是程式碼執行的基本單元,LLVM 基於 BB 進行覆蓋率計數指令的插入,BB 的特點是:

  1. 只有一個入口。
  2. 只有一個出口。
  3. 只要基本塊中第一條指令被執行,那麼基本塊內所有指令都會順序執行一次

覆蓋率計數指令的插入會進行兩次迴圈,外層迴圈遍歷編譯單元中的函式,內層迴圈遍歷函式的基本塊。函式遍歷僅用來向 .gcno 中寫入函式位置資訊,這裡不再贅述。

一個函式中基本塊的插樁方法如下:

  1. 統計所有 BB 的後繼數 n,建立和後繼數大小相同的陣列 ctr[n]。
  2. 以後繼數編號為序號將執行次數依次記錄在 ctr[i] 位置,對於多後繼情況根據條件判斷插入。

舉個例子,下面是一段猜數字的遊戲程式碼,當玩家猜中了我們預設的數字10的時候會輸出Bingo,否則輸出You guessed wrong!。這段程式碼的控制流程圖如圖1所示。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSLog(@"Welcome to the game");
    if (guessNumber == 10) {
        NSLog(@"Bingo!");
    } else {
        NSLog(@"You guess is wrong!");
    }
}

例1 猜數字遊戲

這段程式碼如果開啟了覆蓋率檢測,會生成一個長度為 6 的 64 位陣列,對照插樁位置,方括號中標記了樁點序號,圖 1 中程式碼前數字為所在行數。


圖 1 樁點位置

.gcno計數符號和檔案位置關聯

.gcno 是用來儲存計數插樁位置和原始檔之間關係的檔案。GCOVPass 在通過兩層迴圈插入計數指令的同時,會將檔案及 BB 的資訊寫入 .gcno 檔案。寫入步驟如下:

  1. 建立 .gcno 檔案,寫入 Magic number(oncg+version)。
  2. 隨著函式遍歷寫入檔案地址、函式名和函式在原始檔中的起止行數(標記檔名,函式在原始檔對應行數)。
  3. 隨著 BB 遍歷,寫入 BB 編號、BB 起止範圍、BB 的後繼節點編號(標記基本塊跳轉關係)。
  4. 寫入函式中BB對應行號資訊(標註基本塊與原始碼行數關係)。

從上面的寫入步驟可以看出,.gcno 檔案結構由四部分組成:

  • 檔案結構
  • 函式結構
  • BB 結構
  • BB 行結構

通過這四部分結構可以完全還原插樁程式碼和原始碼的關聯,我們以 BB 結構 / BB 行結構為例,給出結構圖 2 (a) BB 結構,(b) BB 行資訊結構,在本章末尾覆蓋率解析部分,我們利用這個結構圖還原始碼執行次數(每行等高格代表 64bit):


圖2 BB 結構和 BB 行資訊結構

.gcda

入口函式

關於 .gcda 的生成邏輯,可參考覆蓋率資料分發原始碼。這個檔案中包含了 __gcov_flush() 函式,這個函式正是分發邏輯的入口。接下來看看 __gcov_flush() 如何生成 .gcda 檔案。

通過閱讀程式碼和除錯,我們發現在二進位制程式碼載入時,呼叫了llvm_gcov_init(writeout_fn wfn, flush_fn ffn)函式,傳入了_llvm_gcov_writeout(寫 gcov 檔案),_llvm_gcov_flush(gcov 節點分發)兩個函式,並且根據呼叫順序,分別建立了以檔案為節點的連結串列結構。(flush_fn_node * ,writeout_fn_node *

__gcov_flush() 程式碼如下所示,當我們手動呼叫__gcov_flush() 進行覆蓋率分發時,會遍歷flush_fn_node *這個連結串列(即遍歷所有檔案節點),並呼叫分發函式_llvm_gcov_flush(curr->fn 正是__llvm_gcov_flush函式型別)。

void __gcov_flush() {
    struct flush_fn_node *curr = flush_fn_head;
    
    while (curr) {
        curr->fn();
        curr = curr->next;
    }
}

具體的分發邏輯

觀察__llvm_gcov_flush 的 IR 程式碼,可以看到:


圖3 __llvm_gcov_flush 程式碼示例

  1. __llvm_gcov_flush 先呼叫了__llvm_gcov_writeout,來向 .gcda 寫入覆蓋率資訊。
  2. 最後將計數陣列清零__llvm_gcov_ctr.xx

__llvm_gcov_writeout邏輯為:

  1. 生成對應原始檔的 .gcda 檔案,寫入 Magic number。
  2. 迴圈執行
    llvm_gcda_emit_function: 向 .gcda 檔案寫入函式資訊。

    llvm_gcda_emit_arcs: 向 .gcda 檔案寫入BB執行資訊,如果已經存在 .gcda 檔案,會和之前的執行次數進行合併

  3. 呼叫llvm_gcda_summary_info,寫入校驗資訊。
  4. 呼叫llvm_gcda_end_file,寫結束符。

感興趣的同學可以自己生成 IR 檔案檢視更多細節,這裡不再贅述。

.gcda 的檔案/函式結構和 .gcno 基本一致,這裡不再贅述,統計插樁資訊結構如圖 4 所示。定製化的輸出也可以通過修改上述函式完成。我們的增量程式碼測試覆蓋率工具解決程式碼 BB 結構變動後合併到已有 .gcda 檔案不相容的問題,也是修改上述函式實現的。


圖4 計數樁輸出結構

覆蓋率解析

在瞭解瞭如上所述 .gcno ,.gcda 生成邏輯與檔案結構之後,我們以例 1 中的程式碼為例,來闡述解析演算法的實現。

例 1 中基本塊 B0,B1 對應的 .gcno 檔案結構如下圖所示,從圖中可以看出,BB 的主結構完全記錄了基本塊之間的跳轉關係。


圖5 B0,B1 對應跳轉資訊

B0,B1 的行資訊在 .gcno 中表示如下圖所示,B0 塊因為是入口塊,只有一行,對應行號可以從 B1 結構中獲取,而 B1 有兩行程式碼,會依次把行號寫入 .gcno 檔案。


圖6 B0,B1 對應行資訊

在輸入數字 100 的情況下,生成的 .gcda 檔案如下:


圖7 輸入 100 得到的 .gcda 檔案

通過控制流程圖中節點出邊的執行次數可以計算出 BB 的執行次數,核心演算法為計算這個 BB 的所有出邊的執行次數,不存在出邊的情況下計算所有入邊的執行次數(具體實現可以參考 gcov 工具原始碼),對於 B0 來說,即看 index=0 的執行次數。而 B1 的執行次數即 index=1,2 的執行次數的和,對照上圖中 .gcda 檔案可以推斷出,B0 的執行次數為 ctr[0]=1,B1 的執行次數是 ctr[1]+ctr[2]=1, B2 的執行次數是 ctr[3]=0,B4 的執行次數為 ctr[4]=1,B5 的執行次數為 ctr[5]=1。

經過上述解析,最終生成的 HTML 如下圖所示(利用 lcov):


圖8 覆蓋率檢測報告

以上是 Clang 生成覆蓋率資訊和解析的過程,下面介紹美團到店餐飲 iOS 團隊基於以上原理做的增量程式碼測試覆蓋率工具。

增量程式碼覆蓋率檢測原理

方案權衡

由於 gcov 工具(和前面的 .gcov 檔案區分,gcov 是覆蓋率報告生成工具)生成的覆蓋率檢測報告可讀性不佳,如圖 9 所示。我們做的增量程式碼測試覆蓋率工具是基於 lcov 的擴充套件,報告展示如上節末尾圖 8 所示。


圖9 gcov 輸出,行前數字代表執行次數,#### 代表沒執行

比 gcov 直接生成報告多了一步,lcov 的處理流程是將 .gcno 和 .gcda 檔案解析成一個以 .info 結尾的中間檔案(這個檔案已經包含全部覆蓋率資訊了),之後通過覆蓋率報告生成工具生成可讀性比較好的 HTML 報告。

結合前兩章內容和覆蓋率報告生成步驟,覆蓋率生成流程如下圖所示。考慮到增量程式碼覆蓋率檢測中程式碼增量部分需要通過 Git 獲取,比較自然的想法是用 git diff 的資訊去過濾覆蓋率的內容。根據過濾點的不同,存在以下兩套方案:

  1. 通過 GCOVPass 過濾,只對修改的程式碼進行插樁,每次修改後需重新插樁。
  2. 通過 .info 過濾,一次性為所有程式碼插樁,獲取全部覆蓋率資訊,過濾覆蓋率資訊。


圖10 覆蓋率生成流程

分析這兩個方案,第一個方案需要自定義 LLVM 的 Pass,進而會引入以下兩個問題:

  • 只能使用開源 Clang 進行編譯,不利於接入正常的開發流程。
  • 每次重新插樁會丟失之前的覆蓋率資訊,多次執行只能得到最後一次的結果。

而第二個方案相對更加輕量,只需要過濾中間格式檔案,不僅可以解決我們在文章開頭提到的問題,也可以避免上述問題:

  • 可以很方便地加入到平常程式碼的開發流程中,甚至對開發者透明。
  • 未修改檔案的覆蓋率可以疊加(有修改的那些控制流程圖結構可能變化,無法疊加)。

因此我們實際開發選定的過濾點是在 .info 。在選定了方案 2 之後,我們對中間檔案 .info 進行了一系列調研,確定了檔案基本格式(函式/程式碼行覆蓋率對應的檔案的表示),這裡不再贅述,具體可以參考 .info 生成文件

增量程式碼測試覆蓋率工具的實現

前一節是實現增量程式碼覆蓋率檢測的基本方案選擇,為了更好地接入現有開發流程,我們做了以下幾方面的優化。

降低使用成本

在接入方面,接入增量程式碼測試覆蓋率工具只需一次接入配置,同步到程式碼倉庫後,團隊中成員無需配置即可使用,降低了接入成本。

在使用方面,考慮到插樁在編譯時進行,對全部程式碼進行插樁會很大程度降低編譯速度,我們通過解析 Podfile(iOS 開發中較為常用的包管理工具 CocoaPods 的依賴描述檔案),只對 Podfile 中使用原生程式碼的倉庫進行插樁(可配置指定倉庫),降低了團隊的開發成本。

對開發者透明

接入增量程式碼測試覆蓋率工具後,開發者無需特殊操作,也不需要對工程做任何其他修改,正常的 git commit 程式碼,git push 到遠端就會自動生成並上傳這次 commit 的覆蓋率資訊了。

為了做到這一點,我們在接入 Pod 的過程中,自動部署了 Git 的 pre-push 指令碼。熟悉 Git 的同學知道,Git 的 hooks 是開發者的本地指令碼,不會被納入版本控制,如何通過一次配置就讓這個倉庫的所有使用成員都能開啟,是做好這件事的一個難點。

我們考慮到 Pod 本身會被納入版本控制,因此利用了 CocoaPods 的一個屬性 script_phase,增加了 Pod 編譯後指令碼,來幫助我們把 pre-push 插入到本地倉庫。利用 script_phase 插入還帶來了另外一個好處,我們可以直接獲取到工程的快取檔案,也避免了 .gcno / .gcda 檔案獲取的不確定性。整個流程如下:


圖11 pre-push 分發流程

覆蓋率累計

在實現了覆蓋率的過濾後,我們在實際開發中遇到了另外一個問題:修改分支/迴圈結構後生成的 .gcda 檔案無法和之前的合併。 在這種情況下,__gcov_flush會直接返回,不再寫入 .gcda 檔案了導致覆蓋率檢測失敗,這也是市面上已有工具的通用問題

而這個問題在開發過程中很常見,比如我們給例 1 中的遊戲增加一些提示,當輸入比預設數字大時,我們就提示出來,反之亦然。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSInteger targetNumber = 10;
    NSLog(@"Welcome to the game");
    if (guessNumber == targetNumber) {
        NSLog(@"Bingo!");
    } else if (guessNumber > targetNumber) {
        NSLog(@"Input number is larger than the given target!");
    } else {
        NSLog(@"Input number is smaller than the given target!");
    }
}

這個問題困擾了我們很久,也推動了對覆蓋率檢測原理的調研。結合前面覆蓋率檢測的原理可以知道,不能合併的原因是生成的控制流程圖比原來多了兩條邊( .gcno 和舊的 .gcda 也不能匹配了),反映在 .gcda 上就是陣列多了兩個資料。考慮到程式碼變動後,原有的覆蓋率資訊已經沒有意義了,當發生邊數不一致的時候,我們會刪除掉舊的 .gcda 檔案,只保留最新 .gcda 檔案(有變動情況下 .gcno 會重新生成)。如下圖所示:


圖12 覆蓋率衝突解決演算法

整體流程圖

結合上述流程,我們的增量程式碼測試覆蓋率工具的整體流程如圖 13 所示。

開發者只需進行接入配置,再次執行時,工程中那些作為本地倉庫進行開發的程式碼庫會被自動插樁,並在 .git 目錄插入 hooks 資訊;當開發者使用模擬器進行需求自測時,插樁統計結果會被自動分發出去;在程式碼被推到遠端前,會根據插樁統計結果,生成僅包含本次程式碼修改的詳細增量程式碼測試覆蓋率報告,以及向遠端推送覆蓋率資訊;同時如果測試覆蓋率小於 80% 會強制拒絕提交(可配置關閉,百分比可自定義),保證只有經過充分自測的程式碼才能提交到遠端。


圖13 增量程式碼測試覆蓋率生成流程圖

總結

以上是我們在程式碼開發質量方面做的一些積累和探索。通過對覆蓋率生成、解析邏輯的探究,我們揭開了覆蓋率檢測的神祕面紗。開發階段的增量程式碼覆蓋率檢測,可以幫助開發者聚焦變動程式碼的邏輯缺陷,從而更好地避免線上問題。

作者介紹

丁京,iOS 高階開發工程師。2015 年 2 月校招加入美團到店餐飲事業群,目前負責大眾點評 App 美食頻道的開發維護。

王穎,iOS 開發工程師。2017 年 3 月校招加入美團到店餐飲事業群,目前參與大眾點評 App 美食頻道的開發維護。

招聘資訊

到店餐飲技術部交易與資訊科技中心,負責點評美食使用者端業務,服務於數以億計使用者,通過更好的榜單、真實的評價和完善的資訊為使用者提供更好的決策支援,致力於提升使用者體驗;同時承載所有餐飲商戶端線上流量,為餐飲商戶提供多種營銷工具,提升餐飲商戶營銷效率,最終達到讓使用者“Eat Better、Live Better”的美好願景!我們的團隊包含且不限於 Android、iOS、FE、Java、PHP 等技術方向,已完備覆蓋前後端技術棧。只要你來,就能點亮全棧開發技能樹。誠摯歡迎投遞簡歷至 wangkang@meituan.com。

參考資料

相關文章