写点什么

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

评论

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

揭秘字节跳动内部流量调度与容灾实践【下】

火山引擎边缘云

流量 DNS 调度 调度任务 流量路由

Python语言——万数据插入测试

KaiwuDB

数据库

成功的DApp经济模型中治理代币与功能代币的协同机制解析

区块链软件开发推广运营

交易所开发 dapp开发 链游开发 公链开发 公链开发代币开发

中烟创新为千行百业客户免费部署DeepSeek,让AI应用遍地开花

中烟创新

等保测评常见的安全产品

等保测评

Wolfram Mathematica for Mac(高级数学及符号运算软件)

晨光熹微

ColorWell for Mac(调色板软件)

晨光熹微

官宣|矩阵起源官网「体验中心」正式上线,文档解析能力开放试用!

MatrixOrigin

ICML 2025 高分论文!快手&南开提出:模块化双工注意力机制,显著提升多模态大模型情感理解能力!

快手技术

多模态 快手 情感理解

TiDB 社区第四届专栏征文大赛联合墨天轮火热开启,TiDB 业务场景实战、运维开发攻略两大赛道,BOSE 降噪耳机、大疆手持云台、投影仪、运动手环、礼品卡等重磅礼品等你来拿!

TiDB 社区干货传送门

社区活动

数据智能化管理技术创新|PingCAP 合作论文入选 SIGMOD 2025,引领下一代数据基础设施发展方向

TiDB 社区干货传送门

中烟创新全系产品接入DeepSeek,数智化应用场景进入深度推理时代

中烟创新

Try Hack Me SOAR 实战演练:安全编排、自动化与响应技术解析

qife122

安全运维 蓝队

AI赋能灯塔低代码平台,AI应用落地“加速器”

中烟创新

夏日,户外LED显示屏如何降温?

Dylan

广告 LED LED display 户外LED显示屏 LED屏幕

在AI技术唾手可得的时代,挖掘真实需求才是核心竞争力

qife122

开发者工具 AI助手

ISO 50004 标准深度解读:赋能企业能源管理升级的实用指南​

开源能源管理系统

开源 能源管理系统

CommunityOverCode Asia 2025 Messaging 专题预告,RocketMQ 邀您共探相关议题

Apache RocketMQ

开源 云原生 消息队列 Asia

什么是等保一体机?

等保测评

深度解析 DApp 开发:从技术架构到商业落地的全链路解决

区块链软件开发推广运营

交易所开发 dapp开发 链游开发 公链开发 代币开发

Murus Pro for mac(网络安全防火墙工具)

晨光熹微

从 Python 演进探寻 AI 与云对编程语言的推动

阿里巴巴云原生

Python 阿里云 云原生

MyEMS开源能源管理系统核心代码解读013

开源能源管理系统

开源 能源管理系统

数智先锋 | Bonree ONE助力国金证券构建数智一体化可观测平台

博睿数据

低代码平台:从热潮到现实,如何破解复杂企业应用难题?

中烟创新

MyEMS开源能源管理系统核心代码解读014

开源能源管理系统

开源 能源管理系统

体验KWDB及其测试组件kwdb-tsbs

KaiwuDB

数据库

Studio One 7 Pro for mac(音乐创作编辑软件)

晨光熹微

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