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

2020 年 9 月 25 日

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 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

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

原文链接

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

2020 年 9 月 25 日 10:10 871

评论

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

浅谈行业软件

孙苏勇

软件 思考 转型

程序员陪娃漫画系列——吃饭

孙苏勇

程序员 生活 程序员人生 陪伴 漫画

如何避免把中台变成外包团队

松花皮蛋me

数据中台

“IPO上市扒层皮”,以阿里巴巴为例看看公开了什么 | 如何读IPO招股书(3-a)

赵新龙

阿里巴巴 IPO 招股说明书

媒体的经营 02 | 媒体/内容行业的主要变现方式

邓瑞恒Ryan

创业 投资 商业

对开发人员有用的定律、理论、原则和模式

松花皮蛋me

Java 设计模式

人生一大误区:做到80%就不错了

池建强

个人成长 自我管理

死磕Java并发(5):线程详解,Java开发这么久,这些线程的基础知识你确定都会了?

七哥爱编程

Java Java并发 线程

哪儿有真实靠谱的数据,说谎话必须负责的那种?| IPO招股说明书(1)

赵新龙

阿里巴巴 IPO 旷视科技 数据

Nginx学习

陈雷雷

nginx nginx编译 安装 PHP-FPM 和 Nginx

判断链表是否有环

Kenn

算法 链表 双指针 Brent

JCJC错别字检测JS接口新增CORS跨域支持

田春峰-JCJC错别字检测

怎么写出bug的

三爻

不知不觉,写了10000字了

小天同学

写作 个人感想 思辨

演讲的秘诀

伯薇

个人成长 演讲 追求极致 完美主义

媒体的经营 03 | 很显然,媒体卖广告是最没有前途的

邓瑞恒Ryan

创业 媒体 商业模式

OpenCV 在 Android 上的应用

fengzhizi715

android OpenCV 计算机视觉

如何读IPO招股说明书(2)到哪儿下载招股书?

赵新龙

IPO 上市 招股说明书

二叉树的先序中序后序递归实现

Kenn

算法 递归

曾国藩的人生“六戒”

泰稳@极客邦科技

身心健康 个人成长 心理学

二叉树先序中序后序的非递归实现

Kenn

算法

迷茫时,想想能为这个世界做些什么就好了

泰稳@极客邦科技

身心健康 个人成长 团队协作

我为什么不愿在公众号发文章,却愿在写作平台发

小天同学

微信公众平台 产品 反馈 写作平台

专家的直觉和你的直觉

池建强

书摘 直觉

“IPO上市扒层皮”,以阿里巴巴为例看看公开了什么 | 如何读IPO招股书(3-b)

赵新龙

阿里巴巴 IPO 招股说明书

您到底要说什么?

水色

回"疫"录(5):不见面,云拜年

小天同学

疫情 回忆录 现实纪录 纪实

小技巧:ssh -D 让终端访问或下载快一点

LinkPwd

Linux Shell

npm version 使用详解

Leo

前端 npm 语义化 版本控制

我们是时候降低对完全自动驾驶的期望了

赵钰莹

自动驾驶 AI

说说疫情下的新常态该怎么应对

CD826

疫情 新常态

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