【ArchSummit】如何通过AIOps推动可量化的业务价值增长和效率提升?>>> 了解详情
写点什么

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

  • 2020-09-25
  • 本文字数:6976 字

    阅读完需:约 23 分钟

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

导读

高德地图开放平台产品不断迭代,代码逻辑越来越复杂,现有的测试流程不能保证完全覆盖所有业务代码,测试不到的代码及分支,会存在一定的风险。为了保证测试全面覆盖,需要引入代码覆盖率做为测试指标,需要对 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    probearrayxPUSH    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 指令之前插入探针。


官方文档中介绍



示例代码



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


本文转载自公众号高德技术(ID:amap_tech)。


原文链接


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


2020-09-25 10:102157

评论

发布
暂无评论
发现更多内容

Predicate

急需上岸的小谢

9月月更

Linkerd 流量拆分方案

CTO技术共享

Go 言 Go 语,一文看懂 Go 语言文件操作

梦想橡皮擦

Python Go 9月月更

Web3的流支付代表Zebec,熊市布局的价值逻辑

股市老人

Web3大行其道,为何说Zebec值得投资人期待?

EOSdreamer111

室友只用了一把王者的时间就入门了「C语言」

Albert Edison

c++ C语言 函数 循环 9月月更

数据中心网络架构的需求原则及策略

阿泽🧸

数据中心 9月月更

Web3大行其道,为何说Zebec值得投资人期待?

BlockChain先知

云原生(三十三) | Kubernetes篇之平台存储系统部署

Lansonli

云原生 9月月更

亿级别搜索系统架构与总结

goodrain

Java elasticsearch

【CSS·清除浮动】Clearing floats:clear、clearfix、overflow、flow-root

翼同学

CSS 前端 9月月更

跟着卷卷龙一起学Camera--黑电平Blacklevel

卷卷龙

ISP camera 9月月更

Containerlab + Kind 部署 Cilium BGP

CTO技术共享

JPEX如期推出VISA借记卡,预计第四季度发放实体卡

股市老人

玖章算术受邀参加红杉Talk「创新的复利」科技专场,共同探讨云计算的前世今生

数据库 数据复制 数据管理 数据备份 玖章算术

高级产品经理必备素养

产品海豚湾

产品经理 职业发展 商业化 9月月更 产品价值

【CSS】:正常布局流(normal flow)、有趣的浮动(float)

翼同学

CSS 前端 9月月更

以数字技术赋能产业金融生态能力建设,破解银行的场景焦虑

易观分析

金融 银行 数字科技

NodeJs小试牛刀--聊天室搭建

zxhtom

9月月更

Python 教程之数据分析(4)—— 使用 Python 进行数据分析和可视化 | 第 1 套

海拥(haiyong.site)

Python pandas 9月月更

[Go WebSocket] 你的第一个Go WebSocket服务: echo server

HullQin

Go golang 后端 websocket 9月月更

leetcode 206. Reverse Linked List 反转链表(简单)

okokabcd

LeetCode 数据结构与算法

C++学习------cassert头文件的作用与源码学习

桑榆

c++ 9月月更

实践分享!GitLab CI/CD 快速入门

北京好雨科技有限公司

企业号九月金秋榜 Kuberetes

如何增强 ABAP 系统,允许开发人员给 ABAP 对象设置标签,方便快速检索

Jerry Wang

SAP abap Netweaver 标签体系 9月月更

LeetCode最长快乐字符串使用JavaScript解题

大师兄

JavaScript 面试 算法 前端 9月月更

【CSS·显示类型】 block、inline以及inline-block

翼同学

CSS 前端 9月月更

LeetCode两数之和的两种JavaScript解题方法比较|前端学算法

大师兄

JavaScript 算法 9月月更

HAVE FUN | SOFA 飞船——Layotto 星球登陆计划

SOFAStack

#开源

2022-09-01:字符串的 波动 定义为子字符串中出现次数 最多 的字符次数与出现次数 最少 的字符次数之差。 给你一个字符串 s ,它只包含小写英文字母。请你返回 s 里所有 子字符串的 最大波

福大大架构师每日一题

算法 rust语言 福大大

华为云快成长GaussDB(for Redis)

IT资讯搬运工

Android端代码染色原理及技术实践_大前端_高德技术_InfoQ精选文章