我是靠谱客的博主 忧郁金鱼,最近开发中收集的这篇文章主要介绍dex工具与transform_一起玩转Android项目中的字节码(Transform篇),觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

原标题:一起玩转Android项目中的字节码(Transform篇)

作者:Quinn Chen

http://quinnchen.me/2018/09/13/2018-09-13-asm-transform/

作为Android开发,日常写Java代码之余,是否想过,玩玩class文件?直接对class文件的字节码下手,我们可以做很多好玩的事情,比如:

对全局所有class插桩,做UI,内存,网络等等方面的性能监控

发现某个第三方依赖,用起来不爽,但是不想拿它的源码修改再重新编译,而想对它的class直接做点手脚

每次写打log时,想让TAG自动生成,让它默认就是当前类的名称,甚至你想让log里自动加上当前代码所在的行数,更方便定位日志位置

Java自带的动态代理太弱了,只能对接口类做动态代理,而我们想对任何类做动态代理

为了实现上面这些想法,可能我们最开始的第一反应,都是能否通过代码生成技术、APT,抑或反射、抑或动态代理来实现,但是想来想去,貌似这些方案都不能很好满足上面的需求,而且,有些问题不能从Java文件入手,而应该从class文件寻找突破。而从class文件入手,我们就不得不来近距离接触一下字节码!

JVM平台上,修改、生成字节码无处不在,从ORM框架(如Hibernate, MyBatis)到Mock框架(如Mockio),再到Java Web中的常青树Spring框架,再到新兴的JVM语言Kotlin的编译器,还有大名鼎鼎的cglib项目,都有字节码的身影。

字节码相关技术的强大之处自然不用多说,而且在Android开发中,无论是使用Java开发和Kotlin开发,都是JVM平台的语言,所以如果我们在Android开发中,使用字节码技术做一下hack,还可以天然地兼容Java和Kotlin语言。

近来我对字节码技术在Android上的应用做了一些调研和实践,顺便做了几个小轮子,项目地址:Hunter

Hunter: 一个插件框架,在它的基础上可以快速开发一个并发、增量的字节码编译插件,帮助开发人员隐藏了Transform和ASM的绝大部分逻辑,开发者只需写少量的ASM code,就可以开发一款编译插件,修改Android项目的字节码。 在上面框架基础上,我还开发了几个小工具

OkHttp-Plugin: 可以为你的应用所有的OkhttpClient设置全局 Interceptor / Eventlistener, (包括第三方依赖里的OkhttpClient),借助这个插件,可以轻松实现全局网络监控。

Timing-Plugin: 帮你监控所有UI线程的执行耗时,并且提供了算法,帮你打印出一个带有每步耗时的堆栈,统计卡顿方法分布,你也可以自定义分析卡顿堆栈的方式。

LogLine-Plugin: 为你的日志加上行号

Debug-Plugin: 只要为指定方法加上某个annotation,就可以帮你打印出这个方法所有输入参数的值,以及返回值和执行时间(其实,JakeWharton的hugo用AspectJ实现了类似功能,而我的实现方式是基于ASM,ASM处理字节码的速度更快)

你可以在这里查看我想继续开发的一些插件 TODO,另外,欢迎你提供你宝贵的idea 今天写这篇文章,分享自己摸索相关技术和开发这个项目过程中的一些积累。

这个项目主要使用的技术是Android gradle插件,Transform,ASM与字节码基础。这篇文章将主要围绕以下几个技术点展开:

Transform的应用、原理、优化

ASM的应用,开发流,以及与Android工程的适配

几个具体应用案例 所以阅读这篇文章,读者最好有Android开发以及编写简单Gradle插件的背景知识。

话不多说,让我们开始吧。

一、Transform引入Transform

Transform是Android gradle plugin 1.5开始引入的概念。

我们先从如何引入Transform依赖说起,首先我们需要编写一个自定义插件,然后在插件中注册一个自定义Transform。这其中我们需要先通过gradle引入Transform的依赖,这里有一个坑,Transform的库最开始是独立的,后来从2.0.0版本开始,被归入了Android编译系统依赖的gradle-api中,让我们看看Transform在jcenter上的历个版本。

所以,很久很久以前我引入transform依赖是这样

compile'com.android.tools.build:transform-api:1.5.0'

现在是这样

//从2.0.0版本开始就是在gradle-api中了

implementation'com.android.tools.build:gradle-api:3.1.4'

然后,让我们在自定义插件中注册一个自定义Transform,gradle插件可以使用java,groovy,kotlin编写,我这里选择使用java。

publicclassCustomPluginimplementsPlugin{

@SuppressWarnings( "NullableProblems")

@Override

publicvoidapply(Project project){

AppExtension appExtension = (AppExtension)project.getProperties().get( "android");

appExtension.registerTransform( newCustomTransform(), Collections.EMPTY_LIST);

}

}

那么如何写一个自定义Transform呢?

Transform的原理与应用

介绍如何应用Transform之前,我们先介绍Transform的原理,一图胜千言

每个Transform其实都是一个gradle task,Android编译器中的TaskManager将每个Transform串连起来,第一个Transform接收来自javac编译的结果,以及已经拉取到在本地的第三方依赖(jar. aar),还有resource资源,注意,这里的resource并非android项目中的res资源,而是asset目录下的资源。这些编译的中间产物,在Transform组成的链条上流动,每个Transform节点可以对class进行处理再传递给下一个Transform。我们常见的混淆,Desugar等逻辑,它们的实现如今都是封装在一个个Transform中,而我们自定义的Transform,会插入到这个Transform链条的最前面。

但其实,上面这幅图,只是展示Transform的其中一种情况。而Transform其实可以有两种输入,一种是消费型的,当前Transform需要将消费型型输出给下一个Transform,另一种是引用型的,当前Transform可以读取这些输入,而不需要输出给下一个Transform,比如Instant Run就是通过这种方式,检查两次编译之间的diff的。至于怎么在一个Transform中声明两种输入,以及怎么处理两种输入,后面将有示例代码。

为了印证Transform的工作原理和应用方式,我们也可以从Android gradle plugin源码入手找出证据,在TaskManager中,有一个方法createPostCompilationTasks.为了避免贴篇幅太长的源码,这里附上链接

TaskManager#createPostCompilationTasks

这个方法的脉络很清晰,我们可以看到,Jacoco,Desugar,MergeJavaRes,AdvancedProfiling,Shrinker,Proguard, JarMergeTransform, MultiDex, Dex都是通过Transform的形式一个个串联起来。其中也有将我们自定义的Transform插进去。

讲完了Transform的数据流动的原理,我们再来介绍一下Transform的输入数据的过滤机制,Transform的数据输入,可以通过Scope和ContentType两个维度进行过滤。

ContentType,顾名思义,就是数据类型,在插件开发中,我们一般只能使用CLASSES和RESOURCES两种类型,注意,其中的CLASSES已经包含了class文件和jar文件

从图中可以看到,除了CLASSES和RESOURCES,还有一些我们开发过程无法使用的类型,比如DEX文件,这些隐藏类型在一个独立的枚举类ExtendedContentType中,这些类型只能给Android编译器使用。另外,我们一般使用TransformManager中提供的几个常用的ContentType集合和Scope集合,如果是要处理所有class和jar的字节码,ContentType我们一般使用TransformManager.CONTENT_CLASS。

Scope相比ContentType则是另一个维度的过滤规则,

我们可以发现,左边几个类型可供我们使用,而我们一般都是组合使用这几个类型,TransformManager有几个常用的Scope集合方便开发者使用。

如果是要处理所有class字节码,Scope我们一般使用TransformManager.SCOPE_FULL_PROJECT。

好,目前为止,我们介绍了Transform的数据流动的原理,输入的类型和过滤机制,我们再写一个简单的自定义Transform,让我们对Transform可以有一个更具体的认识

publicclassCustomTransformextendsTransform{

publicstaticfinalString TAG = "CustomTransform";

publicCustomTransform(){

super();

}

@Override

publicString getName(){

return"CustomTransform";

}

@Override

publicvoidtransform(TransformInvocation transformInvocation)throwsTransformException, InterruptedException, IOException{

super.transform(transformInvocation);

//当前是否是增量编译

booleanisIncremental = transformInvocation.isIncremental();

//消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务

Collection inputs = transformInvocation.getInputs();

//引用型输入,无需输出。

Collection referencedInputs = transformInvocation.getReferencedInputs();

//OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null

TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

for(TransformInput input : inputs) {

for(JarInput jarInput : input.getJarInputs()) {

File dest = outputProvider.getContentLocation(

jarInput.getFile().getAbsolutePath(),

jarInput.getContentTypes(),

jarInput.getScopes(),

Format.JAR);

//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了

FileUtils.copyFile(jarInput.getFile(), dest);

}

for(DirectoryInput directoryInput : input.getDirectoryInputs()) {

File dest = outputProvider.getContentLocation(directoryInput.getName(),

directoryInput.getContentTypes(), directoryInput.getScopes(),

Format.DIRECTORY);

//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了

FileUtils.copyDirectory(directoryInput.getFile(), dest);

}

}

}

@Override

publicSet getInputTypes() {

returnTransformManager.CONTENT_CLASS;

}

@Override

publicSet superQualifiedContent.Scope> getScopes() {

returnTransformManager.SCOPE_FULL_PROJECT;

}

@Override

publicSet getOutputTypes() {

returnsuper.getOutputTypes();

}

@Override

publicSet superQualifiedContent.Scope> getReferencedScopes() {

returnTransformManager.EMPTY_SCOPES;

}

@Override

publicMap getParameterInputs(){

returnsuper.getParameterInputs();

}

@Override

publicbooleanisCacheable(){

returntrue;

}

@Override

publicbooleanisIncremental(){

returntrue; //是否开启增量编译

}

}

可以看到,在transform方法中,我们将每个jar包和class文件复制到dest路径,这个dest路径就是下一个Transform的输入数据,而在复制时,我们就可以做一些狸猫换太子,偷天换日的事情了,先将jar包和class文件的字节码做一些修改,再进行复制即可,至于怎么修改字节码,就要借助我们后面介绍的ASM了。而如果开发过程要看你当前transform处理之后的class/jar包,可以到

/build/intermediates/transforms/CustomTransform/下查看,你会发现所有jar包命名都是123456递增,这是正常的,这里的命名规则可以在OutputProvider.getContentLocation的具体实现中找到

public synchronized File getContentLocation(

@NonNullStringname,

@NonNullSet types,

@NonNullSet superScope> scopes,

@NonNullFormat format) {

// runtime check these since it's (indirectly) called by 3rd party transforms.

checkNotNull(name);

checkNotNull(types);

checkNotNull(scopes);

checkNotNull(format);

checkState(!name.isEmpty());

checkState(!types.isEmpty());

checkState(!scopes.isEmpty());

// search for an existing matching substream.

for(SubStream subStream : subStreams) {

// look for an existing match. This means same name, types, scopes, and format.

if(name.equals(subStream.getName())

&& types.equals(subStream.getTypes())

&& scopes.equals(subStream.getScopes())

&& format == subStream.getFormat()) {

returnnewFile(rootFolder, subStream.getFilename());

}

}

//按位置递增!!

// didn't find a matching output. create the new output

SubStream newSubStream = newSubStream(name, nextIndex++, scopes, types, format, true);

subStreams.add(newSubStream);

returnnewFile(rootFolder, newSubStream.getFilename());

}

Transform的优化:增量与并发

到此为止,看起来Transform用起来也不难,但是,如果直接这样使用,会大大拖慢编译时间,为了解决这个问题,摸索了一段时间后,也借鉴了Android编译器中Desugar等几个Transform的实现,发现我们可以使用增量编译,并且上面transform方法遍历处理每个jar/class的流程,其实可以并发处理,加上一般编译流程都是在PC上,所以我们可以尽量敲诈机器的资源。

想要开启增量编译,我们需要重写Transform的这个接口,返回true。

@Override

publicbooleanisIncremental(){

returntrue;

}

虽然开启了增量编译,但也并非每次编译过程都是支持增量的,毕竟一次clean build完全没有增量的基础,所以,我们需要检查当前编译是否是增量编译。

如果不是增量编译,则清空output目录,然后按照前面的方式,逐个class/jar处理

如果是增量编译,则要检查每个文件的Status,Status分四种,并且对这四种文件的操作也不尽相同

NOTCHANGED: 当前文件不需处理,甚至复制操作都不用;

ADDED、CHANGED: 正常处理,输出给下一个任务;

REMOVED: 移除outputProvider获取路径对应的文件。

大概实现可以一起看看下面的代码

@Override

public voidtransform(TransformInvocation transformInvocation){

Collection inputs = transformInvocation.getInputs();

TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

boolean isIncremental = transformInvocation.isIncremental();

//如果非增量,则清空旧的输出内容

if(!isIncremental) {

outputProvider.deleteAll();

}

for(TransformInput input : inputs) {

for(JarInput jarInput : input.getJarInputs()) {

Status status = jarInput.getStatus();

File dest = outputProvider.getContentLocation(

jarInput.getName(),

jarInput.getContentTypes(),

jarInput.getScopes(),

Format.JAR);

if(isIncremental && !emptyRun) {

switch(status) {

caseNOTCHANGED:

break;

caseADDED:

caseCHANGED:

transformJar(jarInput.getFile(), dest, status);

break;

caseREMOVED:

if(dest.exists()) {

FileUtils.forceDelete(dest);

}

break;

}

} else{

transformJar(jarInput.getFile(), dest, status);

}

}

for(DirectoryInput directoryInput : input.getDirectoryInputs()) {

File dest = outputProvider.getContentLocation(directoryInput.getName(),

directoryInput.getContentTypes(), directoryInput.getScopes(),

Format.DIRECTORY);

FileUtils.forceMkdir(dest);

if(isIncremental && !emptyRun) {

StringsrcDirPath = directoryInput.getFile().getAbsolutePath();

StringdestDirPath = dest.getAbsolutePath();

Map fileStatusMap = directoryInput.getChangedFiles();

for( Map.Entry changedFile : fileStatusMap.entrySet()) {

Status status = changedFile.getValue();

File inputFile = changedFile.getKey();

StringdestFilePath = inputFile.getAbsolutePath().replace(srcDirPath, destDirPath);

File destFile = newFile(destFilePath);

switch(status) {

caseNOTCHANGED:

break;

caseREMOVED:

if(destFile.exists()) {

FileUtils.forceDelete(destFile);

}

break;

caseADDED:

caseCHANGED:

FileUtils.touch(destFile);

transformSingleFile(inputFile, destFile, srcDirPath);

break;

}

}

} else{

transformDir(directoryInput.getFile(), dest);

}

}

}

}

这就能为我们的编译插件提供增量的特性。

实现了增量编译后,我们最好也支持并发编译,并发编译的实现并不复杂,只需要将上面处理单个jar/class的逻辑,并发处理,最后阻塞等待所有任务结束即可。

private WaitableExecutor waitableExecutor = WaitableExecutor.useGlobalSharedThreadPool();

//异步并发处理jar/ class

waitableExecutor.execute( ()->{

bytecodeWeaver.weaveJar(srcJar, destJar);

returnnull;

});

waitableExecutor.execute( ()->{

bytecodeWeaver.weaveSingleClassToFile(file, outputFile, inputDirPath);

returnnull;

});

//等待所有任务结束

waitableExecutor.waitForTasksWithQuickFail( true);

接下来我们对编译速度做一个对比,每个实验都是5次同种条件下编译10次,去除最大大小值,取平均时间

首先,在我工作中的项目(Java/kotlin代码量行数百万级别),我们先做一次cleanbuild

./gradlew clean assembleDebug --profile

给项目中添加UI耗时统计,全局每个方法(包括普通class文件和第三方jar包中的所有class)的第一行和最后一行都进行插桩,实现方式就是Transform+ASM,对比一下并发Transform和非并发Transform下,Tranform这一步的耗时

可以发现,并发编译,基本比非并发编译速度提高了80%。效果很显著。

然后,让我们再做另一个试验,我们在项目中模拟日常修改某个class文件的一行代码,这时是符合增量编译的环境的。然后在刚才基础上还是做同样的插桩逻辑,对比增量Transform和全量Transform的差异。

./gradlew assembleDebug --profile

可以发现,增量的速度比全量的速度提升了3倍多,而且这个速度优化会随着工程的变大而更加显著。

数据表明,增量和并发对编译速度的影响是很大的。而我在查看Android gradle plugin自身的十几个Transform时,发现它们实现方式也有一些区别,有些用kotlin写,有些用java写,有些支持增量,有些不支持,而且是代码注释写了一个大大的FIXME, To support incremental build。所以,讲道理,现阶段的Android编译速度,还是有提升空间的。

上面我们介绍了Transform,以及如何高效地在编译期间处理所有字节码,那么具体怎么处理字节码呢?

由于公众号字数的限制,JVM平台上的处理字节码神兵利器ASM会在下一篇文章进行介绍。

— — — END — — —

分享大前端、Java和跨平台技术,返回搜狐,查看更多

责任编辑:

最后

以上就是忧郁金鱼为你收集整理的dex工具与transform_一起玩转Android项目中的字节码(Transform篇)的全部内容,希望文章能够帮你解决dex工具与transform_一起玩转Android项目中的字节码(Transform篇)所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(46)

评论列表共有 0 条评论

立即
投稿
返回
顶部