您的位置 首页 科技

写个更牛逼的Transform | Plugin 进阶教程

本文作者作者: 究极逮虾户 虾哥的文章内容不是那么循序渐进,很多同学表示不好理解。但是文章内容都是极其不错的,对于走性能优化路线的同学,博客都有极高的借鉴价值,而且他本人也在不断进步,从他的文章和开源项目的迭代中可以学习到很多精深的技巧。

本文作者

作者: 究极逮虾户

虾哥的文章内容不是那么循序渐进,很多同学表示不好理解。但是文章内容都是极其不错的,对于走性能优化路线的同学,博客都有极高的借鉴价值,而且他本人也在不断进步,从他的文章和开源项目的迭代中可以学习到很多精深的技巧。

1

前言

文章主要要演示的代码都在github上 https://github.com/Leifzhang/AndroidAutoTrack

还是我那个90年的老安卓,这算是一篇自吹自擂的装逼爽文。首先我写这个AndroidAutoTrack Demo的原因很简单,我就单纯觉得很好玩,然后同时其实对于自己的技术水平是会有成长的。我最近下班在优化以前写的自动化埋点。我看过很多文章介绍这个,但是我觉得都是一些入门相关的,很难有一些更深入一点的文章。

Plugin插件或者说Transform,我个人觉得说难其实也不难,但对于新入门的人来说,这个东西非常的不友善,gralde 官方资料都是英文的,然后Gralde Plugin的编写调试又比较繁琐,如果中间碰到了什么问题,如果没人带你一把,你也走进死胡同了。

所以并不是各位看下文章就能完全搞懂这个的,我个人觉得如果没有人带你的情况下,你基本很难学会这个东西。我的项目内有如何通过构ComposeBuilding简单方便的写一个Gradle Plugin的方式,我的上一篇文章也有对应的介绍。协程 路由 组件化 1+1+1>3 | 掘金年度征文

2

开始我的表演

一个合格的Transform插件是需要增量编译的功能的,我拿以前增编的文章的数据给大家做个比较好了。

全量编译的情况下

写个更牛逼的Transform | Plugin 进阶教程

二次增量编译情况下

写个更牛逼的Transform | Plugin 进阶教程

我们抛开别的Task,同样一个Transform全量编译的耗时是2784ms,而代码变更增量编译的情况下只有68ms。其中的差距之大也值得各位去把增量编译给写出来了。

当项目后期代码持续不断的增加之后,不可避免的Transform变多了,只要有任意的一个Transform不是增量的,就会导致整个编译Transform过程都变成全量。这个以前我也介绍过,其实有很多系统的Transform任务,比如ShrinkDex合并等等。

你们想一想哦,一个人优化了1分半的编译时间的话,那么如果团队人员一多,那么岂不就是Kpi美滋滋。

如何去实现一个增编

有兴趣的大佬们可以看下这个地址 https://github.com/Leifzhang/AndroidAutoTrack/tree/master/Plugin/BasePlugin/src/main/java/com/kronos/plugin/base

首先我将Transform流程进行了一次抽象,主要是因为我比较懒,同样代码和功能如果要让我复制黏贴好两遍其实我都不乐意。所以我先对这部分代码进行了梳理,整理出来两个部分,第一就是文件的复制拷贝,第二就是文件的ASM操作。其中我觉第一部分的代码是可以进行整合的,然后就是下面的逻辑了。

写个更牛逼的Transform | Plugin 进阶教程

先从Transform开始吧,简单的说Transform就是一个输入文件集合Collection<TransformInput>一个输出文件集合TransformOutputProvider的过程。我们先读取原始的class jar,然后我们自己对其进行加工之后生成好另外一部分class jar,最后把这个Transform的输出产物当作下一个Transform的输入产物。当class+jar输入的时候,我会先把整个流数据进行一次copy操作,然后对这个temp文件进行加工,如果asm操作完了,我们就将文件进行覆盖操作。

而在增量编译的情况下,输入流就会发生轻微的变更,TransformInput会告诉我们其中变更的类是什么,其中变更被定义为三种,无论是Jar还是Class都是一样的。

  1. NOTCHANGED 当前文件不需要处理跳过就行了。
  2. ADDED、CHANGED 因为我们都是先用 temp 然后覆盖当前文件,所以采用同样的处理方式。
  3. REMOVED 删除当前文件夹下的该历史文件。

所以当增编被调用的情况下,我们只是对于上述这四种不同的操作符号进行不同的处理就好了,只要几个if else就能搞定了。

而一般我们在使用asm的时候,我们都只会操作Class文件,然后根据class的文件名+路径对其进行一次简单的判断,当前类是不是我们需要做插桩或者扫描操作的,然后我们会读取这个文件byte数组,之后在完成asm操作之后返回一个byte数组,之后覆盖掉原始文件。那么其实我在这里就对其进行了第一次的抽象,asm操作被我定义成了一个接口。

packagecom.kronos.plugin.base

interfaceTransformCallBack{

funprocess(className: String, classBytes: ByteArray?) : ByteArray?

}

这个接口只负责接受一个文件名和一个byte数组,然后方法结束返回一个byte数组就行了。如果byte数组非空的情况下,代表当前类被进行了字节码修改操作,然后我们只要把这个文件进行一次覆盖操作就可以了。进行了这个抽象,我们就可以把上面的文件操作和ASM操作进行一次整合,sdk使用者只需要对这个接口负责就好了。

那么剩下来我们需要做的就是对这部分文件的写入进行封装了。我是怎么做的呢?我参考了另外一个大佬的多线程优化transform的思路,大佬的项目地址Leaking / Hunter

https://github.com/Leaking/Hunter

  1. 所有的输入文件先进行第一次文件拷贝操作
  2. forecah 遍历将每一个文件操作压入线程池中执行
  3. 获取文件名以及 byte 数组 调用我们定义的抽象接口
  4. 根据 interface 返回的 byte 生成 temp 文件,然后进行文件覆盖操作
  5. 线程池等待所有任务执行完成之后结束 transform

DoubleTap的编译速度优化

原来的DoubleTap plugin是整个项目的代码进行扫描的,虽然完成了增量编译功能,同时我也过滤了很多无效扫描的逻辑,但是其实还是会拖慢整个编译速度的。一直到我前一阵子学习了另外一个大佬的一个StringFog项目的时候,发现大佬的常量加密的Transform,可以直接对Module生效。

https://github.com/MegatronKing/StringFog

代码地址 https://github.com/Leifzhang/AndroidAutoTrack/tree/master/Plugin/double_tap_plugin/src/main/java/com/kronos/doubletap

publicclassDoubleTapPluginimplementsPlugin< Project> {

privatestaticfinalString EXT_NAME = “doubleTab”;

@Override

publicvoidapply(Project project){

booleanisApp = project.getPlugins.hasPlugin(AppPlugin.class);

project.getExtensions.create(EXT_NAME, DoubleTabConfig.class);

project.afterEvaluate(project1 -> {

DoubleTabConfig config = (DoubleTabConfig) project1.getExtensions.findByName(EXT_NAME);

if(config == null) {

config = newDoubleTabConfig;

}

config.transform;

});

if(isApp) {

AppExtension appExtension = project.getExtensions.getByType(AppExtension.class);

appExtension.registerTransform( newDoubleTapAppTransform);

return;

}

if(project.getPlugins.hasPlugin( “com.android.library”)) {

LibraryExtension libraryExtension = project.getExtensions.getByType(LibraryExtension.class);

libraryExtension.registerTransform( newDoubleTapLibraryTransform);

}

}

}

以前我在写的时候一般只会给AppExtension注册一个Transform,而在LibraryExtension同样也可以注册一个Transform。在LibraryExtension上注册的会让这部分字节码操作被使用在使用了这个PluginModule上。

小贴士: 这个Transform同样会对Aar生效哦,不仅仅是本地产物。

而这个Transform的代码上最大的差别就是,其中的输入产物和类型有差别以外,其实别的代码全是一样的。

classDoubleTapLibraryTransform: DoubleTapTransform{

overridefungetScopes: MutableSet< inQualifiedContent.Scope> {

returnImmutableSet.of(

QualifiedContent.Scope.PROJECT

)

}

overridefungetInputTypes: Set<QualifiedContent.ContentType>? {

returnTransformManager.CONTENT_CLASS

}

}

我个人的一个小看法哦,如果只是一个需要针对模块内修改的话,那么你完全不需要写一个全局操作的Transform,只需要对每个Module进行操作就好了。这样有几个好处,扫描速度会变得更快,因为我们不需要操作无关的Jar。另外如果Module没有变更的情况下就不会参与编译,可以变得更快。

3

自动化埋点的参数传递

我在写自动化埋点Demo的时候,一直没有特别好的解决关于参数的问题。以前留了个小坑,只能使用匿名内部类内定义的属性,而如果是外部类的话,因为asm中的ClassVisitor写起来,其实我感觉很不舒服,其原理都是基于事件的。当一个方法被触发之后你要记录下相关值,然后在另外一个函数内进行插入操作。

之前在做ThreadPoolHook的时候了解到滴滴的Booster内的asm用的都是ClassNode,这里我先简单的说下ClassNode好了。

ClassNode简介

如果你仔细读了关于字节码的文章后,你应该会知道Java中当一个方法被调用时会产生一个栈帧(Stack Frame),可以理解为那个方法被包含在了这个栈帧里,栈帧包括3个部分,局部变量区,操作数栈区和帧数据区.接下来我们主要要用到的是局部变量区和操作数栈区.

一般一句简单的java代码,被翻译成字节码的情况下复杂度都会翻好几倍,其中特别是Java字节码的栈帧。给一个方法传递参数,就是压栈的操作,所以当用ClassVisitor直接操作的时候,我想要修改一行代码,其实难度都非常大。

ClassNodeClassVisitor的一个实现类,相比较于ClassVisitor,ClassNode已经存储记录了所有的ClassVisitor信息,构建好了语法树,包括方法内的代码以及行号,还有当前的类属性,类信息等等。其核心就是牺牲了内存,但是由于记录了所有类信息,所以对于复杂的多类联动的操作,会更加方便实用。

不过TreeAPI比CoreAPI慢30%左右,内存占用也高。

修改Class,我们只需使用ClassTransformer,然后在transform方法中修改对应的ClassNode即可。使用TreeAPICoreAPI更耗时,内存占用也多,但是对于某些复杂的修改也相对简单。treeAPI被设计用于那些使用coreAPI一遍解析无法完成,需要解析多次的场景。

这部分如果大家有兴趣详细了解的话可以看下这篇文章啊,Java字节码(Bytecode)与ASM简单说明。

http://blog.hakugyokurou.net/?p=409

ClassNode传入参数

好了 show me the code 吧

classAutoTrackHelper: AsmHelper {

privatevalclassNodeMap = hashMapOf<String, ClassNode>

@Throws(IOException::class)

overridefunmodifyClass(srcClass: ByteArray) : ByteArray {

valclassNode = ClassNode(ASM5)

valclassReader = ClassReader(srcClass)

//1 将读入的字节转为classNode

classReader.accept(classNode, 0)

classNodeMap[classNode.name] = classNode

// 判断当前类是否实现了OnClickListener接口

classNode.interfaces?.forEach {

if(it == “android/view/View$OnClickListener”) {

valfield = classNode.getField

classNode.methods?.forEach { method ->

// 找到onClick 方法

insertTrack(classNode, method, field)

}

}

}

//调用Fragment的onHiddenChange方法

visitFragment(classNode)

valclassWriter = ClassWriter( 0)

//3 将classNode转为字节数组

classNode.accept(classWriter)

returnclassWriter.toByteArray

}

privatefuninsertTrack(node: ClassNode, method: MethodNode, field: FieldNode?) {

// 判断方法名和方法描述

if(method.name == “onClick”&& method.desc == “(Landroid/view/View;)V”) {

valclassName = node.outerClass

valparentNode = classNodeMap[className]

// 根据outClassName 获取到外部类的Node

valparentField = field ?: parentNode?.getField

valinstructions = method.instructions

instructions?.iterator?.forEach {

// 判断是不是代码的截止点

if((it.opcode >= Opcodes.IRETURN && it.opcode <= Opcodes.RETURN) || it.opcode == Opcodes.ATHROW) {

instructions.insertBefore(it, VarInsnNode(Opcodes.ALOAD, 1))

instructions.insertBefore(it, VarInsnNode(Opcodes.ALOAD, 1))

// 获取到数据参数

if(parentField != null) {

parentField.apply {

instructions.insertBefore(it, VarInsnNode(Opcodes.ALOAD, 0))

instructions.insertBefore(

it, FieldInsnNode(Opcodes.GETFIELD, node.name, parentField.name, parentField.desc)

)

}

} else{

instructions.insertBefore(it, LdcInsnNode( “1234”))

}

instructions.insertBefore(

it, MethodInsnNode(

Opcodes.INVOKESTATIC,

“com/wallstreetcn/sample/ToastHelper”,

“toast”,

“(Ljava/lang/Object;Landroid/view/View;Ljava/lang/Object;)V”,

false

)

)

}

}

}

}

// 判断Field是否包含注解

privatefunClassNode. getField: FieldNode? {

returnfields?.firstOrNull { field ->

varhasAnnotation = false

field?.visibleAnnotations?.forEach { annotation->

if( annotation.desc == “Lcom/wallstreetcn/sample/adapter/Test;”) {

hasAnnotation = true

}

}

hasAnnotation

}

}

}

写个更牛逼的Transform | Plugin 进阶教程

这次我顺便给大家画了一个这部分逻辑的流程图,方便大家可以搞懂这部分代码。这里顺便给大家展开下我之前用ClassVisitor的痛苦吧,这个地方可能是我的操作方式有问题哦。asm操作的是.class文件,每一个内部类其实都是.class文件,这部分扫描都是单独的,如果你要用内部类去访问一些外部类的Field,我是完全没办法的。因为两个类的实例都不同,然后我整个人都感觉有点裂开了。

这次使用ClassNode,我用HashMap保存了大部分类的ClassNode,然后通过outClassName,去获取到ClassNode实例,然后就可以对其进行修改了。

上面基本上就是我所有的插桩的代码了,其实基本上都是字符串匹配之类的,只是因为bytecode上的和java的不一样,而且bytecode的可读性比较差了点,之前也安利过大家asm bytecode viewer。还是很香的。

4

总结

我的这个github项目,其实断断续续也写了大概三年了。从第一个版本的只能照着别人代码抄,然后每次调试只能发一个本地aar,然后从新编译调试。到后面双击的时候完成了增量编译以及buildSrc编译的能力。然后年初的时候我完成了ThreadPool HookTransform, 完成了字节码替换调用类,主要是拿来解决线上稳定性问题。年底的时候我更换了下编译模式,最近我把原来最早的AutoTrack完成了重构以及把参数传递。

我觉得这个项目其实也算是见证了我的技术成长了吧,我不认为我是一个天赋异禀的人,我觉得人有时候努努力,逼一逼自己还是能学会一些你之前完全不了解的东西的。

文章主要要演示的代码都在github上

https://github.com/Leifzhang/AndroidAutoTrack

原创文章版权所有,如需转载,请注明出处。http://www.qilurongmei.com/archives/4293

为您推荐

发表评论

邮箱地址不会被公开。 必填项已用*标注