导读
高德地图开放平台产品不断迭代,代码逻辑越来越复杂,现有的测试流程不能保证完全覆盖所有业务代码,测试不到的代码及分支,会存在一定的风险。为了保证测试全面覆盖,需要引入代码覆盖率做为测试指标,需要对SDK代码进行染色,测试结束后可生成代码覆盖率报告,作为发版前的一项重要卡点指标。本文小结了Android端代码染色原理及技术实践。
相关阅读:iOS代码染色原理及技术实践
JaCoCo工具
JaCoCo有以下优点:
支持Ant和Gradle打包方式,可以自由切换。
支持离线模式,更贴合SDK的使用场景。
JaCoCo文档比较全面,还在持续维护,有问题便于解决。
JaCoCo主要是通过ASM技术对Java字节码进行处理和插桩,ASM和Java字节码技术不是本文重点,感兴趣的朋友可以自行了解。下面重点介绍JaCoCo的插桩原理。
Jacoco探针
由于Java字节码是线性的指令序列,所以JaCoCo主要是利用ASM处理字节码,在需要的地方插入一些特殊代码。
我们通过Test1方法观察一下JaCoCo做的处理。
-
//原始java方法
-
public static int Test1(int a, int b) {
-
int c = a + b;
-
int d = c + a;
-
return d;
-
}
-
//--------------------------我是分割线--------------------------------------------//
-
//jacoco处理后的方法
-
private
static
transient
/* synthetic */
boolean[] $jacocoData;
-
-
-
public static int Test1(final int a, final int b) {
-
final
boolean[] $jacocoInit = $jacocoInit();
-
final
int c = a + b;
-
final
int n;
-
final
int d = n = c + a;
-
$jacocoInit[
3] =
true;
-
return n;
-
}
-
private
static
boolean[] $jacocoInit() {
-
boolean[] $jacocoData;
-
if (($jacocoData = TestInstrument.$jacocoData) ==
null) {
-
$jacocoData = (TestInstrument.$jacocoData =
-
Offline.getProbes(-
6846167369868599525L,
-
"com/jacoco/test/TestInstrument",
4));
-
}
-
return $jacocoData;
-
}
可以看出代码中插入了多个Boolean数组赋值,自动添加了jacocoInit方法和jacocoData数组声明。
JaCoCo统计覆盖率就是标记Boolean数组, 只要执行过的代码,就对相应角标的Boolean数组进行赋值, 最后对Boolean进行统计即可得出覆盖率,这个数组官方的名字叫探针 (Probe)。
探针是由以下四行字节码组成,探针不改变该代码的行为,只记录他们是否已被执行,从理论上讲,可以在每行代码都插入一个探针,但是探针本身需要多个字节码指令,这将增加几倍的类文件的大小和执行速度,所以JaCoCo有一定的插桩策略。
-
ALOAD
probearray
-
xPUSH
probeid
-
ICONST_1
-
BASTORE
探针插桩策略
探针的插入需要遵循一定策略,大体可分成以下三个策略:
统计方法的执行情况。
统计分支语句的执行情况。
统计普通代码块的执行情况。
方法的执行情况
这个比较容易处理, 在方法头或者方法尾加就可以了。
方法尾加: 能说明方法被执行过, 且说明了探针上面的方法被执行了,但是这种处理比较麻烦, 可能有多个return或者throw。
方法头加: 处理简单, 但只能说明方法有进去过。
通过分析源码,发现JaCoCo是在方法结尾处插入探针,retrun和throw之后都会加入探针。
-
public void visitInsn(final int opcode) {
-
switch (opcode) {
-
case Opcodes.IRETURN:
-
case Opcodes.LRETURN:
-
case Opcodes.FRETURN:
-
case Opcodes.DRETURN:
-
case Opcodes.ARETURN:
-
case Opcodes.RETURN:
-
case Opcodes.ATHROW:
-
probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
-
break;
-
default:
-
probesVisitor.visitInsn(opcode);
-
break;
-
}
-
}
分支的执行情况
Java字节码通过Jump指令来控制跳转,分为有条件Jump和无条件Jump。
无条件Jump (goto)
这种一般出现在continue, break 中, 由于在任何情况下都执行无条件跳转,因此在GOTO指令之前插入探针。
官方文档中介绍
示例代码
有条件Jump (if-else)
这种经常出现于if等有条件的跳转语句,JaCoCo会对if语句进行反转,将字节码变成if not的逻辑结构。
为什么要对if进行反转?下面示例将说明原因。
Test4方法是一个普通的单条件if语句,可以看到JaCoCo将>10的条件反转成<=10,为什么要进行反转而不是直接在原有if后面增加else块呢?继续往下看复杂一点的情况。
-
//源码
-
public static void Test4(int a) {
-
if(a>
10){
-
a=a+
10;
-
}
-
a=a+
12;
-
}
-
-
-
//jacoco处理后的字节码
-
public static void Test4(int a) {
-
boolean[] var1 = $jacocoInit();
-
if (a <=
10) {
-
var1[
11] =
true;
-
}
else {
-
a +=
10;
-
var1[
12] =
true;
-
}
-
a +=
12;
-
var1[
13] =
true;
-
}
Test5方法是一个多条件的if语句,可以看出来将两个组合条件拆分成单一条件,并进行反转。
这样做的好处:可以完整统计到每个条件分支的执行情况,各种条件都会插入探针,保证了完整的覆盖,而反转操作再配合GOTO指令可以更简单的插入探针,这里可以看出JaCoCo的处理非常巧妙。
-
//源码,if有多个条件
-
public static void Test5(int a,int b) {
-
if(a>
10 || b>
10){
-
a=a+
10;
-
}
-
a=a+
12;
-
}
-
-
-
//jacoco处理后的字节码。
-
public static void Test5(int a, int b) {
-
boolean[] var2;
-
label15: {
-
var2 = $jacocoInit();
-
if (a >
10) {
-
var2[
14] =
true;
-
}
else {
-
if (b <=
10) {
-
var2[
15] =
true;
-
break label15;
-
}
-
var2[
16] =
true;
-
}
-
a +=
10;
-
var2[
17] =
true;
-
}
-
a +=
12;
-
var2[
18] =
true;
-
}
可以通过测试报告看出来,标记为黄色代表分支执行情况覆盖不完整,标记为绿色代表分支所有条件都执行完整了。
代码块的执行情况
理论上只要在每行代码前都插入探针即可, 但这样会有性能问题。JaCoCo考虑到非方法调用的指令基本都是按顺序执行的, 因此对非方法调用的指令不插入探针, 而对方法调用的指令之前都插入探针。
Test6方法内在调用Test方法前都插入了探针。
-
public static void Test6(int a, int b) {
-
boolean[] var2 = $jacocoInit();
-
a += b;
-
b = a + a;
-
var2[
19] =
true;
-
Test();
-
int var10000 = a + b;
-
var2[
20] =
true;
-
Test();
-
var2[
21] =
true;
-
}
源码解析
通过上面的示例,我们暂时通过表面现象理解了探针插入策略。知其然不知其所以然,我们通过源码分析论证一下JaCoCo的真实逻辑,看看JaCoCo是如何通过ASM,来实现探针插入策略的。
源码MethodProbesAdapter.java类中,通过needsProbe方法判断Lable前面是否需要插入探针。
-
@Override
-
public void visitLabel(final Label label) {
-
if (LabelInfo.needsProbe(label)) {
-
if (tryCatchProbeLabels.containsKey(label)) {
-
probesVisitor.visitLabel(tryCatchProbeLabels.get(label));
-
}
-
probesVisitor.visitProbe(idGenerator.nextId());
-
}
-
probesVisitor.visitLabel(label);
-
}
下面看一下needsProbe方法,主要的限制条件有三个successor、multiTarget、methodInvocationLine。
-
public static boolean needsProbe(final Label label) {
-
final LabelInfo info = get(label);
-
return info !=
null && info.successor
-
&& (info.multiTarget || info.methodInvocationLine);
-
}
先看到successor属性。顾名思义,表示当前的Lable是否是前一条Lable的继任者,也就是说当前指令和上一条指令是否是连续的,两条指令中间没有插入GOTO或者return.
LabelFlowAnalyzer.java类中,对每行指令进行流程分析,对successor属性赋值。
-
boolean successor =
false;
//默认是false
-
boolean first =
true;
//默认是true
-
-
-
@Override
-
public void visitJumpInsn(final int opcode, final Label label) {
-
LabelInfo.setTarget(label);
-
if (opcode == Opcodes.JSR) {
-
throw
new AssertionError(
"Subroutines not supported.");
-
}
-
//如果是GOTO指令,successor=false,表示前后两条指令是断开的。
-
successor = opcode != Opcodes.GOTO;
-
first =
false;
-
}
-
-
-
@Override
-
public void visitInsn(final int opcode) {
-
switch (opcode) {
-
case Opcodes.RET:
-
throw
new AssertionError(
"Subroutines not supported.");
-
case Opcodes.IRETURN:
-
case Opcodes.LRETURN:
-
case Opcodes.FRETURN:
-
case Opcodes.DRETURN:
-
case Opcodes.ARETURN:
-
case Opcodes.RETURN:
-
case Opcodes.ATHROW:
-
successor =
false;
//return或者throw,表示两条指令是断开的
-
break;
-
default:
-
successor =
true;
//普通指令的话,表示前后两条指令是连续的
-
break;
-
}
-
first =
false;
-
}
-
-
-
@Override
-
public void visitLabel(final Label label) {
-
if (first) {
-
LabelInfo.setTarget(label);
-
}
-
if (successor) {
//这里设置当前指令是不是上一条指令的继任者,
-
//源码中,只有这一个地方地方会触发这个条件赋值,也就是访问每个label的第一条指令。
-
LabelInfo.setSuccessor(label);
-
}
-
}
再看一下methodInvocationLine属性,当ASM访问到visitMethodInsn方法的时候,就标记当前Lable代表调用一个方法,将methodInvocationLine赋值为True
-
@Override
-
public void visitLineNumber(final int line, final Label start) {
-
lineStart = start;
-
}
-
-
-
@Override
-
public void visitMethodInsn(final int opcode, final String owner,
-
final String name,
final String desc,
final
boolean itf) {
-
successor =
true;
-
first =
false;
-
markMethodInvocationLine();
-
}
-
-
-
private void markMethodInvocationLine() {
-
if (lineStart !=
null) {
-
//lineStart就是当前这个Lable
-
LabelInfo.setMethodInvocationLine(lineStart);
-
}
-
}
-
-
-
LabelInfo.java类
-
public static void setMethodInvocationLine(final Label label) {
-
create(label).methodInvocationLine =
true;
-
}
再看一下multiTarget属性,它表示当前指令是否可能从多个来源跳转过来。源码在下面。
当执行到一条Jump语句时,第二个参数表示要跳转到的Label,这时就会标记一次来源,后续分析流到了该Lable,如果它还是一条继任者指令,那么就将它标记为多来源指令。
-
public void visitJumpInsn(final int opcode, final Label label) {
-
LabelInfo.setTarget(label);
//Jump语句 将Lable标记一次为true
-
if (opcode == Opcodes.JSR) {
-
throw
new AssertionError(
"Subroutines not supported.");
-
}
-
successor = opcode != Opcodes.GOTO;
-
first =
false;
-
}
-
-
-
//如果当设置它是否是上一条指令的后续指令时,再一次设置它为multiTarget=true,表示至少有2个来源
-
public static void setSuccessor(final Label label) {
-
final LabelInfo info = create(label);
-
info.successor =
true;
-
if (info.target) {
-
info.multiTarget =
true;
-
}
-
}
特殊问题解答
有了前面对源码的分析,再来看一些特殊情况。
问:else块结尾为什么会插入探针?
答:L3的来源有两处,一处是GOTO来的,一处是L1顺序执行来的,使得multiTarget = true条件成立,所以在L3之前插入探针,表现在Java代码中就是在else块结尾增加了探针。
问:为什么case 1条件里第一个Test方法前不插入探针?
答:L1上一条是指GOTO指令,使得successor = false,所以该方法调用前无需插入探针。
探针插桩结论
通过以上分析得出结论,代码块中探针的插入策略:
return和throw之前插入探针。
复杂if语句,为统计分支覆盖情况,会进行反转成if not,再对个分支插入探针。
当前指令是上一条指令的连续,并且当前指令是触发方法调用,则插入探针。
当前指令和上一条指令是连续的,并且是有多个来源的时候,则插入探针。
构建SDK染色包
利用JaCoCo提供的Ant插件,在原有打包脚本上进行修改。
Ant脚本根节点增加JaCoCo声明。
引入jacocoant 自定义task。
在compile task完成之后,运行instrument任务,对原始classes文件进行插桩,生成新的classes文件。
将插桩后的classes打包成jar包,不需要混淆,就完成了染色包的构建。
-
<project name=
"Example" xmlns:jacoco=
"antlib:org.jacoco.ant">
//增加jacoco声明
-
//引入自定义task
-
<taskdef uri="antlib:org.jacoco.ant" resource="org/jacoco/ant/antlib.xml">
-
<classpath path="path_to_jacoco/lib/jacocoant.jar"/>
-
</taskdef>
-
-
-
...
-
//对classes插桩
-
<jacoco:instrument destdir=
"target/classes-instr" depends=
"compile">
-
<fileset dir="target/classes" includes="**/*.class"/>
-
</jacoco:instrument>
-
-
-
</project>
测试工程配置
将生成的染色包放入测试工程lib库中,测试工程build.gradle配置中开启覆盖率统计开关。
官方gradle插件默认自带JaCoCo支持,需要开启开关。
testCoverageEnabled = true //开启代码染色覆盖率统计
收集覆盖率报告的方式有两种,一种是用官方文档里介绍的:配置jacoco-agent.properties文件,放Demo的resources资源目录下。
文件配置生成覆盖率产物的路径,然后测试完Demo,在终止JVM也就是退出应用的时候,会自动将覆盖率数据写入,这种方式不方便对覆盖率文件命名自定义,多轮测试产物不明确。
destfile=/sdcard/jacoco/coverage.ec
另一种方式是利用反射技术:反射调用jacoco.agent.rt.RT类的getExecutionData方法,获取上文中探针的执行数据,将数据写入sdcard中,生成ec文件。这段代码可以在应用合适位置触发,推荐退出之前调用。
-
/**
-
* 生成ec文件
-
*/
-
public static void generateEcFile(boolean isNew, Context context) {
-
File file =
new File(DEFAULT_COVERAGE_FILE_PATH);
-
if(!file.exists()){
-
file.mkdir();
-
}
-
DEFAULT_COVERAGE_FILE = DEFAULT_COVERAGE_FILE_PATH + File.separator+
"coverage-"+getDate()+
".ec";
-
Log.d(TAG,
"生成覆盖率文件: " + DEFAULT_COVERAGE_FILE);
-
OutputStream
out =
null;
-
File mCoverageFilePath =
new File(DEFAULT_COVERAGE_FILE);
-
try {
-
if (!mCoverageFilePath.exists()) {
-
mCoverageFilePath.createNewFile();
-
}
-
out =
new FileOutputStream(mCoverageFilePath.getPath(),
true);
-
-
-
Object agent = Class.forName(
"org.jacoco.agent.rt.RT")
-
.getMethod(
"getAgent")
-
.invoke(
null);
-
-
-
out.write((
byte[]) agent.getClass().getMethod(
"getExecutionData", boolean.class)
-
.invoke(agent,
false));
-
Log.d(TAG,"写入" + DEFAULT_COVERAGE_FILE + "完成!" );
-
Toast.makeText(context,"写入" + DEFAULT_COVERAGE_FILE + "完成!",Toast.LENGTH_SHORT).show();
-
}
catch (Exception e) {
-
Log.e(TAG, "generateEcFile: " + e.getMessage());
-
Log.e(TAG,e.toString());
-
}
finally {
-
if (
out ==
null)
-
return;
-
try {
-
out.close();
-
}
catch (IOException e) {
-
e.printStackTrace();
-
-
-
}
-
}
-
}
覆盖率报告生成
JaCoCo支持将多个ec文件合并,利用Ant脚本即可。
-
<jacoco:merge destfile="merged.exec">
-
<fileset dir="executionData" includes="*.exec"/>
-
</jacoco:merge>
将ec文件从手机导出,配合插桩前的classes文件、源码文件(可选),配置Ant脚本中,就可以生成Html格式的覆盖率报告。
-
<jacoco:report>
-
-
-
<executiondata>
-
<file file="jacoco.exec"/>
-
</executiondata>
-
-
-
<structure name="Example Project">
-
<classfiles>
-
<fileset dir="classes"/>
-
</classfiles>
-
<sourcefiles encoding="UTF-8">
-
<fileset dir="src"/>
-
</sourcefiles>
-
</structure>
-
-
-
<html destdir="report"/>
-
-
-
</jacoco:report>
熟悉Java字节码技术、ASM框架、理解JaCoCo插桩原理,可以有各种手段玩转SDK,例如在不修改源码的情况下,在打包阶段可以动态插入和删除相应代码,完成一些特殊需求。
参考连接
https://www.jacoco.org/jacoco/trunk/doc/index.html
活动预告
云栖大会将于9月17日-18日在线举行,阿里巴巴高德地图携手合作伙伴精心筹备了“智慧出行”专场。我们将为大家分享高德地图在打造基于DT+AI和全面上云架构下的新一代出行生活服务平台过程中的思考和实践,同时邀请了合作伙伴和业内资深专家分享行业动态、理论发展方向以及在业界和学术界的最新应用案例。9月17日13:00线上开播,敬请观看。
转载:https://blog.csdn.net/amap_tech/article/details/108591304