资讯详情

Android 代码覆盖率如何实现

背景

许多团队通过测试这个过程作为高质量在线代码的最后一个关卡,因此确保测试过程没有问题是非常重要的。

因此,为了提高代码质量,通常有以下方案:

  • 通过单测,来cover部分代码逻辑的边界
  • 通过代码覆盖率,测试团队的黑盒测试可以覆盖大部分分支
  • 通过自动化测试,将部分人工验证场景交给机器验证

当然,即使上述方案已经完成,也不能保证在线没有问题,但在很大程度上减少在线问题的场景,收入相对较大。

本文会针对代码覆盖率这一场景进行分析。在android在使用代码覆盖率时,使用更多jacoco。在android内置了整个工具链jacoco我们可以在不引入其他库的情况下使用它jacoco。

jacoco使用

在Android中使用jacoco代码覆盖率相对简单,可以按照以下步骤打开并显示结果。

开启jacoco插件

1plugins { 2    id 'com.android.application' 3    id 'kotlin-android' 4    id 'jacoco' 5} 6 7jacoco { 8    toolVersion = "0.8.5" 9} 

jacoco在androidStudio已内置androidStudio可直接打开jacoco插件。

可以通过jacoco的Extension指定的设置jacoco版本。

打开打包插桩开关

 1    buildTypes {  2        release {  3            minifyEnabled false  4            testCoverageEnabled = true   5            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'  6        }  7        debug {  8            testCoverageEnabled = true  9        } 10    } 

需要在BuildTypes对于不同的包装类型,通过 testCoverageEnabled 打开代码插桩。

保存代码覆盖结果

编译过程中的代码插桩将在运行过程中实时记录代码运行情况。我们需要在我们定制的时间保留代码覆盖结果。支持相同的文件。

 1object JacocoUtil {  2    val ecFile = File(Environment.getExternalStorageDirectory(),  "/coverage.ec")  3    fun generateEcFile() {  4  5        val agent = Class.forName("org.jacoco.agent.rt.RT")  6            .getMethod("getAgent")  7            .invoke(null)  8        writeBytes2File(ecFile.absolutePath, agent.javaClass.getMethod("getExecutionData",  9            Boolean::class.javaPrimitiveType).invoke(agent, false) as ByteArray) 10    } 11 } 

通过反射获得jacoco的Agent实例,再通过反射获得出来代码覆盖结果的字节流,写入到文件中。

1public byte[] getExecutionData(final boolean reset) { 2        final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); 3        try { 4            final ExecutionDataWriter writer = new ExecutionDataWriter(buffer); 5            data.collect(writer, writer, reset); 6        } catch (final IOException e) {} 7        return buffer.toByteArray(); 8    } 

从手机中dump指定覆盖率文件放在指定文件夹中 1adb pull sdcard/coverage.ec xxx/MyTest/app/build/ecf 分析代码覆盖率报告 在gradle在文件中,增加处理任务。

 1task jacocoTestReport(type: JacocoReport) {  2    group = "JacocoReport"  3    description = "Generate Jacoco coverage reports after running tests."  4    reports {  5        html.enabled = true  6    }  7    classDirectories.from = files(files(coverageClassDirs).files.collect {  8        fileTree(dir: "$rootDir"   it)  9    }) 10    sourceDirectories.from = files(coverageSourceDirs) 11    executionData.from = files("$buildDir/ecf/coverage.ec") 12 13    doFirst { 14        coverageClassDirs.each { filePath -> 15            println("$rootDir"   filePath) 16            new File("$rootDir"   filePath).eachFileRecurse { file -> 17                if (file.name.contains('$$')) { 18                    file.renameTo(file.path.replace('$$', '$')) 19                } 20            } 21        } 22    } 23} 

查看报告

在执行中生成报告Task之后,可以看到build多生成一个目录reports文件夹,生成的报告就在里面。

在这里插入图片描述

检查相应的覆盖结果如下:

各类覆盖细节:

列表展示 单类覆盖细节:

jacoco原理

看完了jacoco让我们来看看简单的使用。jacoco的实现

jacoco整体是使用maven构建的方式。maven构造的方法是java一般的施工方法。

maven构建相对于android中gradle建筑差异还是比较大的。主要区别在于建筑配置

Gradle基于groovy语言和DSL语法提供了简单、灵活、可读的配置模式

maven使用xml配置文件格式比较繁琐

gradle扩展任何语言的构建,maven不行。

gradle支持增量建设

gradle支持缓存建设

gradle支持守护过程

所以gradle的构建性能要优于maven。

插件开发

阅读代码插桩入口时,会在类上方打一个特定的注释Mojo

1@Mojo(name = "instrument", defaultPhase = LifecyclePhase.PROCESS_CLASSES, threadSafe = true) 2public class InstrumentMojo extends AbstractJacocoMojo {} 

Maven plain Old Java Object ,mojo是基于maven注释插件开发。mojo对象是执行目标。

类似于gradle中的gradle-plugin。一个mojo对应一个java类,在整体jar配置的插件将在包编译时执行。

代码插入

jacoco记录代码覆盖率完全取决于原始代码的插桩,需要在原始代码中插入探针,通过记录探针执行来计算代码覆盖。

插入代码有两套实现

动态方式:在Jvm加载class过程中,动态的去修改class

离线模式:在编译class的阶段,对原始class进行修改,生成类已经带有全量的插入代码

动态方式之javaAgent

利用JVM提供的 InstrumentAPI 来更改加载的到JVM的现有字节码。

JavaAgent也有两种方式修改字节码

静态修改:在加载jar包之前修改字节码。静态加载会调用到premain方法。

java -javaagent:agent.jar -jar xxx.jar

比如jacoco就使用的静态修改的方式。可以查看其PreMain的premain方法

1public static void premain(final String options, final Instrumentation inst)
2        throws Exception {
final AgentOptions agentOptions = new AgentOptions(options);
3
4final Agent agent = Agent.getInstance(agentOptions);
5final IRuntime runtime = createRuntime(inst);
6runtime.startup(agent.getData());
7inst.addTransformer(new CoverageTransformer(runtime, agentOptions,
8        IExceptionLogger.SYSTEM_ERR));
9
}

动态修改: 将javaAgent加载到已经运行的JVM中的过程称为动态加载。需要使用Java Attach API。

1public class XXX {
2public static void agentmain(String agentArgs, Instrumentation inst) {
3       // can use inst to add args
4}
}

离线模式

Jacoco的离线模式,是在编译阶段,通过ASM修改原始字节码。

本次会针对jacoco的离线模式进行分析,jacoco在android的使用场景上无法使用动态插入方式。

因为Android运行加载的本质上是Dex文件,已经不是jar包了,指令上和jar包不一样,javaAgent无法识别dex文件结构,所以在Android上只能使用离线模式。

原理

jacoco是如何实现的代码覆盖率的统计的呢?

简单来说关键逻辑可以分为三块:

插桩逻辑:通过ASM做静态代码插桩,提前给期望覆盖的类都添加上代码探针

覆盖率统计:运行时,把对应的类执行过的探针记录存储,存储在内存中,接入方在自己期望的时机进行探针记录的本地存储

报告计算:从本地导出探针记录,进行记录合并,将记录转为期望的结果显示,比如html等。

插桩逻辑

先看一个简单的例子

1class TestForJacocoData {
2    fun testMethod(result: Boolean) {
3        if (result) {
4            System.out.println("分支1")
5            return
6        }
7        System.out.println("分支2")
8    }
9}
经过jacoco插桩之后的代码逻辑为

 1public final class TestForJacocoData {
 2    private static transient /* synthetic */ boolean[] $jacocoData;
 3
 4    private static /* synthetic */ boolean[] $jacocoInit() {
 5        boolean[] zArr = $jacocoData;
 6        if (zArr != null) {
 7            return zArr;
 8        }
 9        boolean[] probes = Offline.getProbes(-1643518976017980468L, "com/xx/zz/TestForJacocoData", 18);
10        $jacocoData = probes;
11        return probes;
12    }
13
14    public TestForJacocoData() {
15        $jacocoInit()[4] = true;
16    }
17
18    public final void testMethod(boolean z) {
19        boolean[] $jacocoInit = $jacocoInit();
20        if (z) {
21            $jacocoInit[0] = true;
22            System.out.println("分支1");
23            $jacocoInit[1] = true;
24            return;
25        }
26        $jacocoInit[2] = true;
27        System.out.println("分支2");
28        $jacocoInit[3] = true;
29    }
30}

通过这个样例代码在jacoco插桩前后的对比,可以发现jacoco的代码插桩会做下面几个操作:

这个成员用来记录当次进程启动之后,该类的代码分支执行情况。当对应的分支执行之后,就会给对应的数组元素赋值为true。

jacocoInit()的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员jacocoInit()的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员jacocoInit()的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员jacocoData赋初始值

对于每一个方法,针对各类型的指令,会插入代码探针。所谓的探针就是$jacocoInit[i] = true;语句,一旦执行到,就把当前位置的探针index设置为true,表示已经执行过了。

探针插入的关键逻辑如下所示:

 1    public void insertProbe(final int id) {
 2        mv.visitVarInsn(Opcodes.ALOAD, variable);
 3        InstrSupport.push(mv, id);
 4        mv.visitInsn(Opcodes.ICONST_1);
 5        mv.visitInsn(Opcodes.BASTORE);
 6    }
 7
 8  // InstrSupport.push
 9    public static void push(final MethodVisitor mv, final int value) {
10        if (value >= -1 && value <= 5) {
11            mv.visitInsn(Opcodes.ICONST_0 + value);
12        } else if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) {
13            mv.visitIntInsn(Opcodes.BIPUSH, value);
14        } else if (value >= Short.MIN_VALUE && value <= Short.MAX_VALUE) {
15            mv.visitIntInsn(Opcodes.SIPUSH, value);
16        } else {
17            mv.visitLdcInsn(Integer.valueOf(value));
18        }
19    }

整体的探针插入的代码逻辑比较简单,主要关注下面两点:

在ASM中如何对数组元素的赋值,需要给操作数栈依次放入数组对象引用, 需要放入index位置以及具体要放入的值,通过BASTORE指令,把栈顶的boolean数组存入数组指定的索引位置。

对于不同大小的Int值处理的指令不一样。

int值-15,使用ICONST_(05)

1            Push the int constant <i> (-1, 0, 1, 2, 3, 4 or 5) onto the operand stack 

-1到5,直接将常量push到操作数据栈中。

1* int值在-128~127,使用BIPUSH
2
3            The immediate byte is sign-extended to an int value. That value is pushed onto the operand stack。 

在字节码层面上会使用一个byte字节去实现。一个byte有8bit,第一个bit表示符号,后面7位表示具体大小,所以区间范围是-27~(27 -1)

1* int值在-32768~32767,使用SIPUSH
2
3            The immediate unsigned byte1 and byte2 values are assembled into an intermediate short where the value of the short is (byte1 << 8) | byte2. The intermediate value is then sign-extended to an int, and the resulting value is pushed onto the operand stack.

在字节码层面上会使用两个byte字节去实现。第一个bit表示符号,后面15bit表示具体的数值。所以区间范围是

-215~(215 -1)

1* 其他Int区间,使用的Ldc指令

jacoco的插入规则是比较重要,如何能够尽可能的覆盖全每一个分支?可以看下具体的插桩代码。

关键的代码插入逻辑在MethodProbesAdapter中。

 1public final class MethodProbesAdapter extends MethodVisitor {
 2    @Override
 3    public void visitLabel(final Label label) {
 4        if (LabelInfo.needsProbe(label)) {
 5            probesVisitor.visitProbe(idGenerator.nextId());
 6        }
 7    }
 8  @Override
 9    public void visitInsn(final int opcode) {
10        switch (opcode) {
11        case Opcodes.IRETURN:
12        case Opcodes.LRETURN:
13        case Opcodes.FRETURN:
14        case Opcodes.DRETURN:
15        case Opcodes.ARETURN:
16        case Opcodes.RETURN:
17        case Opcodes.ATHROW:
18            probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
19            break;
20        default:
21            probesVisitor.visitInsn(opcode);
22            break;
23        }
24    }
25  @Override
26    public void visitJumpInsn(final int opcode, final Label label) {
27        if (LabelInfo.isMultiTarget(label)) {
28            probesVisitor.visitJumpInsnWithProbe(opcode, label,
29                    idGenerator.nextId(), frame(jumpPopCount(opcode)));
30        }
31    ....
32    }
33}
34    @Override
35    public void visitLookupSwitchInsn(final Label dflt, final int[] keys,
36            final Label[] labels) {
37        if (markLabels(dflt, labels)) {
38            probesVisitor.visitLookupSwitchInsnWithProbes(dflt, keys, labels,
39                    frame(1));
40        }
41    ...
42    }
43
44    @Override
45    public void visitTableSwitchInsn(final int min, final int max,
46            final Label dflt, final Label... labels) {
47        if (markLabels(dflt, labels)) {
48            probesVisitor.visitTableSwitchInsnWithProbes(min, max, dflt, labels,    frame(1));
49        }
50    ...
51    }

在这个MethodProbesAdapter中有以下几个时机会插入探针。

visitLabel:在字节码访问Label时调用

visitInsn:在访问各个指令时会调用

visitJumpInsn:在跳转指令时调用

visitLookupSwitchInsn&visitTableSwitchInsn:在switch-case语句中会调用

详细分析下这几个时机

visitLabel

在visitLabel方法中,会调用visitProbe方法,进行探针插入。在字节码层面上,Label的含义可以先看看ASM文档中的介绍:

1A position in the bytecode of a method. Labels are used for jump, goto, and switch instructions, and for try catch blocks. A label designates the instruction that is just after. Note however that there can be other elements between a label and the instruction it designates (such as other labels, stack map frames, line numbers, etc.).

Label表示字节码在方法中的位置

Label通常使用在跳转、goto、switch指令和try-catch块

Label可以指定下一条需要执行的指令。不过Label和其跳转的指令中间可能存在其他指令

在字节码层面上,指令默认是顺序执行的,假如没有label的支持,就无法实现跳转。

一个Label至少包含一条字节码指令。也就是说一个Label定义之后,后面的指令就是这个Label对象所对应的指令。

在VisitLabel中,并不是所有的Label访问都会插入探针,只有满足下面几个场景才会做探针的插入操作。

1    public static boolean needsProbe(final Label label) {
2        final LabelInfo info = get(label);
3        return info != null && info.successor
4                && (info.multiTarget || info.methodInvocationLine);
5    }

表示指令的连续性,当前Label是相对于上一条指令是否是连续的,如果上一条指令是goto、jump指令,那么当前Label对于上条指令就不是连续的。

在ASM阶段中,通过每一条指令访问时修改successor的值来记录是否是连续的。

表示当前是否这个label是否有多个跳转来源,在一个方法调用中,可能存在多处指令会跳转到当前这个Label。

对于multiTarget的设置可以看下面的代码:

1    public static void setTarget(final Label label) {
2        final LabelInfo info = create(label);
3        if (info.target || info.successor) {
4            info.multiTarget = true;
5        } else {
6            info.target = true;
7        }
8    }

如果这个label首次访问,那么target设置为true。

如果这个label再次访问时,即target为true,此时设置multiTarget为true。

如果当前这个探针的跳转是单来源,在显示结果上,这个Label会直接跟着前面的探针是否执行展示,如果多个地方都可能跳转到当前Label,就意味着其他两个分支到这个分支之间中间会有断层,不是连续的,没有办法通过之前的探针是否执行表明当前Label是否能够在结果显示上

 1    @Override
 2    public void visitInvokeDynamicInsn(final String name, final String desc,
 3            final Handle bsm, final Object... bsmArgs) {
 4        successor = true;
 5        first = false;
 6        markMethodInvocationLine();
 7    }
 8
 9    private void markMethodInvocationLine() {
10        if (lineStart != null) {
11            LabelInfo.setMethodInvocationLine(lineStart);
12        }
13    }

表示当前是调用Label表示调用一个方法。在方法调用场景下,会在方法调用前插入探针。

 1  @Override
 2    public void visitInsn(final int opcode) {
 3        switch (opcode) {
 4        case Opcodes.IRETURN:
 5        case Opcodes.LRETURN:
 6        case Opcodes.FRETURN:
 7        case Opcodes.DRETURN:
 8        case Opcodes.ARETURN:
 9        case Opcodes.RETURN:
10        case Opcodes.ATHROW:
11            probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
12            break;
13        default:
14            probesVisitor.visitInsn(opcode);
15            break;
16        }
17    }
18
19====》
20
21  @Override
22    public void visitInsnWithProbe(final int opcode, final int probeId) {
23        probeInserter.insertProbe(probeId);
24        mv.visitInsn(opcode);
25    }

在识别到return语句时,会在return语句前插入探针。在return语句前加入探针,当这个探针执行了,就表示当前这个分支执行结束了。

主要表示跳转指令。

1Visits a jump instruction. A jump instruction is an instruction that may jump to another instruction.
2Params:
3opcode – the opcode of the type instruction to be visited. This opcode is either IFEQ, IFNE, IFLT, IFGE, IFGT, IFLE, IF_ICMPEQ, IF_ICMPNE, IF_ICMPLT, IF_ICMPGE, IF_ICMPGT, IF_ICMPLE, IF_ACMPEQ, IF_ACMPNE, GOTO, JSR, IFNULL or IFNONNULL.
4label – the operand of the instruction to be visited. This operand is a label that designates the instruction to which the jump instruction may jump.

主要有列的几个指令:IFEQ, IFNE, IFLT, IFGE, IFGT, IFLE, IF_ICMPEQ, IF_ICMPNE, IF_ICMPLT, IF_ICMPGE, IF_ICMPGT, IF_ICMPLE, IF_ACMPEQ, IF_ACMPNE, GOTO, JSR, IFNULL or IFNONNULL。


1  @Override
2    public void visitJumpInsn(final int opcode, final Label label) {
3        if (LabelInfo.isMultiTarget(label)) {
4            probesVisitor.visitJumpInsnWithProbe(opcode, label,
5                    idGenerator.nextId(), frame(jumpPopCount(opcode)));
6        }
7    ....
8    }
9}

并不是所有的跳转指令都会插入探针,也会判断跳转的目标label是否有个来源。

 1    @Override
 2    public void visitJumpInsnWithProbe(final int opcode, final Label label,
 3            final int probeId, final IFrame frame) {
 4        if (opcode == Opcodes.GOTO) {
 5            probeInserter.insertProbe(probeId);
 6            mv.visitJumpInsn(Opcodes.GOTO, label);
 7        } else {
 8            final Label intermediate = new Label();
 9            mv.visitJumpInsn(getInverted(opcode), intermediate);
10            probeInserter.insertProbe(probeId);
11            mv.visitJumpInsn(Opcodes.GOTO, label);
12            mv.visitLabel(intermediate);
13            frame.accept(mv);
14        }
15    }

对于goto指令,探针需要添加到跳转指令之前

对于其他跳转指令,比如IF,会做一层转换,把IFEQ转换为IFNE,同时添加GOTO语句

 1private int getInverted(final int opcode) {
 2    switch (opcode) {
 3    case Opcodes.IFEQ:
 4        return Opcodes.IFNE;
 5    case Opcodes.IFNE:
 6        return Opcodes.IFEQ;
 7    case Opcodes.IFLT:
 8        return Opcodes.IFGE;
 9    case Opcodes.IFGE:
10        return Opcodes.IFLT;
11    case Opcodes.IFGT:
12        return Opcodes.IFLE;
13    case Opcodes.IFLE:
14        return Opcodes.IFGT;
15    case Opcodes.IF_ICMPEQ:
16        return Opcodes.IF_ICMPNE;
17    case Opcodes.IF_ICMPNE:
18        return Opcodes.IF_ICMPEQ;
19    case Opcodes.IF_ICMPLT:
20        return Opcodes.IF_ICMPGE;
21    case Opcodes.IF_ICMPGE:
22        return Opcodes.IF_ICMPLT;
23    case Opcodes.IF_ICMPGT:
24        return Opcodes.IF_ICMPLE;
25    case Opcodes.IF_ICMPLE:
26        return Opcodes.IF_ICMPGT;
27    case Opcodes.IF_ACMPEQ:
28        return Opcodes.IF_ACMPNE;
29    case Opcodes.IF_ACMPNE:
30        return Opcodes.IF_ACMPEQ;
31    case Opcodes.IFNULL:
32        return Opcodes.IFNONNULL;
33    case Opcodes.IFNONNULL:
34        return Opcodes.IFNULL;
35    }
36    throw new IllegalArgumentException();
37}

比如下面的例子:

1    class TestForJacocoData {
2    fun testMethod(result: Int) {
3        if (result == 1) {
4            defineA()
5        }
6    }
7    fun defineA() { val a = 1 }
8}

编译后:

 1    public final void testMethod(int result) {
 2        boolean[] $jacocoInit = $jacocoInit();
 3        if (result != 1) {
 4            $jacocoInit[1] = true;
 5        } else {
 6            $jacocoInit[2] = true;
 7            defineA();
 8            $jacocoInit[3] = true;
 9        }
10        $jacocoInit[4] = true;
11    }

比如在一些较为复杂的 if 语句中,会把复杂的判断的语句拆分成单一条件,并进行反转,这样能够保证能够覆盖全所有的分支,并且在反转操作后,可以更好的配合GOTO语句插入探针。

switch-case分支

switch-case对应下面两个字节码:

tableSwitch

查找效率为O(1),通过偏移量就可以找到对应的case。

比如下面的例子:

 1    class TestForJacocoData {
 2    fun testSwitch() {
 3        val value = 1;
 4        when(value) {
 5            0 -> System.out.println(0)
 6            2 ->  System.out.println(2)
 7            5 ->  System.out.println(5)
 8            else -> System.out.println(6)
 9        }
10    }
11}

编译后的字节码为

 1    public final void testSwitch();
 2    descriptor: ()V
 3    flags: ACC_PUBLIC, ACC_FINAL
 4    Code:
 5      stack=2, locals=2, args_size=1
 6         0: iconst_1
 7         1: istore_1
 8         2: iload_1
 9         3: tableswitch   { // 0 to 5
10                       0: 40
11                       1: 70
12                       2: 50
13                       3: 70
14                       4: 70
15                       5: 60
16                 default: 70
17            }
18}

可以看到这里使用的tableswitch指令,并且原本我们的case语句只有0、2、5,系统自动给我们补齐成了0,1,2,3,4,5,让其成为了顺序的table,可以直接通过游标直接访问。时间复杂度最终才能O(1),

lookupSwitch

查找效率为O(lgn),通过二分查找寻找对应的value值。

比如下面的例子:

 1    class TestForJacocoData {
 2    fun testSwitch() {
 3        val value = 2;
 4        when(value) {
 5            0 -> System.out.println(0)
 6            1000 -> System.out.println(2)
 7            else -> {
 8                System.out.println(6)
 9            }
10        }
11    }
12}
对应的字节码为:

 1    public final void testSwitch();
 2    descriptor: ()V
 3    flags: ACC_PUBLIC, ACC_FINAL
 4    Code:
 5      stack=2, locals=2, args_size=1
 6         0: iconst_2
 7         1: istore_1
 8         2: iload_1
 9         3: lookupswitch  { // 2
10                       0: 28
11                    1000: 38
12                 default: 48
13            }
14        28: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;

可以看到这里使用的lookupswitch指令。字节码层面会根据case语句value值的稀疏,选择tableswitch指令还是lookupswitch指令。

对应的在ASM层看到的调用指令是下面两个:

 1    @Override
 2    public void visitLookupSwitchInsn(final Label dflt, final int[] keys,
 3            final Label[] labels) {
 4        if (markLabels(dflt, labels)) {
 5            probesVisitor.visitLookupSwitchInsnWithProbes(dflt, keys, labels,
 6                    frame(1));
 7        }
 8    ...
 9    }
10
11    @Override
12    public void visitTableSwitchInsn(final int min, final int max,
13            final Label dflt, final Label... labels) {
14        if (markLabels(dflt, labels)) {
15            probesVisitor.visitTableSwitchInsnWithProbes(min, max, dflt, labels,    frame(1));
16        }
17    ...
18    }

其中,

dflt :默认的处理label

keys: 当前case的value集合

labels:对应的keys的处理label

通过visitLookupSwitchInsnWithProbes和visitTableSwitchInsnWithProbes会给对应的case语句中插入探针。

综上的介绍,我们可以大概整理下jacoco的代码插入逻辑

return语句前、throw语句前会进行探针的插入。

如果label对于上一条指令来说是连续的,并且有多个来源,那么会进行探针插入

如果label对于上一条指令来说是连续的,并且label是一个方法调用,那么会进行探针插入

如果是一个switch语句,对于各个case跳转也会进行探针插入。

运行时处理

在编译期间做了代码插入之后,运行时是如何生效的?以及数据展示是如何来实现的呢?

对于每一个方法调用,在对jacocoData数组元素赋值前,都会先尝试初始化jacocoData数组元素赋值前,都会先尝试初始化jacocoData数组元素赋值前,都会先尝试初始化jacocoData

1  private static  boolean[] $jacocoInit() {
2        boolean[] zArr = $jacocoData;
3        if (zArr != null) {
4            return zArr;
5        }
6        boolean[] probes = Offline.getProbes(-1643518976017980468L, "com/kuaikan/zz/TestForJacocoData", 18);
7        $jacocoData = probes;
8        return probes;
9    }

会先尝试从 Offline 中获取离线探针数据,有三个参数:

第一个参数是class的唯一Id,在插桩时,会根据字节码流创建生成。

1private byte[] instrument(final byte[] source) {
2    final long classId = CRC64.classId(source);
}

第二个参数是class全路径名称

第三个参数是当前class共计有多少个探针。

这三个参数在编译后就已经固定了,不会发生改变,所以在插桩后插入的都是具体的值。

然后呢根据传入的参数,创建出来一个ExecutionData对象并返回

 1    public ExecutionData get(final Long id, final String name,
 2            final int probecount) {
 3        ExecutionData entry = entries.get(id);
 4        if (entry == null) {
 5            entry = new ExecutionData(id.longValue(), name, probecount);
 6            entries.put(id, entry);
 7            names.add(name);
 8        } else {
 9            entry.assertCompatibility(id.longValue(), name, probecount);
10        }
11        return entry;
12    }

可以看到ExecutionData做了内存存储,并没有做本地的文件存储。

所以,当我们想要用jacoco来实现多人协作的覆盖率合并时,就需要自己实现当前覆盖率结果的文件存储。

如下代码所示:

1fun generateEcFile() {
2    FileUtils.createFolderIfNotExists(path)
3  FileUtils.createFileIfNotExists(ecFile)
4  val agent = Class.forName("org.jacoco.agent.rt.RT")
5      .getMethod("getAgent")
6      .invoke(null)
7  IOUtils.writeBytes2File(ecFile, agent.javaClass.getMethod("getExecutionData", 
8      Boolean::class.javaPrimitiveType).invoke(agent, false) as ByteArray)}

通过反射获取出RT实例,拿出当前所有的已执行的结果,并存储到文件中。

覆盖结果

在不看代码的情况下,我们可以先推理下,按照前面插桩和收集的数据,如何展现出实际覆盖率的结果。

首先需要把所有上传的数据进行合并

DumpTask + MergeTask

1* DumpTask:jacoco内置dumpTask,主要的作用是从远端下载收集到的测试覆盖数据。 2 3* MergeTask:因为每次运行的测试覆盖数据都是单独的文件数据,所以需要有一个专门的Task,把众多的文件的测试覆盖数据合并成单个文件。 合成的逻辑比较简单:

 1            private void load(final ExecFileLoader loader) {
 2            final Iterator<?> resourceIterator = files.iterator();
 3            while (resourceIterator.hasNext()) {
 4                final Resource resource = (Resource) resourceIterator.next();
 5                    resourceStream = resource.getInputStream();
 6                    loader.load(resourceStream);
 7        }
 8        }
 9
10        public void load(final InputStream stream) throws IOException {
11            final ExecutionDataReader reader = new ExecutionDataReader(
12                    new BufferedInputStream(stream));
13            reader.setExecutionDataVisitor(executionData);
14            reader.setSessionInfoVisitor(sessionInfos);
15            reader.read();
16        }
17
18    ====》
19        public void visitClassExecution(final ExecutionData data) {
20            put(data);
21        }
22
23      ====》 
24        public void put(final ExecutionData data) throws IllegalStateException {
25            final Long id = Long.valueOf(data.getId());
26            final ExecutionData entry = entries.get(id);
27            if (entry == null) {
28                entries.put(id, data);
29                names.add(data.getName());
30            } else {
31                entry.merge(data);
32            }
33        }

即遍历每一个文件,根据文件内容,调用load,最终merge到同一个hashMap中。

需要把方法覆盖率和对应的className和源代码文件做关联

这个流程是比较重要并且复杂的流程。因为运行时收集到的ExecutionData数据还比较少,仅有唯一ID、名称、和覆盖率结果数组,无法直接应用于结果展示。

1    public final class ExecutionData {
2    private final long id;
3    private final String name;
4    private final boolean[] probes;
5    }
因此,需要有分析的Task,将这个结果和源文件进行关联。分析单个方法主要使用InstructionsBuilder。

1        InstructionsBuilder(final boolean[] probes) {
2        this.probes = probes;
3        this.currentLine = ISourceNode.UNKNOWN_LINE;
4        this.currentInsn = null;
5        this.instructions = new HashMap<AbstractInsnNode, Instruction>();
6        this.currentLabel = new ArrayList<Label>(2);
7        this.jumps = new ArrayList<Jump>();
8    }

根据MethodVisitor的访问顺序,重建探针对应的覆盖的行号、指令等。

 1    @Override
 2    public void visitLabel(final Label label) {
 3        builder.addLabel(label);
 4    }
 5    @Override
 6    public void visitLineNumber(final int line, final Label start) {
 7        builder.setCurrentLine(line);
 8    }
 9    @Override
10    public void visitInsn(final int opcode) {
11        builder.addInstruction(currentNode);
12    }
13    @Override
14    public void visitIntInsn(final int opcode, final int operand) {
15        builder.addInstruction(currentNode);
16    }
17
18    void addProbe(final int probeId, final int branch) {
19        final boolean executed = probes != null && probes[probeId];
20        currentInsn.addBranch(executed, branch);
21    }

最关键的还是在插桩时标记需要插入探针的地方,都访问一次addProbe(final int probeId, final int branch)方法,这样可以重新从探针数组中获取当前指令是否覆盖到。

根据展示的样式,转化为html等其他格式。

将merge的结果,展示成对应的文件样式,比如html等。

关键逻辑是是否覆盖的展示逻辑,如下所示:

 1    HTMLElement highlight(final HTMLElement pre, final ILine line,
 2            final int lineNr) throws IOException {
 3        final String style;
 4        switch (line.getStatus()) {
 5        case ICounter.NOT_COVERED:
 6            style = Styles.NOT_COVERED;
 7            break;
 8        case ICounter.FULLY_COVERED:
 9            style = Styles.FULLY_COVERED;
10            break;
11        case ICounter.PARTLY_COVERED:
12            style = Styles.PARTLY_COVERED;
13            break;
14        default:
15            ret
16        }

这里的line就是我们前面通过InstructionsBuilder分析出来的结果,根据不同的结果,展示不同的色值。

在现有的jacoco的能力基础上快速实现增量,目前来说,有比较多的方式

在插桩过程中做增量,在非增量文件中,不进行插桩

在结果merge的过程中,进行增量逻辑处理,过滤扫描出来的增量代码段。

在生成的结果文件中,过滤出来增量结果并展示

第一种方案较优,仅对需要增量的代码进行插桩,可以降低整体的编译耗时。

第二种、第三种方案较简单,仅需要针对结果集层面做处理,不需要care较为复杂的插桩逻辑。

增量代码获取

比较通用的方案是通过git diff可以计算两个分支间的增量代码。不过这种方案有缺陷

在某些场景下,diff过大的情况下,查询不出来结果。在一些改动较大的业务下,会导致整个增量方案失效。

基于git diff实现的,需要自己实现一套解析器,针对git diff的结果,解析出来增量数据集,较为复杂。

因此,我们基于目前jacoco的代码插桩,在全量时,直接利用jacoco对于每个类、各个方法的插桩记录做了记录,然后和分支关联,并且上传作为备份。在每一次代码编译时,拉下来对应分支上传备份文件,计算增量。然后在插桩过程中,过滤掉对应非增量的class和method来实现增量。

标签: 二极管dflt48a

锐单商城拥有海量元器件数据手册IC替代型号,打造 电子元器件IC百科大全!

锐单商城 - 一站式电子元器件采购平台