一、字节码与援用检测
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