QCon 演讲火热征集中,快来分享技术实践与洞见! 了解详情
写点什么

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

评论

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

Snagit for mac(最强大的屏幕截图软件)v2023.3.1中文版

iMac小白

OpenAI 发布的 GPT-4o是什么,有什么功能?

蓉蓉

openai gpt4o

TouchDesigner Pro 2023 mac破解版 及安装教程

Rose

XnViewMP for Mac(图片浏览查看器)v1.7.2激活版

iMac小白

XnViewMP破解版 XnViewMP 下载 XnViewMP mac

Ulysses for Mac(优秀的markdown写作软件)v35中文直装版

iMac小白

Beyond Compare 4 mac中文破解版 及安装教程 兼容M1/M2

Rose

Affinity Publisher 2(逆天排版神器)中文直装版mac/win

iMac小白

Affinity Publisher 2下载 Affinity Publisher 2直装 Affinity Publisher 2激活

用技术定义未来,洲际交易所ICE即将推出AI高频交易平台

科技热闻

什么时候需要用到 @EnableWebSecurity 注解?

江南一点雨

Java spring spring security

ForkLift for Mac(文件管理程序)v4.1.3激活版

iMac小白

即时通讯技术文集(第39期):推送技术合集(Part1) [共18篇]

JackJiang

即时通讯;IM;网络编程

Mac SVN客户端 SmartSVN for Mac许可证书及详细安装图文教程 支持M1/M2

Rose

DpEasy社区版1.4.0 | 体验全新的数据库特权账号管理平台!

BinTools图尔兹

数据库 应用 数据库安全

活动回顾丨AI 原生应用架构专场·北京站 PPT 下载

阿里巴巴云原生

阿里云 架构 云原生

Affinity Photo for Mac(好用的图片编辑软件)v2.5.2免激活版

iMac小白

实时数据驱动电站管理,EMQX 助力黑马高科光伏电站运维监控

EMQ映云科技

mqtt 光伏发电 emqx 新能源行业 mqtt broker

Cockos Reaper for Mac(专业数字音频制作软件)v7.16激活版

iMac小白

Photoshop CC 2018 for Mac(ps cc 2018)v19.1.8中文激活版

Rose

MySQL5.7 通过逻辑备份迁移到GreatSQL注意事项

GreatSQL

心大陆多智能体还原人类情感,激发心理健康领域“新质生产力”!

心大陆多智能体

一文搞懂5种内存溢出案例,内含完整源码

华为云开发者联盟

Java JVM 华为云 华为云开发者联盟 企业号2024年6月PK榜

Sip for mac(强大的屏幕取色软件)v3.5.1激活版

iMac小白

接口测试 Mock 工具使用 - 弱网测试

测试人

软件测试 弱网测试

Affinity Designer for Mac(强大的矢量图设计软件)v2.5.2中文免激活版

iMac小白

Affinity Designer mac Affinity Designer破解版 Affinity Designer激活版 Affinity Designer直装版

Native Instruments Creator Tools for mac(高级采样器)v1.5.2.0激活版

iMac小白

Mock 工具使用 - 模拟弱网测试

霍格沃兹测试开发学社

AE脚本Motion 4 中文版for Mac v4.1.2激活版 MG运动图形高级工具

Rose

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