字节码引用检测原理与实战
一、字节码与引用检测
1.1 Java 字节码
本章中的字节码重点研究 Java 字节码,Java 字节码(Java bytecode)是 Java 虚拟机执行的一种指令格式。可以通过 javap -c -v xxx.class(Class 文件路径) 命令来查看一个 Class 对应的字节码文件,如下图所示:
1.2 字节码检测
字节码检测本质就是对.java 或.kt 文件编译后生成的 Class 文件进行相关的分析和检测。在正式介绍字节码分析在引用检测上的原理与实战前,先介绍下字节码引用检测的技术预研背景。
二、字节码检测技术的预研背景
整个预研背景需要先从笔者负责的 APP--内销官网 APP 的软件架构讲起。
2.1 内销官网 APP 软件架构
内销官网 APP 目前共 12 个子仓,子仓分别独立编译成 AAR 文件供 APP 工程使用,软件架构图如下图所示:
APP 以下,上层浅蓝色为业务层,中间绿色为组件层,最下层深蓝色为基础框架层:
业务层:位于架构最上层,根据业务线划分的业务模块(比如商城、社区、服务),与产品业务相对应。
组件层:是 APP 的一些基础功能(比如登录、自升级)和业务公用的组件(比如分享、地址管理、视频播放),提供一定的复用能力。
基础框架层:通过跟业务完全无关的基础组件(比如三方框架、自行封装的通用能力),提供完全的复用能力。
2.2 内销官网 APP 客户端开发模式
官网 APP 目前主要分 3 条业务线,多业务版本并行开发是常态,所以模块化非常必要。
官网 APP 模块化的子仓均已 AAR 形式供 APP 使用,且存在上层 AAR 依赖下层 AAR 的情况。
官网 APP 模块化分仓优化工作穿插在各业务版本中,各业务版本并行开发,底层仓库难免有修改。
官网 APP 各业务版本并行开发时,一般只会新拉取当前版本需要修改代码的仓库,其他仓库均继续依赖老版本的 AAR。
2.3 类、方法、属性引用错误导致的运行时崩溃
假设以下场景:
官网 APP5.0 版本开发过程中,由于 HardWare 仓没有业务修改,所以继续使用上个版本 4.9.0.0 的 HardWare(版本开发过程中一般只会重新拉取需要修改的仓库,无需修改的仓库会继续使用老版本),但 Core 仓有代码修改,所以拉取了新的 5.0 分支,并修改了相关代码,删除了 CoreUtils 类中的某个 fun1 方法,如下图所示:
注:硬件检测模块 v4.9.0.0 版本 AAR 中用到了核心仓 CoreUtils.class 中的 fun1 方法,其他仓包括主 APP 工程均未使用到该 fun1 方法。
请大家思考下,以上场景项目编译是否会有问题?
答:编译无问题
APP 主仓依赖的是 4.9.0.0 版本的 HardWare 仓编译后的 AAR 文件,这个 AAR 文件早在 4.9 版本就编好没动,所以 HardWare 仓没有编译问题;
APP 主仓依赖的是 5.0.0.0 版本的 Core 仓,HardWare 依赖的是 4.9.0.0 版本的 Core 仓,最终编译会取 Core 仓的高版本 5.0.0.0 版本参与 APP 工程编译,App 仓没有使用被删除的 fun1 方法,也不存在编译问题。
以上场景项目编译完成后运行过程中是否会有问题?
答:有问题。
在 APP 运行到 HardWare 仓调用了 CoreUtils 类中 fun1 方法的情况下就会出现运行时崩溃:Method Not Found。
因为最终参与 APP 工程编译的是 5.0.0.0 版本的 Core 仓,该版本已经删除了 fun1 方法,所以会出现运行时错误。
真实案例:
1)找不到方法
2)找不到类
所幸以上问题均在开发、测试阶段发现并及时修复掉了,如果流到线上,就是运行到某功能时的必崩场景,将会非常严重。
如果你负责的 APP 的所有 module 均是源码依赖,一般情况下如果存在引用问题,编译器会进行提示,所以一般情况下无需担心(除非依赖的底层 sdk 存在引用问题),但如果是类似官网这样的软件架构,则需要重点注意。
2.4 现状分析、思考
本地测试过程中已出现过引用问题导致的运行时异常,这种运行时异常的检测只靠人工是不够的,必须要有自动化的检测工具来进行检查。传统的 findBugs、Lint 等是代码静态检测工具,是无法检测出这种潜在的引用问题导致的运行时异常的,静态代码检测无法解决此问题。所以自研自动化的检测工具迫在眉睫!
三、字节码检测的解决方案
如果能在 APK 编译期间,通过自动化工具对所有 JAR、AAR 包中每个类做一遍检测,检测其中调用的方法、属性的使用是否存在引用问题,将检测出疑似问题的地方在编译时进行提示,有必要的情况下直接报错终止编译,并输出错误日志来提醒开发人员检查,防止问题流入线上出现运行时异常。
原理:各子仓的 Java 类(或 Kotlin 类)在编译成 AAR 或 JAR 后,AAR、JAR 中会有所有类的 Class 文件,我们实际上就是需要对编译后生成的 Class 文件进行分析。
如何对 Class 文件进行字节码分析?
这里推荐使用 JavaAssist 或 ASM,我们知道 Android 编译过程主要通过 Gradle 来控制的,要想分析 Class 文件字节码,我们需要实现自己的 Gradle Transform,在 Transform 里对 Class 字节码进行分析,这里我们直接做成 Gradle 插件。
在编译期间自动分析 Class 字节码是否存在方法引用、属性引用、类引用找不到或者当前类无权访问的问题,发现问题停止编译,并输出相关日志,提醒开发人员分析,并支持对插件的配置。
到这里,整个方案的主体框架就比较清晰了,如下图所示:
3.1 方法和属性引用检测原理
方法和属性引用问题的识别:
如何识别一个方法引用存在问题?
该方法被删除,找不到相关方法名;
找不到方法签名相同的方法,主要是指方法的入参数量、入参类型无法匹配;
方法是非 public 方法,当前类无权限访问该方法。
如何识别一个属性(字段)引用存在问题?
该属性被删除,找不到相关属性、字段;
属性是非 public 属性,当前类无权限访问该属性。
权限修饰符说明:
方法和属性引用的字节码检测:我们可以利用 JavaAssist、ASM 等支持字节码操作的库来实现对所有类中方法、属性的扫描,并分析方法调用、属性引用是否存在引用问题。
3.2 方法和属性引用检测实战
以下代码均已 Kotlin 编写,实现 Gradle Plugin、Transform 具体过程省略,直接上检测功能的代码。方法、字段引用检测:
在以上代码实现中,是遍历了所有的方法,对方法内的方法调用、字段访问进行了检测。那么全局变量如何检查呢?
例如以上代码中,mTest1 属性的值以及 mTest2 属性的值应该如何做检测?这个问题困扰笔者良久。在 JavaAssist、ASM 中均未能找到获取属性当前值的相关的 Api、也未能找到 Class 字节码直接分析属性值的相关思路以及资料。
在研究了 Class 字节码相关知识,并做了大量的实验,打了大量的 Log 后,解决思路才慢慢浮出水面。
我们先来看下 BillActivity 的一段字节码:
在这里我们找到了定义的 mTest1 这个全局变量,然后大家可以注意到,右边 Method 中出现了一个 init 方法,实际上 Java 在编译之后会在字节码文件中生成 init 方法,称之为实例构造器,该实例构造器会将语句块,变量初始化,调用父类的构造器等操作收敛到 init 方法中。那我们的 mTest2 这个全局变量呢?
搜索后发现 mTest2 实际上是在 static 代码块中,这里似乎 mTest2 赋值并没有被方法包裹,如下图所示:
实际上通过查阅大量资料后得知,Java 在编译之后会在字节码文件中生成 clinit 方法,称之为类构造器,类构造器会将静态语句块,静态变量初始化,收敛到 clinit 方法中。上图通过 javap 查看 Class 字节码中未显示 clinit 方法是因为 javap 未对此进行相关的适配展示而已。
通过实验 Log 发现 mTest2 的初始化确实出现在 clinit 方法中,且在 ASMPlugin 的 ByteCode 中查看跟上图相同的字节码,展示为带有 clinit 方法标识的字节码,如下图所示:
研究到这里,我们实际也就知道了 mTest1 和 mTest2 的赋值实际都发生在 init 和 clinit 方法中。所以我们前面遍历类中所有方法来检测方法和属性的引用检查是可以覆盖到全局变量的。
问题到这里似乎已经全部完美解决了,但我在全局变量的代码这里看了几眼后,又发现了新的问题:
我们前面只关心了 TAG 这个属性和 getFormatProvinceInfo 这个方法的引用是否存在问题,但我们没有对 CreateNewAddressActivity 这个类本身做引用检查,假设这个类是 private 的,这里依然会有问题。所以我们引用检查不能忘记对类引用的检查。
3.3 类引用检查原理
如何识别一个类引用存在问题?
该类被删除,找不到相关类;
类是非 public 的,当前类无权限访问该类。
3.4 类引用检测实战
类引用检查
到这里本次字节码引用检测的原理以及实战就介绍完了。
3.5 解决方案的反思
在内销官网的 buildSrc 中实现了引用检测功能后,得知其他 APP 很多都已做了模块化,联想到其他 APP 可能也采用类似官网的模块化架构,也会存在类似痛点,反思当前技术实现并不具备通用的接入能力,深感这件事其实并没有做完,在解决自身 APP 痛点后需要横向赋能其他 APP,解决大团队所面临的痛点,所有才有了后面的独立 Gradle 插件。
四、独立 Gradle 插件
如果需要在编译期间进行引用检测的 APP 模块,欢迎大家接入我开发的这款字节码引用检测的 Gradle 插件。
4.1 独立 Gradle 插件目标
1)独立 Gradle 插件,方便所有 APP 接入;
2)支持常用的开发配置项,支持插件功能开关、异常跳过等配置;
3)对 Java、Kotlin 编译后的字节码进行引用检查,能在 CI、Jenkins 上编译 APK 包发现引用问题时,编译报错并输出引用问题的具体信息供开发分析、解决。
4.2 插件功能
1)方法引用检测;
2)属性(字段)引用检测;
3)类引用检测;
4)插件支持常用配置,可开可关。
比如能检测出 Class Not Found \Method Not Found 或者 Field Not Found 的问题。整个插件在编译期间运行时间很短,以内销官网 APP 为例,该插件在 APP 编译期间运行时间在 2.3 秒左右,速度很快,不必担心会增加编译耗时。
4.3 插件接入
在主工程根目录 build.gradle 中添加依赖:
在 APP 工程的 build.gradle 中使用插件并设置配置信息:
4.4 插件配置项说明
Enable:是否打开引用检查功能,如果为 false,则不进行引用检查
StrictMode:严苛模式开启时,发现引用异常直接中断编译(严苛模式关闭时,只会将异常信息打在编译过程的日志中,发现引用问题不会终止编译)。
建议:Jekins 或 CI 上打 Release 包时 build.gradle 中配置的 enable 和 strictMode 都设置为 true。
Check:需要检测的包名,一般只配置检查当前 APP 包名即可,如需对依赖的第三方 sdk 等做检查,可根据需要进行配置。
NotWarn:发现引用问题不报错的白名单,在开发人员检查插件报错的问题并认定实际不会导致崩溃后,可将当前引用不到的类名配置在这里,可跳过检查。如 A 类引用不到 B 类中的某个方法,可将 B 类的类名配置在这里,将不会报错。
4.5 内销官网 APP 中 NotWarn 配置项说明
内销官网 APP 将 org.apache.http 以及 com.core.videocompressor.VideoController 加入到了不报错白名单中。org.apache.http 实际用的是 Android 系统中的包,该包并没有参与 APK 编译,如果不加该配置项,则会报错,但实际运行不会出错。
com.core.videocompressor.VideoController 该项不加的话会报错:FileProcessFactory 中引用不到 CompressProgressListener 类。排查下 FileProcessFactory 代码,FileProcessFactory 类的 138 行 调用了 convertVideo 方法,最后一个 listner 参数传的 null。
该类的字节码 Class 文件如下,会自动对 converVideo 最后一个入参 null 进行强制类型转换:
而这个 CompressProgressListener 并不是 public 的,是默认的 package。而且 FileProcessFactory 类与 CompressProgressListener 不在同一个 package 下,所以会报错。但实际运行时并不会崩溃,所以需要将其类名加入到不报错的白名单中。
如果在插件使用过程中遇到不应报错的案例,可以通过白名单控制进行跳过,同时希望将案例反馈给我,我这边对案例进行分析并对插件进行迭代更新。
五、总结
预研过程中由于字节码知识较深,且网络上类似字节码插桩、进行代码生成的的教程较多,但做字节码分析的资料太少,所以需要熟悉字节码知识并在实践中慢慢实验和摸索,细节也需慢慢打磨。
在预研过程中积极思考解决方案的通用性和可配置性,最终开发出通用的 Gradle 插件,积极推动其他模块接入,借此次宝贵的机会进行横向技术赋能,争取大团队的成功。
目前已有两个 APP 接入插件,插件会持续维护并迭代,等插件稳定后规划集成到 CI、Jenkins 上。欢迎有需求的 APP 接入引用检测的 Gradle 插件,希望能帮助到存在引用检测痛点的 APP 和团队。
作者:vivo 官网商城客户端团队-Qi Haoxin
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/d95c0b9123d74a7ea7b36255b】。文章转载请联系作者。
评论