写点什么

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

评论

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

运维审计系统是堡垒机么?跟堡垒机有啥区别?

行云管家

运维 堡垒机 运维审计系统

母婴后浪品牌频出,各个细分市场有哪些发展潜力?

易观分析

母婴

一周热点回顾|虎符交易所上线多链合一;俄央行称加强监控加密资产等P2P交易

区块链前沿News

区块链 虎符交易所

集结创新力量,2022航天宏图&华为云杯PIE软件开发者大赛正式开启

科技热闻

小程序容器技术,App热更新与敏捷开发新方案

Speedoooo

敏捷开发 APP开发 热更新 小程序容器 动态更新

收藏很久的资源整合网站,一个网站一个世界

小炮

电脑就是我的安全感|ONES 人物

万事ONES

招聘 软件工程师

计算机编码规则之:Base64编码

程序那些事

Java base64 nio 程序那些事 3月月更

Linux之uniq命令

入门小站

Linux

centos7.6安装MySQL5.7采坑指南

云原生

MySQL 数据库 sql centos

TDesign 更新周报(2022年3月第3周)

TDesign

数字化原住民|ONES 人物

万事ONES

软件 招聘 软件工程师

多场景推进 服务网格在联通的落地实践(下)

百度大脑

敏捷实践|好的用户故事怎么写?

LigaAI

用户故事 敏捷实践

恒源云(GpuShare)_MaskFormer:语义分割可以不全是像素级分类

恒源云

语义分割 像素分割 MaskFormer

在线HTML压缩格式化工具

入门小站

工具

“StarRocks 极客营” 重磅来袭,和技术大牛一起推开数据库梦想之门!

StarRocks

数据库 大数据 StarRocks

从 SVN 迁移到极狐GitLab

极狐GitLab

svn 迁移 极狐GitLab

春分耕种时,AI“现身”田间地头

百度大脑

3. 堪比JMeter的.Net压测工具 - Crank 进阶篇 - 认识bombardierdate

MASA技术团队

C# .net 测试 压测 测试工具

在线JSON转HTML,TABLE表格工具

入门小站

工具

Centos7安装Nginx

云原生

nginx centos 部署

Git 如何回退代码

秋天

Python迎来31岁生日,蝉联年度编程语言排行榜冠军

Python猫

Python

【网络安全】网络安全的重要性你知道吗?

行云管家

网络安全

网络安全:绕过MSF的一次渗透测试

网络安全学海

黑客 网络安全 信息安全 渗透测试 安全漏洞

科幻变现实:喷下即疗愈,生物3D打印绘就生命密码图

脑极体

如何进行高效的版本管理,版本管理的方法

阿里云云效

云计算 阿里云 项目管理 云原生 版本管理

什么技术,让浩鲸科技拿下中国移动大奖?

鲸品堂

中国移动

深入浅出事务的本质,附 OceanBase 事务解析14问!

OceanBase 数据库

oceanbase OceanBase 社区版

使用 ABAP 开发的一个基于 Web Socket 的小工具,能提高程序员日常工作效率

汪子熙

自动化 前端开发 websocket 程序员进阶 3月月更

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