自定义插件涉及到几个知识点,比如Gradle构建工具、Groovy语法、Gradle插件开发流程等等。这些知识我就默认大家都知道了。想学习或温习的可以参考:
这个插件用来做什么?
试想一下我们代码埋点的过程:首先定位到事件响应函数,例如Button的onClick函数,然后在该事件响应函数中调用SDK数据搜集接口。
Button btnLogin = (Button) findViewById(R.id.btn_login);
btnLogin.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
...
NPTracker.getInstance().trackEvent(Constant.EVENT_LOGIN_METHOD);
}
});
下面,我们介绍使用gradle插件自动在目标响应函数中插入SDK数据搜集代码,达到自动埋点的目的。本文对此的实战即通过字节码插桩,在class文件编译成dex之前(同时也是proguard操作之前),遍历所有要编译的class文件并对其中符合条件的方法进行修改,注入我们要调用的SDK数据搜集代码,从而实现自动埋点的目的。
插件是如何与应用关联起来的?
为了方便开发,新建一个Android项目,然后开发只针对当前项目的Gradle插件。针对当前项目开发Gradle插件相对较简单,但必须注意的是:
新建的Module名称必须为BuildSrc
目录结构示意如下:
然后自定义Gradle插件类:
package com.codeless.plugin
import com.android.build.gradle.BaseExtension
import com.codeless.plugin.utils.DataHelper
import com.codeless.plugin.utils.Log
import org.gradle.api.Plugin
import org.gradle.api.Project
class InjectPluginImpl implements Plugin<Project> {
@Override
void apply(Project project) {
project.extensions.create('codelessConfig', InjectPluginParams)
registerTransform(project)
...
}
...
}
然后在app module的build.gradle
中引入自定义插件:
apply plugin: com.codeless.plugin.InjectPluginImpl
codelessConfig {
//this will determine the name of this plugin transform, no practical use.
pluginName = 'myPluginTest'
//turn this on to make it print help content, default value is true
showHelp = true
//this flag will decide whether the log of the modifying process be printed or not, default value is false
keepQuiet = false
//this is a kit feature of the plugin, set it true to see the time consume of this build
watchTimeConsume = false
//this is the most important part, 3rd party JAR packages that want our plugin to inject;
//our plugin will inject package defined in 'AndroidManifest.xml' and 'butterknife.internal.butterknife.internal.DebouncingOnClickListener' by default.
//structure is like ['butterknife.internal','com.a.c'], type is HashSet<String>.
//You can also specify the name of the class;
//example: ['com.xxx.xxx.BaseFragment']
targetPackages = ['okhttp3']
}
可能有人对apply plugin: com.codeless.plugin.InjectPluginImpl
不是很理解,没关系,那下面这个呢:
apply plugin: 'com.android.library' <==如果是编译 Library,则加载此插件
apply plugin: 'com.android.application' <==如果是编译 Android APP,则加载此插件
Project 的 API 请戳 Project。apply 其实是 Project 实现的 PluginAware 接口定义的:
apply函数接收多种参数,上述用法调用的其实是void apply(Map<String,?> options)
:
plugin 作为Map Key,表示加载指定id的插件。
回到apply plugin: com.codeless.plugin.InjectPluginImpl
,它表示加载id为com.codeless.plugin.InjectPluginImpl
的插件,也就是我们的自定义插件。
apply plugin: com.codeless.plugin.InjectPluginImpl
这一行会导致直接执行com.codeless.plugin.InjectPluginImpl
插件的void apply(Project project)
方法,传入app module的build.gradle
对应的 Project 对象。
知识拓展:
每一个
build.gradle
文件都会转换成一个 Project 对象。通过 Project 对象可以访问到build.gradle
中的各种配置。
拿到了app module的build.gradle
对应的 Project 对象,自然就能通过 project.codelessConfig
访问传入的一些配置项喽。例如:
project.afterEvaluate {
Log.setQuiet(project.codelessConfig.keepQuiet);
Log.setShowHelp(project.codelessConfig.showHelp);
Log.logHelp();
if (project.codelessConfig.watchTimeConsume) {
Log.info "watchTimeConsume enabled"
project.gradle.addListener(new TimeListener())
} else {
Log.info "watchTimeConsume disabled"
}
}
讲到这里,相信大家已经清楚,我们的自定义插件是如何应用到app module的了吧。
Transform代码注入原理
从1.5.0-beta1
开始,android的gradle插件引入了com.android.build.api.transform.Transform
(transform-api),可以用于在android 打包、class转换成dex过程中,加入开发者自定义的处理逻辑。
初识Transform
Transform的工作流程
Transform每次都是将一个输入进行处理,然后将处理结果输出,而输出的结果将会作为另一个Transform的输入。
理解Transform需要先了解一些概念:
概念 | 描述 |
---|---|
TransformInput | 输入文件:DirectoryInput集合与JarInput集合 |
DirectoryInput | 以源码方式参与项目编译的所有目录结构及其目录下的源码文件 |
JarInput | 以jar包方式参与项目编译的所有本地jar包或远程jar包 |
TransformOutputProvider | Transform的输出 |
Scope | 作用域:PROJECT、SUB_PROJECTS、EXTERNAL_LIBRARIES等 |
ContentType | 文件的类型:CLASSES、RESOURCES、DEX、NATIVE_LIBS等 |
知识拓展:
通过 Scope 和 ContentType 可以组成一个资源流。例如,PROJECT 和 CLASSES,表示了主项目中java 编译成的 class 组成的一个资源流。再如,SUB_PROJECTS 和 CLASSES ,表示的是本地子项目中的 java 编译成的 class 组成的一个资源流。Transform 用来处理和转换这些流。
Transform是一个抽象类,我们需要自定义一个Transform,并实现必要的几个方法:
public class InjectTransform extends Transform {
@Override
public String getName() {
return "xxxxxx";
}
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
// 配置 Transform 的输入类型为 Class
return TransformManager.CONTENT_CLASS;
}
@Override
public Set<QualifiedContent.Scope> getScopes() {
// 配置 Transform 的作用域为全工程
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
public boolean isIncremental() {
return false;
}
@Override
public void transform(
@NonNull Context context,
@NonNull Collection<TransformInput> inputs,
@NonNull Collection<TransformInput> referencedInputs,
@Nullable TransformOutputProvider outputProvider,
boolean isIncremental) throws IOException, TransformException, InterruptedException {
// ...
}
}
下面一一解释这几个方法:
-
getName
指明本Transform的名字,随意
-
getInputTypes
指明Transform的输入类型,例如,返回 TransformManager.CONTENT_CLASS 表示配置 Transform 的输入类型为 Class。
-
getScopes
指明Transform的作用域,例如,返回 TransformManager.SCOPE_FULL_PROJECT 表示配置 Transform 的作用域为全工程。
-
isIncremental
指明是否是增量构建
-
transform
用于处理具体的输入输出,核心操作都在这里。上例中,配置 Transform 的输入类型为 Class, 作用域为全工程,因此在
transform
方法中,inputs 会传入工程内所有的 class 文件。
定义好 Transform 后,接下来要在自定义的Plugin中注册该Transform,从而添加到android编译流程中。
class InjectPluginImpl implements Plugin<Project> {
@Override
void apply(Project project) {
...
registerTransform(project)
}
def static registerTransform(Project project) {
BaseExtension android = project.extensions.getByType(BaseExtension)
InjectTransform transform = new InjectTransform(project)
android.registerTransform(transform)
}
}
Transform的工作原理
Gradle 包中有一个 TransformManager 的类,用来管理所有的 Transform,其中,TransformManager 包含addTransform
方法:
public <T extends Transform> AndroidTask<TransformTask> addTransform(
@NonNull TaskFactory taskFactory,
@NonNull TransformVariantScope scope,
@NonNull T transform,
@Nullable TransformTask.ConfigActionCallback<T> callback) {
...
transforms.add(transform);
// create the task...
AndroidTask<TransformTask> task = taskRegistry.create(
taskFactory,
new TransformTask.ConfigAction<>(
scope.getFullVariantName(),
taskName,
transform,
inputStreams,
referencedStreams,
outputStream,
callback));
...
return task;
}
}
}
显然,addTransform
方法在执行的过程中,会将 T (T extends Transform) 包装成一个 TransformTask 对象,并进一步包装成AndroidTask对象。所以可以理解为一个 Transform 就是一个 Task。该 Task 执行时,gradle引擎会去调用含有@TaskAction注解的方法,TransformTask类拥有Transfrom类型字段,其transform
方法被标记为@TaskAction,且TransformTask的transform
方法最终调用了Transfrom的transform
方法。
/**
* A task running a transform.
*/
@ParallelizableTask
public class TransformTask extends StreamBasedTask implements Context {
private Transform transform;
Collection<SecondaryFile> secondaryFiles = null;
public Transform getTransform() {
return transform;
}
...
@TaskAction
void transform(final IncrementalTaskInputs incrementalTaskInputs)
throws IOException, TransformException, InterruptedException {
...
ThreadRecorder.get().record(ExecutionType.TASK_TRANSFORM, executionInfo,
getProject().getPath(), getVariantName(), new Recorder.Block<Void>() {
@Override
public Void call() throws Exception {
transform.transform(new TransformInvocationBuilder(TransformTask.this)
.addInputs(consumedInputs.getValue())
.addReferencedInputs(referencedInputs.getValue())
.addSecondaryInputs(changedSecondaryInputs.getValue())
.addOutputProvider(outputStream != null
? outputStream.asOutput()
: null)
.setIncrementalMode(isIncremental.getValue())
.build());
return null;
}
});
}
}
Gradle 的包中有一个 TaskManager 类,管理所有的 Task 执行。 其中有一个createPostCompilationTasks
方法:
public void createPostCompilationTasks(
@NonNull TaskFactory tasks,
@NonNull final VariantScope variantScope) {
...
// ----- External Transforms -----
// 添加自定义的 Transform
List<Transform> customTransforms = extension.getTransforms();
List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();
for (int i = 0, count = customTransforms.size() ; i < count ; i++) {
Transform transform = customTransforms.get(i);
AndroidTask<TransformTask> task = transformManager
.addTransform(tasks, variantScope, transform);
...
}
...
// ----- Minify next -----
// minifyEnabled 为 true 表示开启混淆
// 添加 Proguard Transform
if (isMinifyEnabled) {
boolean outputToJarFile = isMultiDexEnabled && isLegacyMultiDexMode;
createMinifyTransform(tasks, variantScope, outputToJarFile);
}
...
// non Library test are running as native multi-dex
if (isMultiDexEnabled && isLegacyMultiDexMode) {
...
// 添加 JarMergeTransform
// create a transform to jar the inputs into a single jar.
if (!isMinifyEnabled) {
// merge the classes only, no need to package the resources since they are
// not used during the computation.
JarMergingTransform jarMergingTransform = new JarMergingTransform(
TransformManager.SCOPE_FULL_PROJECT);
variantScope.addColdSwapBuildTask(
transformManager.addTransform(tasks, variantScope, jarMergingTransform));
}
// 添加 MultiDex Transform
// create the transform that's going to take the code and the proguard keep list
// from above and compute the main class list.
MultiDexTransform multiDexTransform = new MultiDexTransform(
variantScope,
extension.getDexOptions(),
null);
multiDexClassListTask = transformManager.addTransform(
tasks, variantScope, multiDexTransform);
multiDexClassListTask.optionalDependsOn(tasks, manifestKeepListTask);
variantScope.addColdSwapBuildTask(multiDexClassListTask);
}
...
// 添加 Dex Transform
// create dex transform
DefaultDexOptions dexOptions = DefaultDexOptions.copyOf(extension.getDexOptions());
...
}
该方法在 javaCompile 之后调用, 会遍历所有的 Transform,然后一一添加进 TransformManager。 添加完自定义的 Transform 之后,再添加 Proguard, JarMergeTransform, MultiDex, Dex 等 Transform。所以,现在应该清楚为什么Transform可以在编译之后、class转换成dex之前,加入开发者自定义的处理逻辑了吧。
原理篇就讲这么多了,欲知更多,可以参考:
注入代码的时机
我们可以先看一个transform
的例子,它将不做任何处理,只是将输入原样输出:
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
// 配置 Transform 的输入类型为 Class
return TransformManager.CONTENT_CLASS;
}
@Override
public Set<QualifiedContent.Scope> getScopes() {
// 配置 Transform 的作用域为全工程
return TransformManager.SCOPE_FULL_PROJECT;
}
@Override
void transform(Context context, Collection<TransformInput> inputs,
Collection<TransformInput> referencedInputs,
TransformOutputProvider outputProvider, boolean isIncremental)
throws IOException, TransformException, InterruptedException {
// Transform的inputs有两种类型,一种是目录,一种是jar包,要分开遍历
inputs.each { TransformInput input ->
//对类型为“文件夹”的input进行遍历
input.directoryInputs.each { DirectoryInput directoryInput ->
//文件夹里面包含的是我们手写的类以及R.class、BuildConfig.class以及R$XXX.class等
// 获取output目录
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY)
// 将input的目录复制到output指定目录
FileUtils.copyDirectory(directoryInput.file, dest)
}
//对类型为jar文件的input进行遍历
input.jarInputs.each { JarInput jarInput ->
//jar文件一般是第三方依赖库jar文件
// 重命名输出文件(同目录copyFile会冲突)
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
//生成输出路径
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
//将输入内容复制到输出
FileUtils.copyFile(jarInput.file, dest)
}
}
}
在这个例子中,由于配置了 Transform 的输入类型为 Class, 作用域为全工程,因此在transform
方法中,inputs 会传入工程内所有的 class 文件。inputs 为Collection<TransformInput> 集合对象,集合元素 TransformInput 定义如下:
public interface TransformInput {
/**
* Returns a collection of {@link JarInput}.
*/
@NonNull
Collection<JarInput> getJarInputs();
/**
* Returns a collection of {@link DirectoryInput}.
*/
@NonNull
Collection<DirectoryInput> getDirectoryInputs();
}
看接口方法可知,inputs 包含了 jar 包和目录。也就是说,transform
方法可以遍历到源码目录中java类对应的class文件,也可以遍历到第三方jar包内的class文件。辛辛苦苦摸索到这里,想修改的class文件就在眼前了,此时不注入代码更待何时呢?如下图所示,我们可以分别遍历源码目录&jar包,对满足修改条件的class文件或jar包进行修改,将修改后的文件输出:
ASM实现无埋点
通过前面的分析,我们已经知道在哪里修改class文件了,然后发现又走不下去了,class文件怎么修改呢?难道我们要自己手动编写字节码指令修改二进制文件?好在有ASM这个库,至于ASM语法等知识并非本文的重点,所以,本文只讨论无埋点插件中涉及的部分。需要学习或温习的请猛戳系列博文:
- 深入字节码 -- 使用 ASM 实现 AOP
- 深入字节码 -- 玩转 ASM-Bytecode
- 深入字节码 -- ASM 关键接口 ClassVisitor
- 深入字节码 -- ASM 关键接口 MethodVisitor
ASM关键知识点
ASM被称为是类的扫描器,它可以扫描到组成一个类的各个结构:
- 描述类访问控制权限,类名,父类,接口和注解
- 每个被声明的成员变量,同样包括访问控制权限、名称、类型、注解
- 方法及构造函数,包括访问控制权限,名称、名称、返回值类型、参数类型、注解等;同时还包含方法体
ASM对class的生成和转换是基于ClassVisitor抽象类的,该类的每个方法都对应class的一个结构,它的完整接口如下:
下面重点介绍我们需要用到的几个方法:
-
void visit(int version, int access, String name,
String signature, String superName, String[] interfaces)该方法是当扫描类时第一个访问的方法。各参数代表的含义是:类版本、修饰符、类名、泛型信息、继承的父类、实现的接口。我们只需关心继承的父类和实现的接口,当执行到 visit 方法时,可以通过全局变量保存继承的父类和实现的接口信息。代码示例如下:
static class MethodFilterClassVisitor extends ClassVisitor { private String superName private String[] interfaces private ClassVisitor classVisitor public MethodFilterClassVisitor( final ClassVisitor cv) { super(Opcodes.ASM5, cv); this.classVisitor = cv } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces); this.superName = superName // 记录该类实现了哪些接口 this.interfaces = interfaces super.visit(version, access, name, signature, superName, interfaces); } }
-
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)
当扫描器扫描到类的方法时调用该方法。各参数代表的含义是:修饰符、方法名、方法签名、泛型信息、抛出的异常。其中,方法签名的格式如下:(参数列表)返回值类型;例如
void onClick(View v)
的方法签名为(Landroid/view/View;)V
。 -
visitEnd()
当扫描器完成类扫描时才会调用该方法。
当ASM的ClassReader读取到Method时就转入MethodVisitor接口处理。方法的定义,以及方法中指令的定义都会通过MethodVisitor接口通知给程序。MethodVisitor的具体实现由ClassVisitor的visitMethod返回值指定。下面是MethodVisitor接口的所有方法定义:
由于我们的无埋点目前只支持在方法体头部或尾部插入埋点代码,因此我们只需关心下面两个方法:
-
visitCode()
表示ASM开始扫描这个方法。
-
visitEnd()
表示方法输出完毕。
ASM-Bytecode工具
由于 JVM 对字节码十分敏感,修改过程中稍微有一丝错误都会导致虚拟机错误,而想要排查错误却是一件比较困难的事情。因此不建议大家手动编写ASM 代码,而是借助 ASM-Bytecode 工具。
ASM-Bytecode 是一个Eclipse插件,插件的地址请戳ASM-Bytecode。
也可通过Eclipse Marketplace安装(推荐)
插件效果如下:
ASM无埋点实例
比如我们希望向View的onClick方法里插入自定义的代码:
com.codeless.tracker.PluginAgent.onClick(v);
假设插入前是这样子的:
public class MainActivity extends AppCompatActivity implements
View.OnClickListener{
@Override
public void onClick(View v) {
}
}
插入后的期望代码:
package com.codeless.demo;
public class MainActivity extends AppCompatActivity implements
View.OnClickListener{
@Override
public void onClick(View v) {
PluginAgent.onClick(var1);
}
}
最便捷的方法是,使用 ASM-Bytecode 工具将插入后的期望代码翻译成 ASM 代码:
mv.visitVarInsn(ALOAD, 1);
mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 2);
mv.visitEnd();
从而得到
com.codeless.tracker.PluginAgent.onClick(v);
对应的 ASM 代码语句如下
mv.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
然后,使用 ClassVisitor 扫描源码class文件(当然也包含MainActivity.class啦),将该语句插到void onClick(View v)
方法体头部。具体操作请留意代码注释:
public class MethodFilterClassVisitor extends ClassVisitor {
private String superName
private String[] interfaces
private ClassVisitor classVisitor
public MethodFilterClassVisitor(
final ClassVisitor cv) {
super(Opcodes.ASM5, cv);
this.classVisitor = cv
}
@Override
public void visit(int version, int access, String name,
String signature, String superName, String[] interfaces) {
Log.logEach('* visit *', Log.accCode2String(access), name, signature, superName, interfaces);
this.superName = superName
// 记录该类实现了哪些接口
this.interfaces = interfaces
super.visit(version, access, name, signature, superName, interfaces);
}
@Override
public MethodVisitor visitMethod(int access, String name,
String desc, String signature, String[] exceptions) {
MethodVisitor myMv = null;
if (interfaces != null && interfaces.length > 0) {
// 根据方法名+方法描述判断是否需要修改方法体;
// 例如,当前遍历到View的onClick方法时,name是onClick,desc是(Landroid/view/View;)V;
// 则满足修改条件onClick(Landroid/view/View;)V
// 当第一个条件满足后,还需进一步判断当前类是否实现了View$OnClickListener接口
if ('onClick(Landroid/view/View;)V' == (name + desc) && interfaces.contains('android/view/View$OnClickListener')) {
try {
MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
myMv = new MethodLogVisitor(methodVisitor) {
@Override
void visitCode() {
super.visitCode();
// 在方法体开头插入自定义埋点代码
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/codeless/tracker/PluginAgent", "onClick", "(Landroid/view/View;)V", false);
}
}
} catch (Exception e) {
e.printStackTrace();
myMv = null
}
}
}
if (myMv != null) {
return myMv;
} else {
return cv.visitMethod(access, name, desc, signature, exceptions);
}
}
}
总结一下上面的例子,就是当一个Activity
或Fragment
实现了View$OnClickListener
接口,使用插件遍历到该Activity
或Fragment
字节码中的onClick(View v)
时,向该方法中插入com.codeless.tracker.PluginAgent.onClick(v)
。com.codeless.tracker.PluginAgent中的onClick(View v)
方法即是您想要注入到点击事件响应onClick中的代码。
上面只是简化版的例子,完整项目代码要比这个复杂和丰富一些,有兴趣欢迎forkLazierTracker查看完整代码。
分析到这里,不知道大家清楚了没,如果还有疑问或者有好的建议,可以在LazierTracker下面提issue,就酱。