字节码引用检测原理与实战

3,433 阅读15分钟

一、字节码与引用检测

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具体过程省略,直接上检测功能的代码。方法、字段引用检测:

// Gradle Plugin、自定义Transform的部分这里不做赘述
// 方法引用检测
// 遍历每个类中的 每个方法 (包括构造方法 addBy Qihaoxin)
classObj.declaredBehaviors.forEach { ctMethod ->
    //遍历当前类中所有方法
    ctMethod.instrument(object : ExprEditor() {
        override fun edit(m: MethodCall?) {
            super.edit(m)
            //每个方法调用都会回调此方法,在此方法中进行检测
            //引用检查功能
            try {
                //这里不是每个方法都需要校验的,过滤掉 我们不需要处理的 系统方法,第三方sdk方法 等等 只校验我们自己的业务逻辑代码
                if (ctMethod.declaringClass.name.isNeedCheck()) {
                    return
                }
                if (m == null) {
                    throw Exception("MethodCall is null")
                }
                //不需要检查的包名
                if (m.className.isNotWarn() || classObj.name.isNotWarn()) {
                    return
                }
                //method找不到,底层会直接抛异常的,包括方法删除、方法签名不匹配的情况
                m.method.instrument(ExprEditor())
                //访问权限检测,该方法非public,且对当前调用这个方法的类是不可见的
                if (!m.method.visibleFrom(classObj)) {
                    throw Exception("${m.method.name} 对 ${classObj.name} 这个类是不可见的")
                }
            } catch (e: Exception) {
                e.message?.let {
                    errorInfo += "--方法分析 Exception Message: ${e.message} \n"
                }
                errorInfo += "--方法分析异常发生在 ${ctMethod.declaringClass.name} 这个类的${m?.lineNumber}行, ${ctMethod.name} 这个方法  \n"
                errorInfo += "------------------------------------------------\n"
                isError = true;
            }
        }
 
        /**
         * 成员变量调用的分析主要有:
         * 变量直接被删掉后找不到的问题
         * private变量的只能定义该变量的类试用
         * protected变量的可被类自己\子类\同包名的访问
         * */
        override fun edit(f: FieldAccess?) {
            super.edit(f)
            try {
                if (f == null) {
                    throw Exception("FieldAccess is null")
                }
                //不需要检查的包名
                if (f.className.isNotWarn() || classObj.name.isNotWarn()) {
                    return
                }
                //这里不用判空,如果field找不到(这个属性被删掉了),底层会直接抛异常NotFoundException
                val modifiers = f.field.modifiers
                if (ctMethod.declaringClass.name == classObj.name) {
                    //只处理定义在本类中的方法,不然基类里的方法也会被处理到--会出现本类实际没访问基类里的private变量但报错的问题
                    if (ctMethod.declaringClass.name == classObj.name) {
                        if (!f.field.visibleFrom(classObj)) {
                            throw Exception("${f.field.name} 对 ${classObj.name} 这个类是不可见的")
                        }
                    }
                }
            } catch (e: Exception) {
                e.message?.let {
                    errorInfo += "--字段分析 Exception Message: ${e.message} \n"
                }
                errorInfo += "--字段分析异常发生在 ${classObj.name} 该类在 ${f?.lineNumber}行,使用 ${f?.fieldName} 这个属性时\n"
                errorInfo += "------------------------------------------------\n"
                isError = true
            }
        }
    })
}

在以上代码实现中,是遍历了所有的方法,对方法内的方法调用、字段访问进行了检测。那么全局变量如何检查呢?

class BillActivity {
    ...
    private String mTest1 = CreateNewAddressActivity.TAG;
    private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c");
    ...
}

例如以上代码中,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方法中。所以我们前面遍历类中所有方法来检测方法和属性的引用检查是可以覆盖到全局变量的。

问题到这里似乎已经全部完美解决了,但我在全局变量的代码这里看了几眼后,又发现了新的问题:

class BillActivity {
    ...
    private String mTest1 = CreateNewAddressActivity.TAG;
    private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c");
    ...
}

我们前面只关心了TAG这个属性和getFormatProvinceInfo这个方法的引用是否存在问题,但我们没有对CreateNewAddressActivity这个类本身做引用检查,假设这个类是private的,这里依然会有问题。所以我们引用检查不能忘记对类引用的检查。

3.3 类引用检查原理

如何识别一个类引用存在问题?

  • 该类被删除,找不到相关类;

  • 类是非public的,当前类无权限访问该类。

3.4 类引用检测实战

类引用检查

//类的引用检查
if (classObj.packageName.isNeedCheck()) {
    classObj.refClasses?.toList()?.forEach { refClassName ->
        try {
            if (refClassName.toString().isNotWarn() || classObj.name.isNotWarn()) {
                return@forEach
            }
            //该类被删除,找不到相关类
            val refClass = classPool.getCtClass(refClassName.toString())
                ?: throw NotFoundException("无法找到该类:$refClassName")
            //权限检测
            //.....省略.....跟方法和属性的权限检测一样,这里不再赘述
        } catch (e: Exception) {
            e.message?.let {
                errorInfo += "--类引用分析 Exception Message: ${e.message} \n"
            }
            errorInfo += "--类引用分析异常 在类:${classObj.name} 中引用了 $refClassName \n"
            errorInfo += "------------------------------------------------\n"
            isError = true
        }
    }
}

到这里本次字节码引用检测的原理以及实战就介绍完了。

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中添加依赖:

dependencies {
        ...
        classpath "com.byteace.refercheck:byteace-refercheck:35-SNAPSHOT" //目前是试运行版本,版本还需迭代;欢迎大家体验并提建议和问题,帮助不断完善插件功能
}

在APP工程的build.gradle中使用插件并设置配置信息:

//官网自研的字节码引用检查插件
apply plugin: 'com.byteace.refercheck'
//官网自研的字节码引用检查插件-配置项
referCheckConfig {
        enable true //是否打开引用检查功能
        strictMode true // 控制是否发现问题时停止构建,
        check "com.abc.def" //需要检查的类的包名,因为工程中会使用很多sdk或者第三方库我们一般不做检查,只检查我们需要关注的类的包名
        notWarn "org.apache.http,com.core.videocompressor.VideoController" //人工检查确认后不需要报错的包名
}

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