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. 只要基本块中第一条指令被实行,那么基本块内所有指令都会顺序实行一次。
分支、循环构造对应着基本块之间的跳转。LLVM 基于 BB 进行覆盖率计数指令的插入。
覆盖率计数指令的插入会进行两次循环,外层循环遍历编译单元中的函数,内层循环遍历函数的基本块。函数遍历仅用来向 .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 文件,会和之前的实行次数进行合并。
1. 调用llvm_gcda_summary_info,写入校验信息。
2. 调用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 美食频道的开拓掩护。
欢迎加入美团iOS技能互换群,跟项目掩护者零间隔互换。进群办法:请加美美同学微信(微旗子暗记:MTDPtech02),回答:iOS,美美会自动拉你进群。
到店餐饮技能部交易与信息技能中央,卖力点评美食用户端业务,做事于数以亿计用户,我们的团队包含且不限于 Android、iOS、FE、Java、PHP 等技能方向,已覆盖前后端技能栈。只要你来,就能点亮全栈开拓技能树。诚挚欢迎有兴趣的小伙伴加入我们,扫描下方二维码查看职位详情,或者直接投递简历至 wangkang@meituan.com。