Android加速编译终极方案---模块化

Android加速编译终极方案---模块化_第1张图片
作为一个文艺的程序猿,配图当然是要用自己拍的了,哈哈啊哈哈哈哈

“所谓Android开发,五年开发三年编译。”——鲁迅(鲁迅:mmp...)

本文主要讨论关于Android代码模块化后,对于编译速度提升。其中涉及详细的测试过程和数据分析,以及遇到问题的解决。本文为翻译整理文章,感叹于作者的探索精神。
Nikita Kozlov
原文链接

预备知识点

  • App Module
    每创建一个Android工程都会包含至少一个Application module。模块根目录build.gralde文件中会引入插件:apply plugin: 'com.android.application',这时编译项目会生成apk文件。application module不能被其他项目依赖,可独立运行。

  • Library Module
    将插件改为:apply plugin: ‘com.android.library',该模块就成为了一个Library Module。Library Module可被其他工程依赖,被依赖后编译生成aar文件,模块不可以独立运行。编译生成的aar,和jar相比差别是多了res文件和manifest文件。

  • Android Studio项目构建过程
    Android Studio(后简称AS)项目构建过程可以分为五个部分,分别由一个gradle 任务来代替:
    准备依赖:这个阶段Gradle检查所有module依赖的库,module中依赖的其他module也被同事编译。
    合并资源,处理manifest文件:该阶段资源文件和manifest文件准备就绪。
    编译:该过程如果项目中使用了编译时注解,则由注解处理器开始。然后源码被编译成字节码。如果项目使用到AspectJ,织入也发生在该阶段。
    后期处理:所有gradle任务中带transform前缀的在这个阶段执行。其中最重要的两个任务是 transformClassesWithMultidexlist和transformClassesWithDex。他们是用来生成dex文件的。
    打包发布:所有平台文件打包为aar,生成apk。
    本文最终讨论如何减少编译时间,一次性对所有阶段进行加速是很难,所以我们把注意力集中在最耗时的阶段。对于单模块项目,最耗时的阶段发生在编译和后期处理。资源合并阶段有时也同样很耗时,但如果java代码未发生改变,增量编译也是很快的。
    Gradle运行任务的前提是输入发生改变,如果模块未发生改变是不会重新编译的,那么我们就有了如下猜测:
    “ 多模块模式下的增量编译要比单模块快,因为只有被修改的模块才会被重新编译 ”
    接下来我们就来证明上面的假设。

1、实验前准备

测试项目gradle版本2.2.2,最小android兼容版本为15(该版本兼容了大多数安卓设备),所有模块都依赖butterknife,这样使项目更接近真实情况。同时所有项目都有外部依赖。
项目中主工程为app,子模块为 app1,app2...
每个项目都有100个包,15K个类,9W个方法,参与生成的dex文件在2-3个。由于自动生成的代码是假的,为了保证代码和资源能完整打包进apk,关闭了minifying和shrikning功能。
所有测试结果都通过Gradle build-in profiler保存。在控制台输入如下命令可得到html格式的测试结果:./gradlew assembleDebug --profile
每次测试数据都执行4到15次,以保证测试结果真是可再现。

测试代码生成

手写15000个类,那是不可能的!!!所以实验代码是用Python脚本自动生成的,生成代码见Gist,生成的代码结构如下:

Android加速编译终极方案---模块化_第2张图片
生成测试代码结构

上面代码的调用过程形成了一种剪不断理还乱的代码结构(就像你自己写一毛一样),从而可以真实模拟增加的编译时间。

关于纯Java模块

本实验并未针对纯Java模块进程测试,纯Java模块运行效率肯定高于Android模块。因为没有的资源的整合,编译其运行的任务更少。
如果你对纯Java模块测试结果感兴趣,请留言告诉我。或者你可以下载本实验项目,根据你的需求修改即可。此外,测试结果还取决于硬件水平,为了使结果更具可比性,请多次重复实验。

排除干扰因素

毫无疑问,并发的进行编译测试,同样会减缓编译速度。尽管只同时开两个项目,也会使编译速度减缓5-10%。此外,测试的同时,听音乐、上网、看视频同样也会对编译速度有明显的影响。就个人体会,关闭除AS以外其他进程会使编译速度加快差不多30%。
最后,以下所有实验均是在只开启浏览器和AS的情况下完成的。

2、开始实验

初始单模块

起始状态,编译包含15K个类的单模块项目耗时1m10s。

三个模块

将单模块项目拆解为3个模块,一个主模块,两个库模块,主模块依赖于两个库模块,两个库模块相互独立。每个模块有5K个类,共3W个方法。

如果修改主模块代码,编译时间大概35s左右。比初始状态节省近30s。但其中一个库模块发生修改,尽管主模块未改变,编译时间却增加到了1m50s,长了近40s!!!

以下是编译时间测试日志:

Android加速编译终极方案---模块化_第3张图片
app2
app

从以上截图中可以发现,大多数时间耗在了库模块的编译上。而且两个库模块,Debug版和Release版的编译任务都执行了。Gradle编译时间全耗在两个版本库模块的任务执行上了。这就是为什么相比单模块项目,时间长了近40s。

通过项目模块拆分加快了编译速度(只修改App Module的情况下)。

还有一个问题,且看以下主工程依赖两个库模块的方式:

dependencies {
    compile project(path: ':app2')
    compile project(path: ':app3')
}

以上代码有个重要的问题:像这样的方式依赖库模块,不管主工程是正式环境还是其他环境,
主工程依赖的总是库的正式版。Gradle用户指南上提到:

默认情况下,依赖库只发布正式版。对于所有依赖库,不管编译环境是哪个,均只发布正式版的库。这是一个临时的限制,我们可以将其限制移除。

我们也可以更改其依赖的版本。

首先在build.gradle文件中添加如下代码,使其允许依赖库发布debug版本:

android {
    defaultConfig {
        defaultPublishConfig 'release'
        publishNonDefault true
    }
}

其次,将依赖库的版本做如下更改:

dependencies {
    debugCompile project(path: ':app2', configuration: "debug")
    releaseCompile project(path: ':app2', configuration: "release")

    debugCompile project(path: ':app3', configuration: "debug")
    releaseCompile project(path: ':app3', configuration: "release")
}

现在,我们就可以在不同的编译环境下依赖不同的库版本了。此时修改模块app2并重新编译。经过更改,我们再查看编译时间测试日志:

Android加速编译终极方案---模块化_第4张图片
app2
app

结果中,最大的差别就是:app2中的packageReleaseJarArtifact任务不见了,节省了15s编译时间。此外,其他任务的执行时间也有了变化,最终耗时1m32s。比之前快了18s,但仍然比初始状态慢22s。对于更改主工程的情况,时间几乎没变,35s比36s。

但我仍然没有找到执行两种任务(Debug和Release)的合理解释。期初希望一次性解决Gradle限制的问题,但目前似乎并没有解决。同样的问题在AOSP 问题追踪中也有被讨论到。

我打算再花点时间在Gradle任务测试上,找到问题得解决办法。

五个模块

显而易见,编译时间取决于模块代码量。模块代码量减少一半,编译时间也相应提高一倍。如果3个模块被拆解成5个模块,编译时间也会加快近40%,事实也确实如此:

Android加速编译终极方案---模块化_第5张图片
结果对照图

如果只改变主工程代码,编译时间大概在24s。更改库模块代码,编译时间则需要50s。和开始单模块的1m10s相比,已经是快了。但仍然还有一些加快编译的办法。

减小主工程代码量后的n+1结构

和库模块项目比,主工程每次都需要编译,所以尽可能减小主工程代码量,对加速编译也有显著意义。理想状态下,主工程应该只提供启动页相关功能,其他功能都依赖于各个模块,因为启动页常需要依赖于各个模块的功能。

这种情况下,就诞生了3+1和5+1的项目架构。两种架构下,都是一个小规模的主工程依赖于3-5个库模块。所有库模块彼此独立,规模相同。且看如下测试结果:

Android加速编译终极方案---模块化_第6张图片
img07.jpeg

我们可以观察到编译时间进一步减少。即使在5+1架构下,更改发生在库模块,编译时间和初始的单模块相比,时间同样减少了一半。

3、为什么测试项目要基于ButterKnife?

这里有个重点需要强调。依赖ButterKnife是有重要原因的。初始的单模块项目中,整个运行过程的1m10s里,有45s是增量编译的耗时。但如果移除了butterknife,增量编译的时间只有15s。快了3倍。整个项目在没有ButterKnife的情况下,运行时间就会缩短到40s。

是ButterKnife出了问题吗?

项目中在没有ButterKnife的情况下编译变快的原因是Java增量编译,但是对于注解处理器,他是不支持的。相关问题的讨论在这里你可以看到Gradle Jira、AOSP Issue Tracker、Gradle Design Doc。在ASOP问题追踪中,有评论提到:

"Annotation processors is not yet supported with incremental java compilation. It will depend on changes in Gradle.
注解处理器在Java增量编译中还没有支持。它要依赖Gradle的改变。
We disabled it for project that apply com.neenbedankt.android-apt, so it's no longer a significant issue."
项目中使用到的apt被禁用了,所以增量编译是没有意义的。

这就是变慢的原因。

但我个人并没有弃用注解处理器,因为诸如Dagger、ButterKnfie(都有用到注解处理器)真的太好用了。但可以选择在部分模块中不用这些使用到注解处理器的库,这样模块编译就更快了。

4、提高兼容版本,加速编译

除编译耗时以外,.dex文件生成同样耗时,尤其在方法数超出限制,采取multidex配置时,系统需要决定在哪个dex文件中放那些类。多Dex结构的应用,其运行方式也发生了变化,其中AS开发文档中介绍到:

Android 5.0以版本系统上使用的ART支持从Apk中本地加载dex文件。ART在安装时扫描每个dex文件并编译成一个oat文件,以供在Android设备上执行。

这也导致了编辑时间的增加。原因是每个module都生成自己的dex文件,然后被打包进apk文件。如果你观察它的构建过程,你就会注意到transformClassesWithMultidexlist不再执行。同时编译速度也加快。更多定制flavor的配置信息你可以在这里找到。部分定制编译环境的代码如下:

android {
    defaultConfig {
        ...
    }
    productFlavors {
        dev {
            // Enable pre-dexing to produce an APK that can be tested on
            // Android 5.0+ without the time-consuming DEX build processes.
            minSdkVersion 21
        }
        prod {
            // The actual minSdkVersion for the production version.
            minSdkVersion 14
        }
    }
}

5、最快的编译结构配置

Android加速编译终极方案---模块化_第7张图片
img08.jpeg

基于Android 5.0设备开发,修改module时,编译效率显著提高。

6、模块化后,更方便测试

对单Module项目进行测试很困难的一个主要原因也是编译时间。TDD鼓励频繁测试,时常要一分钟内频繁的运行测试用例。长时间编译导致这种测试方式很低效。多模块开发使得这种问题自然而然的被解决。

总结

模块化可以有效提高编译效率。如果模块以错误的方式拆分,编译时间会增加,因为编译同时产生正式包和测试包。多模块模式下,测试更容易进行。同时,团队合作时,方便并行开发。

参考

  • https://code.google.com/p/android/issues/detail?id=52962
  • https://issues.gradle.org/browse/GRADLE-3259
  • https://code.google.com/p/android/issues/detail?id=200043

你可能感兴趣的:(Android加速编译终极方案---模块化)