您当前的位置:首页 > 电脑百科 > 程序开发 > 移动端 > Android

Android端代码染色原理及技术实践

时间:2020-09-16 13:02:26  来源:  作者:

导读

高德地图开放平台产品不断迭代,代码逻辑越来越复杂,现有的测试流程不能保证完全覆盖所有业务代码,测试不到的代码及分支,会存在一定的风险。为了保证测试全面覆盖,需要引入代码覆盖率做为测试指标,需要对SDK代码进行染色,测试结束后可生成代码覆盖率报告,作为发版前的一项重要卡点指标。本文小结了Android端代码染色原理及技术实践。

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    probeidICONST_1BASTORE

 

探针插桩策略

探针的插入需要遵循一定策略,大体可分成以下三个策略:

  • 统计方法的执行情况。
  • 统计分支语句的执行情况。
  • 统计普通代码块的执行情况。

方法的执行情况

这个比较容易处理, 在方法头或者方法尾加就可以了。

  • 方法尾加: 能说明方法被执行过, 且说明了探针上面的方法被执行了,但是这种处理比较麻烦, 可能有多个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指令之前插入探针。

官方文档中介绍

Android端代码染色原理及技术实践

 

示例代码

Android端代码染色原理及技术实践

 

有条件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;
    }

 

可以通过测试报告看出来,标记为黄色代表分支执行情况覆盖不完整,标记为绿色代表分支所有条件都执行完整了。

Android端代码染色原理及技术实践

 


Android端代码染色原理及技术实践

 

代码块的执行情况

理论上只要在每行代码前都插入探针即可, 但这样会有性能问题。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块结尾增加了探针。

Android端代码染色原理及技术实践

 

问:为什么case 1条件里第一个Test方法前不插入探针?

答:L1上一条是指GOTO指令,使得successor = false,所以该方法调用前无需插入探针。

Android端代码染色原理及技术实践

 

探针插桩结论

通过以上分析得出结论,代码块中探针的插入策略:

  • 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资源目录下。

Android端代码染色原理及技术实践

 

文件配置生成覆盖率产物的路径,然后测试完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

本文作者:高德技术

本文地址:https://www.cnblogs.com/amap_tech/p/13672746.html



Tags:Android   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
今天面试遇到同学说做过内存优化,于是我一般都会问那 Bitmap 的像素内存存在哪?大多数同学都回答在 java heap 里面,就比较尴尬,理论上你做内存优化,如果连图片这个内存大户内存...【详细内容】
2021-12-23  Tags: Android  点击:(6)  评论:(0)  加入收藏
Android logcat日志封装logcat痛点在Android开发中使用logcat非常频繁,logcat能帮我们定位问题,但是在日常使用中发现每次使用都需要传递tag,并且会遇到输出频率很高的log,在多...【详细内容】
2021-12-22  Tags: Android  点击:(7)  评论:(0)  加入收藏
对项目的基本介绍 1.整个框架主要是给MVVM框架使用的,自己写完interface接口后,通过自定义的注解就能自动生成接口方法 2.用Kotlin的Flow去代替Rxjava,因为我发现RxJava功能很...【详细内容】
2021-12-08  Tags: Android  点击:(16)  评论:(0)  加入收藏
前言在Android开发过程中,有些时候会根据需要引用别的项目到当前项目里面,而且以Module形式引用。所以本篇博文就来分享一下怎么以Module形式引用别的项目到当前项目中,方便开...【详细内容】
2021-12-07  Tags: Android  点击:(21)  评论:(0)  加入收藏
新型Android恶意木马程序伪装成数十款街机、射击和策略游戏,通过华为应用市场AppGallery进行分发,从而窃取设备信息和用户的手机号码,全球目前至少有930万台Android设备被该恶...【详细内容】
2021-12-01  Tags: Android  点击:(24)  评论:(0)  加入收藏
作者:fundroid这篇文章偏阅读一些,大家可以了解下 Android 的一些最新动向。每年9/10月份 Google 都会举行约为期2天的 Android Dev Summit,在活动上 Google 的技术专家们会分...【详细内容】
2021-11-30  Tags: Android  点击:(15)  评论:(0)  加入收藏
一、 准备工作1、安装JDK,下载地址(可能需要一个oracle账号,大家百度一下或者自行注册一个就行。尽可能选择8或者11,这两个是长期版本)Java SE | Oracle Technology Network | Or...【详细内容】
2021-11-23  Tags: Android  点击:(26)  评论:(0)  加入收藏
如果你是一名忠实的Android玩家,那么可能会知道,今年的Android 12系统在版本规划上与“往届”相比可以说是很有些特殊。具体来说,除了前段时间刚刚推出正式版的Android 12外,谷...【详细内容】
2021-11-10  Tags: Android  点击:(23)  评论:(0)  加入收藏
使用Maven Publish Plugin插件。(官方支持)一、在Library的build.gradle中配置plugins { id &#39;com.android.library&#39; id &#39;kotlin-android&#39; id &#39;k...【详细内容】
2021-11-05  Tags: Android  点击:(36)  评论:(0)  加入收藏
今年5月,谷歌推出了Android 12,这是原生安卓系统史上最大的设计变化,10月4日,谷歌推出全新的Android12正式版本,并且宣布会在今年晚些时候应用于安卓设备,对比Android11的挤牙膏式...【详细内容】
2021-10-29  Tags: Android  点击:(125)  评论:(0)  加入收藏
▌简易百科推荐
今天面试遇到同学说做过内存优化,于是我一般都会问那 Bitmap 的像素内存存在哪?大多数同学都回答在 java heap 里面,就比较尴尬,理论上你做内存优化,如果连图片这个内存大户内存...【详细内容】
2021-12-23  像程序那样思考    Tags:Android开发   点击:(6)  评论:(0)  加入收藏
Android logcat日志封装logcat痛点在Android开发中使用logcat非常频繁,logcat能帮我们定位问题,但是在日常使用中发现每次使用都需要传递tag,并且会遇到输出频率很高的log,在多...【详细内容】
2021-12-22  YuCoding    Tags:Android   点击:(7)  评论:(0)  加入收藏
对项目的基本介绍 1.整个框架主要是给MVVM框架使用的,自己写完interface接口后,通过自定义的注解就能自动生成接口方法 2.用Kotlin的Flow去代替Rxjava,因为我发现RxJava功能很...【详细内容】
2021-12-08  网易Leo    Tags:Android开发   点击:(16)  评论:(0)  加入收藏
前言在Android开发过程中,有些时候会根据需要引用别的项目到当前项目里面,而且以Module形式引用。所以本篇博文就来分享一下怎么以Module形式引用别的项目到当前项目中,方便开...【详细内容】
2021-12-07  网易Leo    Tags:Android开发   点击:(21)  评论:(0)  加入收藏
作者:fundroid这篇文章偏阅读一些,大家可以了解下 Android 的一些最新动向。每年9/10月份 Google 都会举行约为期2天的 Android Dev Summit,在活动上 Google 的技术专家们会分...【详细内容】
2021-11-30  像程序那样思考    Tags:Android开发   点击:(15)  评论:(0)  加入收藏
一、 准备工作1、安装JDK,下载地址(可能需要一个oracle账号,大家百度一下或者自行注册一个就行。尽可能选择8或者11,这两个是长期版本)Java SE | Oracle Technology Network | Or...【详细内容】
2021-11-23  永沧    Tags:Android   点击:(26)  评论:(0)  加入收藏
使用Maven Publish Plugin插件。(官方支持)一、在Library的build.gradle中配置plugins { id &#39;com.android.library&#39; id &#39;kotlin-android&#39; id &#39;k...【详细内容】
2021-11-05  羊城小阳    Tags:Android   点击:(36)  评论:(0)  加入收藏
谷歌离推出Play Store应用程序的新数据隐私部分又近了一步。应用程序开发人员现在可以通过谷歌在Play控制台的新 "数据安全表 "填写相关细节。该公司表示,所需信息将从2022年...【详细内容】
2021-10-20    中关村在线  Tags:安卓   点击:(57)  评论:(0)  加入收藏
架构究竟是什么?如何更好的理解架构?我们知道一个APP通常是由class组成,而这些class之间如何组合,相互之间又如何产生作用,就是影响这个APP的关键点。细分的话我们可以将其分为类...【详细内容】
2021-09-17  像程序那样思考    Tags:Android架构   点击:(51)  评论:(0)  加入收藏
概述当Android应用程序需要访问设备上的敏感资源时,应用程序开发人员会使用权限模型。虽然该模型使用起来非常简单,但开发人员在使用权限时容易出错,从而导致安全漏洞。本文中,...【详细内容】
2021-09-07  SecTr安全团队    Tags:Android开发   点击:(66)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条