乐趣区

关于编程语言:字节码引用检测原理与实战

一、字节码与援用检测

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

退出移动版