天问

加快安卓项目构建介绍

公司的项目代码比较多,每次调试改动java文件后要将近2分钟才能跑起来,实在受不了。在网上找了一大堆配置参数也没有很明显的效果, 尝试使用instant run效果也不怎么样,然后又尝试使用freeline编译速度还可以但是不稳定,每次失败后全量编译很耗费时间,既然没有好的方案就自己尝试做。

 

https://github.com/typ0520/fastdex

 

注: 本文所有的代码、文件输出路径、gradle任务名、全部使用debug这个buildType作说明

 

注: 本文对gradle task做的说明都建立在关闭instant run的前提下(如果想关闭Instant Run功能,点击左上角Android studio -> Preferences -> Build,Execution,Deployment -> Instant Run -> Enable Instant Run......(把对勾去掉))

 

优化构建速度首先需要找到那些环节导致构建速度这么慢,把下面的代码放进app/build.gradle里把时间花费超过50ms的任务时间打印出来

 

 

执行./gradlew assembleDebug,经过漫长的等待得到以下输出

 

 

从上面的输出可以发现总的构建时间为100秒左右(上面的输出不是按照真正的执行顺序输出的),transformClassesWithDexForDebug任务是最慢的耗费了65秒,它就是我们需要重点优化的任务,首先讲下构建过程中主要任务的作用,方便理解后面的hook点

 

mergeDebugResources任务的作用是解压所有的aar包输出到app/build/intermediates/exploded-aar,并且把所有的资源文件合并到app/build/intermediates/res/merged/debug目录里

 

processDebugManifest任务是把所有aar包里的AndroidManifest.xml中的节点,合并到项目的AndroidManifest.xml中,并根据app/build.gradle中当前buildType的manifestPlaceholders配置内容替换manifest文件中的占位符,最后输出到app/build/intermediates/manifests/full/debug/AndroidManifest.xml

 

processDebugResources的作用

1、调用aapt生成项目和所有aar依赖的R.java,输出到app/build/generated/source/r/debug目录

3、生成资源索引文件app/build/intermediates/res/resources-debug.ap_

2、把符号表输出到app/build/intermediates/symbols/debug/R.txt

 

compileDebugJavaWithJavac这个任务是用来把java文件编译成class文件,输出的路径是app/build/intermediates/classes/debug

编译的输入目录有

 

1、项目源码目录,默认路径是app/src/main/java,可以通过sourceSets的dsl配置,允许有多个(打印project.android.sourceSets.main.java.srcDirs可以查看当前所有的源码路径,具体配置可以参考android-doc

2、app/build/generated/source/aidl

3、app/build/generated/source/buildConfig

4、app/build/generated/source/apt(继承javax.annotation.processing.AbstractProcessor做动态代码生成的一些库,输出在这个目录,具体可以参考Butterknife 和 Tinker)的代码

transformClassesWithJarMergingForDebug的作用是把compileDebugJavaWithJavac任务的输出app/build/intermediates/classes/debug,和app/build/intermediates/exploded-aar中所有的classes.jar和libs里的jar包作为输入,合并起来输出到app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar,我们在开发中依赖第三方库的时候有时候报duplicate entry:xxx 的错误,就是因为在合并的过程中在不同jar包里发现了相同路径的类

 

transformClassesWithMultidexlistForDebug这个任务花费的时间也很长将近8秒,它有两个作用

 

1、扫描项目的AndroidManifest.xml文件和分析类之间的依赖关系,计算出那些类必须放在第一个dex里面,最后把分析的结果写到app/build/intermediates/multi-dex/debug/maindexlist.txt文件里面

2、生成混淆配置项输出到app/build/intermediates/multi-dex/debug/manifest_keep.txt文件里

项目里的代码入口是manifest中application节点的属性android.name配置的继承自Application的类,在android5.0以前的版本系统只会加载一个dex(classes.dex),classes2.dex .......classesN.dex 一般是使用android.support.multidex.MultiDex加载的,所以如果入口的Application类不在classes.dex里5.0以下肯定会挂掉,另外当入口Application依赖的类不在classes.dex时初始化的时候也会因为类找不到而挂掉,还有如果混淆的时候类名变掉了也会因为对应不了而挂掉,综上所述就是这个任务的作用

 

transformClassesWithDexForDebug这个任务的作用是把包含所有class文件的jar包转换为dex,class文件越多转换的越慢

输入的jar包路径是app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar

输出dex的目录是build/intermediates/transforms/dex/debug/folders/1000/1f/main

 

*注意编写gradle插件时如果需要使用上面这些路径不要硬编码的方式写死,最好从Android gradle api中去获取路径,防止以后发生变化

 

结合上面的这些信息重点需要优化的是transformClassesWithDexForDebug这个任务,我的思路是第一次全量打包执行完transformClassesWithDexForDebug任务后把生成的dex缓存下来,并且在执行这个任务前对当前所有的java源文件做快照,以后打包的时候通过当前所有的java文件信息和之前的快照做对比,找出变化的java文件进而得到那些class文件发生变化,然后把app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中没有变化的class移除掉,仅把变化class送去生成dex,然后选择一种热修复方案把这个dex当做补丁dex加载进来,有思路了后面就是攻克各个技术点

 

==============================

 

如何拿到transformClassesWithDexForDebug任务执行前后的生命周期

 

参考了Tinker项目的代码,找到下面的实现

 

把上面的代码放进app/build.gradle执行./gradlew assembleDebug

 

如何做快照与对比快照并拿到变化的class列表

 

执行下面的代码可以获取所有的项目源码目录

 

project.android.sourceSets.main.java.srcDirs.each { srcDir->

println("==srcDir: ${srcDir}")

}

sample工程没有配置sourceSets,因此输出的是app/src/main/java

 

给源码目录做快照,直接通过文件复制的方式,把所有的srcDir目录下的java文件复制到快照目录下(这里有个坑,不要使用project.copy {}它会使文件的lastModified值发生变化,直接使用流copy并用源文件的lastModified覆盖目录文件的lastModified)

 

通过java文件的长度和上次修改时间两个要素对比可以得知同一个文件是否发生变化,通过快照目录没有某个文件而当前目录有某个文件可以得知增加了文件,通过快照目录有某个文件但是当前目录没有可以得知删除文件(为了效率可以不处理删除,仅造成缓存里有某些用不到的类而已)

举个例子来说假如项目源码的路径为/Users/tong/fastdex/app/src/main/java,做快照时把这个目录复制到/Users/tong/fastdex/app/build/fastdex/snapshoot下,当前快照里的文件树为

 

有了上面的匹配模式就可以在补丁打包执行transform前把app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中没有变化的class全部移除掉

然后就可以使用patchJar作为输入jar生成补丁dex

 

注: 这种映射方案如果开启了混淆就对应不上了,需要解析混淆以后产生的mapping文件才能解决,不过我们也没有必要在开启混淆的buildType下做开发开发调试,所以暂时可以不做这个事情

 

==============================

有了补丁dex,就可以选择一种热修复方案把补丁dex加载进来,这里方案有好几种,为了简单直接选择android.support.multidex.MultiDex以dex插桩的方式来加载,只需要把dex按照google标准(classes.dex、classes2.dex、classesN.dex)排列好就行了,这里有两个技术点

 

由于patch.dex和缓存下来dex里面有重复的类,当加载引用了重复类的类时会造成pre-verify的错误,具体请参考QQ空间团队写的安卓App热补丁动态修复技术介绍

,这篇文章详细分析了造成pre-verify错误的原因,文章里给的解决方案是往所有引用被修复类的类中插入一段代码,并且被插入的这段代码所在的类的dex必须是一个单独的dex,这个dex我们事先准备好,叫做fastdex-runtime.dex,它的代码结构是

 

结合我们的项目需要在全量打包前把app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中所有的项目代码的class全部动态插入代码(第三方库由于不在我们的修复范围内所以为了效率忽略掉),具体的做法是往所有的构造方法中添加对com.dx168.fastdex.runtime.antilazyload.AntilazyLoad的依赖,如下面的代码所示

动态往class文件中插入代码使用的是asm,我把做测试的时候找到的一些相关资料和代码都放到了github上面点我查看,代码比较多只贴出来一部分,具体请查看ClassInject.groovy

 

===============

处理完pre-verify问题,接下来又出现坑了,当补丁dex打好后假如缓存的dex有两个(classes.dex classes2.dex),那么合并dex后的顺序就是

fastdex-runtime.dex 、patch.dex、classes.dex 、classes2.dex (patch.dex必须放在缓存的dex之前才能被修复)

 

fastdex-runtime.dex  => classes.dex

patch.dex            => classes2.dex

classes.dex          => classes3.dex

classes2.dex         => classes4.dex

在讲解transformClassesWithMultidexlistForDebug任务时有说过程序入口Application的问题,假如patch.dex中不包含入口Application,apk启动的时候肯定会报类找不到的错误,那么怎么解决这个问题呢

 

第一个方案:

把transformClassesWithMultidexlistForDebug任务中输出的maindexlist.txt中所有的class都参与patch.dex的生成

第二种方案:

对项目的入口Application做代理,并把这个代理类放在第一个dex里面,项目的dex按照顺序放在后面

第一种方案方案由于必须让maindexlist.txt中大量的类参与了补丁的生成,与之前尽量减少class文件参与dex生成的思想是相冲突的,效率相对于第二个方案比较低,另外一个原因是无法保证项目的Application中使用了MultiDex;第二种方案没有上述问题,但是如果项目代码中有使用getApplication()做强转就会出问题

最终选择了第二种方案,以下是fastdex-runtime.dex中代理Application的代码

 

根据之前的任务说明生成manifest文件的任务是processDebugManifest,我们只需要在这个任务执行完以后做处理,创建一个实现类是FastdexManifestTask的任务,代码如下

 

使用下面的代码把这个任务加进去并保证在processDebugManifest任务执行完毕后执行

处理完以后manifest文件application节点android.name属性的值就变成了com.dx168.fastdex.runtime.FastdexApplication,并且把原来项目的Application的名字写入到meta-data中,用来运行期给FastdexApplication去读取

 

<meta-data android:name="FASTDEX_ORIGIN_APPLICATION_CLASSNAME" android:value="com.dx168.fastdex.sample.SampleApplication"/>

==============================

 

开发完以上功能后做下面的四次打包做时间对比(其实只做一次并不是太准确,做几十次测试取时间的平均值这样才最准)

 

1、删除build目录第一次全量打包(不开启fastdex)

 

2、删除build目录第一次全量打包(开启fastdex)

3、在开启fastdex第一次全量打包完成后,关掉fastdex修改sample工程的MainActivity.java

 

4、在开启fastdex第一次全量打包完成后,仍然开启fastdex修改sample工程的MainActivity.java

 

通过1和2对比发现,开启fastdex进行第一次全量的打包时的时间花费比不开启多了10秒左右,这个主要是注入代码和IO上的开销

 

通过2和3对比发现,开启fastdex进行补丁打包时的时间花费比不开启快了60秒左右,这就是期待已久的构建速度啊^_^

 

==============================

刚激动一会就尼玛报了一个错误,当修改activity_main.xml时往里面增加一个控件

打出来的包启动的时候就直接crash掉了

错误信息里的意思是为CustomView的tv1字段,寻找id=2131493007的view时没有找到,先反编译报错的apk,找到报错的地方CustomView$$ViewBinder.bind

 

 

看到这里是不是觉得奇怪,CustomView的源码明明是

在编译以后R.id.tv1怎么就变成数字2131493007了呢,原因是java编译器做了一个性能优化,如果发现源文件引用的是一个带有final描述符的常量,会直接做值copy

 

反编译最后一次编译成功时的R.class文件(

app/build/intermediates/classes/debug/com/dx168/fastdex/sample/R.class)

 

经过分析,当全量打包时R.id.tv1 = 2131493007,由于R文件中的id都是final的,所以引用R.id.tv1的地方都被替换为它对应的值2131493007了;当在activity_layout.xml中添加名字为tv2的控件,然后进行补丁打包时R.id.tv1的值变成了2131493008,而缓存的dex对应节点的值还是2131493007,所以在寻找id为2131493007对应的控件时因为找不到而挂掉

 

我的第一个想法是如果在执行完processDebugResources任务后,把R文件里id类的所有字段的final描述符去掉就可以把值copy这个编译优化绕过去 =>

去掉以后在执行compileDebugJavaWithJavac时编译出错了

 

 

2.png

出错的原因是注解只能引用带final描述符的常量,除此之外switch语句的case也必须引用常量,具体请查看oracle对常量表达式的说明

 

如果采取这个方案,对id的引用就不能使用常量表达式,像ButterKnife这样的view依赖注入的框架都不能用了,限制性太大这个想法就放弃了

 

还有一个思路就是修改aapt的源码,使多次打包时名字相同id的值保持一致,这个肯定能解决不过工作量太大了就没有这样做,之后采用了一个折中的办法,就是每次把项目中的所有类(除去第三方库)都参与dex的生成,虽然解决了这个问题但效率一下子降低好多,需要将近40秒才能跑起来还是很慢

 

==============================

这个问题困扰了好久,直到tinker开源后阅读它的源码TinkerResourceIdTask.groovy时,发现它们也碰到了同样的问题,并有了一个解决方案,我们的场景和tinker场景在这个问题上是一模一样的,直接照抄代码就解决了这个问题,重要的事情说三遍,感谢tinker、感谢tinker、感谢tinker!!

 

tinker的解决方案是,打补丁时根据用户配置的resourceMapping文件(每次构建成功后输出的app/build/intermediates/symbols/debug/R.txt),生成public.xml和ids.xml然后放进app/build/intermediates/res/merged/debug/values目录里,aapt在处理的时候会根据文件里的配置规则去生成,具体这块的原理请看老罗的文章Android应用程序资源的编译和打包过程分析(在里面搜索public.xml)这里面有详细的说明

 

同上并结合我们的场景,第一次全量打包成功以后把app/build/intermediates/symbols/debug/R.txt缓存下来,补丁打包在执行processResources任务前根据缓存的符号表R.txt,去生成public.xml和ids.xml然后放进app/build/intermediates/res/merged/debug/values目录里,这样相同名字的id前后的两次构建值就能保持一致了,代码如下FastdexResourceIdTask.groovy

 

 

 

如果项目中的资源特别多,第一次补丁打包生成public.xml和ids.xml时会占用一些时间,最好做一次缓存,以后的补丁打包直接使用缓存的public.xml和ids.xml**

 

==============================

解决了上面的原理性问题后,接下来继续做优化,上面有讲到 transformClassesWithMultidexlistForDebug任务的作用,由于采用了隔离Application的做法,所有的项目代码都不在classes.dex中,这个用来分析那些项目中的类需要放在classes.dex的任务就没有意思了,直接禁掉它

禁掉以后,执行./gradle assembleDebug,在构建过程中挂掉了

从上面的日志的第一行发现transformClassesWithMultidexlistForDebug任务确实禁止掉了,后面跟着一个SKIPPED的输出,但是执行transformClassesWithDexForDebug任务时报app/build/intermediates/multi-dex/debug/maindexlist.txt (No such file or directory)

,原因是这个文件对于transformClassesWithDexForDebug是必须的,既然这样就在执行transformClassesWithDexForDebug任务前创建一个空文件,看是否还会报错,代码如下

再次执行./gradle assembleDebug

 

这次构建成功说明创建空文件的这种方式可行

 

=========

我们公司的项目在使用的过程中,发现补丁打包时虽然只改了一个java类,但构建时执行compileDebugJavaWithJavac任务还是花了13秒

经过分析由于我们使用了butterknife和tinker,这两个里面都用到了javax.annotation.processing.AbstractProcessor这个接口做代码动态生成,所以项目中的java文件如果很多挨个扫描所有的java文件并且做操作会造成大量的时间浪费,其实他们每次生成的代码几乎都是一样的,因此如果补丁打包时能把这个任务换成自己的实现,仅编译和快照对比变化的java文件,并把结果输出到app/build/intermediates/classes/debug,覆盖原来的class,能大大提高效率,部分代码如下,详情看FastdexCustomJavacTask.groovy

执行./gradlew assembleDebug ,再来一次

 

=========

既然有缓存,就有缓存过期的问题,假如我们添加了某个第三方库的依赖(依赖关系发生变化),并且在项目代码中引用了它,如果不清除缓存打出来的包运行起来后肯定会包类找不到,所以需要处理这个事情。

首先怎么拿到依赖关系呢?通过以下代码可以获取一个依赖列表

可以在第一次全量打包时,和生成项目源码目录快照的同一个时间点,获取一份当前的依赖列表并保存下来,当补丁打包时在获取一份当前的依赖列表,与之前保存的作对比,如果发生变化就把缓存清除掉

 

另外最好给用户提供一个主动清除缓存的任务

 

先来一个清除所有缓存的任务

 

project.tasks.create("fastdexCleanAll", FastdexCleanTask)

然后在根据buildType、flavor创建对应的清除任务

 

==============================

 

#TODO LIST 后续的优化计划

 

1、提高稳定性和容错性,这个是最关键的

2、目前补丁打包的时候,是把没有变化的类从app/build/intermediates/transforms/jarMerging/debug/jars/1/1f/combined.jar中移除,如果能hook掉transformClassesWithJarMergingForDebug这个任务,仅把发生变化的class参与combined.jar的生成,能够在IO上省出大量的时间

3、目前给项目源码目录做快照,使用的是文件copy的方式,如果能仅仅只把需要的信息写在文本文件里,能够在IO上省出一些时间

4、目前还没有对libs目录中发生变化做监控,后续需要补上这一块

5、目前不支持getApplication()然后做强制转换

==============================

 

这里对打包的流程做下总结

 

全量打包时的流程:

 

1、合并所有的class文件生成一个jar包

2、扫描所有的项目代码并且在构造方法里添加对com.dx168.fastdex.runtime.antilazyload.AntilazyLoad类的依赖

这样做的目的是为了解决class verify的问题,

详情请看https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a

3、对项目代码做快照,为了以后补丁打包时对比那些java文件发生了变化

4、对当前项目的所以依赖做快照,为了以后补丁打包时对比依赖是否发生了变化,如果变化需要清除缓存

5、调用真正的transform生成dex

6、缓存生成的dex,并且把fastdex-runtime.dex插入到dex列表中,假如生成了两个dex,classes.dex classes2.dex 需要做一下操作

fastdex-runtime.dex => classes.dex

classes.dex => classes2.dex

classes2.dex => classes3.dex

然后运行期在入口Application(com.dx168.fastdex.runtime.FastdexApplication)使用MultiDex把所有的dex加载进来

7、保存资源映射映射表,为了保持id的值一致,详情看

@see com.dx168.fastdex.build.task.FastdexResourceIdTask

补丁打包时的流程

 

1、检查缓存的有效性

@see com.dx168.fastdex.build.task.FastdexCustomJavacTask 的prepareEnv方法说明

2、扫描所有变化的java文件并编译成class

@see com.dx168.fastdex.build.task.FastdexCustomJavacTask

3、合并所有变化的class并生成jar包

4、生成补丁dex

5、把所有的dex按照一定规律放在transformClassesWithMultidexlistFor${variantName}任务的输出目录

fastdex-runtime.dex => classes.dex

patch.dex => classes2.dex

dex_cache.classes.dex => classes3.dex

dex_cache.classes2.dex => classes4.dex

dex_cache.classesN.dex => classes(N + 2).dex

=============

 

整个项目的代码目前已经开源了 https://github.com/typ0520/fastdex

 

如果你喜欢本文就来给我们star吧

博客地址:http://blog.yoqi.me/?p=2788
扫我捐助哦
喜欢 0

这篇文章还没有评论

发表评论