Labs 导读
插桩技术非常有趣也很有价值,学会这项技术以后,我们就可以随心所欲地操控代码,满足不同场景的需求。很多框架都离不开这个技术,如常见的ButterKnife 注解框架,数据库 ORM 框架、APM性能监控、埋点统计等。
和家亲是一款智慧家庭综合服务入口APP。客户端的性能直接影响用户体验,在这次的和家亲APP性能优化尤其是启动至首屏专项优化中,使用了Gradle+ASM编译插桩技术实现apk全局耗时方法统计,本文以此为例让你认识“插桩”这个效率利器。
Part 01 编译插桩
顾名思义,所谓的编译插桩就是在代码编译期间修改已有的代码或者生成新代码。
在学习插桩之前,你首先需要了解相关基础技术,包括Android打包大致流程、class字节码文件结构、gradle Transform task及ASM字节码操作框架等。后面会做简单介绍,若要详细了解,你可以仔细阅读参考文献。
下图为android编译插桩示意图。
字节码(Bytecode):“.class”文件的是 Java 字节码、“.dex”文件的是 Dalvik 字节码。我们这里的ASM插桩方法是操作Java 字节码。
使用场景:对于代码监控、代码修改以及代码分析这三个场景,一般采用操作字节码的方式,如无埋点统计上报、轻量级AOP等。应用到在Android中,可以用来做用行为统计、方法耗时统计等功能。
Part 02 ASM字节码框架
ASM是一个java字节码操纵框架,它能被用来动态生成类或者增强既有类的功能。
2.1 class文件
了解ASM框架使用之前,必须先了解下class文件格式,一个完整的 class字节码文件包括:
- 魔数与class文件版本
- 常量池
- 访问标志
- 类索引、父类索引、接口索引
- 字段表集合
- 方法表集合
- 属性表集合
为方便查看字节码文件,kotlin代码android studio 有自带工具tools--show kotlin bytecode,java代码可以安装jclasslib查看。
2.2 ASM框架使用
ASM的架构主要是采用了访问者模式来设计,所谓访问者模式就是封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。具体在ASM框架中应用就是将.class类文件的内容从头到尾扫描一遍,每次扫描到类文件相应的内容时,都会调用ClassVisitor内部相应的方法。该方法会返回一个对应的字节码操作对象(比如,visitMethod()返回MethodVisitor实例),通过修改这个对象,就可以修改class文件相应结构部分内容,最后将这个ClassVisitor字节码内容覆盖原来.class文件就实现了类文件的代码切入。
2.3 ASM工具
工欲善其事,必先利其器。在使用 ASM 插入字节码时,如果你不熟悉字节码相关语法和规则可能对于插入字节码代码束手无策了。幸好 ASM官方开发了一款IDE插件,可以将Java代码 转换成 ASM 字节码类型代码,这样再使用ASM插入字节码时就比较方便了。利用插件ASM bytecode outline轻松查看字节码及对应ASM框架代码。
Part 03 Gradle Transform
Gradle Transform 是 Android 官方提供的在apk编译打包的流程中将 .class 文件到 .dex 转换这一阶段用来修改 .class 文件的一套标准 API。这一应用现在主要集中在字节码查找、代码注入等。
3.1 Transform原理
Transform是android gradle api中的一部分,它可以在android项目的.class文件编译为.dex文件之前,得到所有的.class文件,在Transform中处理。使用Transform API, 我们完全可以不用去关注相关task的生成与执行流程, 它让我们可以只聚焦在如何对输入的类文件进行处理。
每个Transform其实都是一个gradle task,Android编译器中的TaskManager将每个Transform串连起来,第一个Transform接收来自javac编译的结果,以及已经拉取到在本地的第三方依赖(jar、aar),还有resource资源。这些编译的中间产物,在Transform组成的链条上流动,每个Transform节点可以对class进行处理再传递给下一个Transform。我们常见的混淆,Desugar等逻辑,它们的实现如今都是封装在一个个Transform中,而我们自定义的Transform,会插入到这个Transform链条的最前面。
自定义的transform在build控制台可以看到对应的task,输出内容可以在build\intermediates\transforms\对应目录找到。
3.2 Transform自定义实现
想要自定义transform,必须实现以下几个方法:
- getName():返回transform名称标识
- getInputTypes(): 输入类型包括俩种,CLASSES 和 RESOURCES分别代表java的class文件和资源文件
- getScopes(): 定义Transform需要处理那些输入文件
- isIncremental(): 表示是否支持增量编译,支持增量编译,可以节省一些编译的时间和资源,一个好的transform都应该支持增量编译
- Transform(): 主要方法,入参TransformInvocation是一个接口,提供一些关于输入的基本信息,利用这些接口就可以获得编译流程中的class文件进行操作
在apk打包过程中,除了自定义的Transform,还有系统提供原生的一些Transform,每个 Transform 在处理完之后交给下一个 Transform,是一个链式结构。下图为自定义Transform实现apk打包流程中字节码插桩的流程示意图,简单来说就是以下几步:
- 筛选符合条件的 Class 文件,其中 Class 有两种可能的文件来源:jar包和特定目录;
- 利用ASM框架读取 Class 文件包含的类信息(例如接口、注解等)进一步筛选符合条件的 Class 文件;
- 对最终符合条件的 Class 做处理(修改字节码、插桩等);
- 将产物拷贝至 Transform 的输出目录,作为下一个 Transform 的输入;
Part 04 实战:APK函数耗时插桩
和家亲是智慧家庭综合服务入口APP,随着用户量的激增,客户端的性能问题愈加明显,启动性能作为APP使用体验的门面,启动耗时较长很可能削减用户使用APP的兴趣。在这次的启动至首屏专项优化中,需要查找启动过程耗时方法并优化,由于业务复杂及SDK接入众多,虽然也有原生工具profile,但是用过的都知道存在不易捕获尤其是启动阶段,且无法输出调用堆栈等问题。需要实现一个快速排查高耗时方法的工具,此次优化通过Gradle TransForm+ASM方式实现了编译插桩全局耗时方法统计,辅助启动优化分析,最终启动到首屏展示耗时从4.5s将至3.2s,启动提速30%,效果显著。
4.1 实现思路
在性能优化阶段,需要函数耗时统计以解决启动慢、卡顿等问题。对Android打包过程和自定义Gradle插件了解后发现,java文件会先转化为class文件,然后再转化为dex文件。而通过Gradle插件提供的Transform API,可以在编译成dex文件之前得到class文件。得到class文件之后,便可以通过ASM对字节码进行修改,即可完成字节码插桩,插入时间统计打印代码,大于阈值则输出调用堆栈。主要实现以下功能:
- 自定义Gradle插件
- 处理class,在方法出口及入口插入耗时统计
- 文件替换
创建一个buildsrc模块
在 Android 工程中,buildSrc 是 gradle默认的插件目录,编译 gradle的时候会自动识别这个目录,因此在 buildSrc 下编写的插件,我们可以直接进行引用。通常我们会使用这种方式进行插件的调试。创建buildSrc 目录,配置plugin插件相关配置及依赖(新版本Gradle plugin已经支持kotlin语言编写)。
注册Transform
想要使用gradle-transform-api,我们必须要先实现一个gradle插件,然后在插件中注册一个Transform,同时需要在gradle-plugins目录的.properties文件声明插件实现者如:
implementation-class=com.xxx.xxx.SystemTracePluginTest
获取所有class文件
transform()通过参数inputs获取所有class文件,包括源码编译后的class文件及三方的jar包。
字节码修改及文件写回
经过上面的步骤,我们已经到输入文件,也确定了输出路径,现在我们只要来处理这些文件,然后输出到输出路径就可以了。这里需要注意的是,就算你不想修改某个class文件,你也应该将它原样拷贝过去,否则这个文件就丢失了。
利用ASM框架,在遍历到方法出口及入口即onMethodEnter、onMethodExit回调中插入耗时统计字节码,相应的字节码可以用上面的工具jclaslib或者asm codeoutline查看得到。(以下代码只是部分示例,细节完善如之针对部分包名统计、getset方法排除等未在次列出)
应用插件完成插桩
app工程apply plugin ‘pluginname’ ,Gradle task会有对应task name 输出则Transform task执行,运行apk,可以看到插入的自定义耗时统计方法输出,比如小编在耗时统计方法加入了逻辑,耗时超过自定义阈值logcat打印日志及堆栈信息。
通过插桩的形式,使用apk的时候可以非常清晰的统计出耗时方法,还有调用堆栈,方便后续性能优化。能够弥补传统的profile工具性能分析的一些不足,比如只能捕获短时间,需要自己寻找长耗时方法等问题。
Part 05 结语
编译插桩这个技术应用场景越来越多,涉及的知识较多,但是相信在你熟悉Android打包流程、class字节码文件结构、Gradle Transform API、ASM之后,相信你会觉得插桩so easy,android开发高手课之编译插桩又get了一个新技能!在性能优化过程中,已经不止一次用到编译插桩的技术了,除了方法耗时统计,我们还使用插桩加hook代理的方式做大图监控,网络监控、线程优化等工作,例如网络数据监控 的实现,就是在 网络层通过 hook 网络库方法和自动化注入拦截器的形式,实现网络请求的全过程监控,包括获取握手时长,首包时间,DNS 耗时,网络耗时等各个网络阶段的信息。大图监控则是通过hook各大图片加载库如Glide、picasso在图片加载过程增加监听计算图片大小,针对大图过滤输出等。让我们一起学习“插桩”这个效率利器吧。
参考文献
[1]https://rebooters.github.io/2020/01/04/Gradle-Transform-ASM-%E6%8E%A2%E7%B4%A2/
[2]https://cloud.tencent.com/developer/article/1399805
[3]https://time.geekbang.org/column/intro/142?tab=catalog