编译提速最佳实践

6,205 阅读16分钟

本文会不定期更新,推荐watch下项目。如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request。本文意在分享作者在实践中对于打包和build的提速方案,如果你有更好的点子可以在下方进行评论补充。

本文固定连接:github.com/tianzhijiex…

需求

让打包变得更快一点,再快一点!
这个需求简单明了,就是要更快的打包,减少时间上的浪费。

实现

分析目前现状

build-time-tracke

build-time-tracke是可以检测出build耗时的gradle插件,会形成十分美观的图表,可以方便的确定当前的编译时间和主要耗时的task。

gradlew build -profile

gradlew build -profile命令会生成报表,gradlew build assembleDebug -profile会产生debug模式下的报表,报表位置在工程的根目录下的build目录中。

dexcount

dexcount也是一个相当不错的统计工具,也是一个gradle插件。
配置后执行build会产生目前的方法数的输出,也会输出一个图表。

> ./gradlew assembleDebug

...buildspam...
:app:compileDebugSources
:app:preDexDebug UP-TO-DATE
:app:dexDebug
:app:packageDebug
:app:zipalignDebug
:app:assembleDebug
Total methods in MyApp-debug-5.3.14.apk: 56538

BUILD SUCCESSFUL

Total time: 33.017 secs

利用这个图表我们可以方便的分析出不同库的不同方法,对于查看复杂库和精简方法有着相当重要的帮助。

精简工程

删除不必要的module

AS的代码结构和eclipse完全不同,它为开发者提供了单工程多module的形式。但多建立一个module就需要多维护一个module,而且依赖的module越多,编辑时间越久,甚至有人说是依赖aar或jar的四倍时间。
如果仅仅是为了方便写代码而建立一个module是不可取的,我强烈建议先做好项目结构的梳理再考虑是否需要建立module。

下面是一个多module的app结构图:

framework

在as中通过自带的预览工具,也可以帮助我们进行modules的梳理:

module

这个项目中的module有很多,所以gradle在编译的时候会去检测module的依赖链,gradle会帮助我们层层梳理module之间的关系,避免因为module之间相互引用而来带的问题。这些梳理工作和module的合并工作都会增加build的时间。
如果你的项目build十分缓慢,我强烈建议你去梳理下module的关系,合并部分module,将稳定的底层module打包为aar,上传到公司的maven仓库,借此来加快build速度。

删除module中的无用文件

as默认在建立module的同时会建立test目录:

test

如果你根本没有编写过测试用例,你完全可以删除test目录,到要写的时候再加上就好。
当然,如果你的module就是纯代码,根本没用到资源文件,也请一并把res目录删除掉。

res

删除主项目中无用的资源文件

项目开发中多少都会存留一些无用的代码和资源,资源越多打包合并资源的时间就越长。对于资源文件,as提供了自动检测失效文件和删除的功能,绝对值得一试。

remove res

在弹出的对话框中,我强烈建议不要勾选删除无用的id,因为databinding会用到一些id,但这在代码中是没有直接依赖的,因此as会认为这些是无用id,会直接删除。如果你删除了这些id,那么就等着编译失败吧。(别问我是怎么知道的T_T)。顺便说一下,每次做这种操作前记得commit一下,方便做diff。

dialog

优化依赖库

利用no-op加快debug的速度

如果项目中有很多公司自己的module依赖,那么你完全可以采用类似于这篇文章提到的技巧,给私有的module做no-op(什么是no-op可以看这篇的例子)。

一般私有的module会比较稳定,并且对外暴露的方法不多,甚至会是别的项目组开发的。在不影响功能的前提下,建议和开发团队商量提供no-op版本。

debugCompile(project(':share-lib-no-op')) {}
releaseCompile(project(':share-lib')) {}
debugCompile(project(':zxing-no-op')) {}
releaseCompile(project(':zxing')) {}

用no-op版本的好处就是只使用接口而不使用实现,将实现的代码全部剔除,如果做的好的话甚至可以在debug时不用multidex,但坏处就是需要进行协作交流。如果module对外的接口变动了,还应该考虑到对no-op版本的影响。

减少方法数,不使用multidex

关于什么是multidex,和怎么使用它,请参考《使用android-support-multidex解决Dex超出方法数的限制问题》

它是一种不得已而为之的举措,使用它后我经常会发现在一些特殊的机型上会出现一些奇奇怪怪的错误,总之就是有很多坑。

在build时间这一块,multidex因为有分包和压缩的过程,所以它对于编译速度方面有有严重的影响。我通过dexcount这个插件分析了我的项目后,发现项目中有一些库已经不再用或者有更好的替代品,于是我精简和替换了第三方库,并且开启了support包的混淆,最终让我们的项目的release包的方法数达到了一个合理的水平。

优化前

精简库,开启support包的混淆后

为了控制变量,我专门用一个空项目进行support包混淆前后的对比。数据如下:

混淆前

混淆后

当一个第三方sdk说不要混淆support包,不要混淆我sdk的代码的时候,我强烈建议你考虑下方法数的问题。混淆的作用之一是将代码进行优化和缩短方法名、字段名;作用之二就是删除没有被用到的变量和方法。第三方sdk的方法数众多,如果没办法混淆,那么会带来大量的方法数,这点需要十分的小心。混淆虽然是一个十分有用的工具,但也是很多错误的来源,所以我建议你小心谨慎的多多使用它!

根据使用场景依赖不同库

上面讲到了优化第三方库会减少方法数,这里简单讲一下一般的优化策略:

  1. 利用debugCompile来依赖debug时才用到的库
    debugCompile我在第三方库开发实践中已经讲到多次了,这里就不再赘述。

  2. 利用更小的库替代现有的库
    这个就要看开发人员的经验和知识面和判断能力了。虽然是废话,如果能真正做到,成果是极其明显的。

  3. 利用exclude来排出某些不需要的依赖
    react native是一个庞大的库,引入rn后会依赖很多别的库:

rn

在我们的项目中,我利用了自己编写的网络请求模块进行网络请求,所以我就想要剔除掉rn引入的okhttp。我还发现它引入了support包,而我项目中也有support包,所以我也想要排出掉它(不排除support包也没事,gradle会仅包含最新的库版本,我这里仅仅是举个例子)。

  compile ('com.facebook.react:react-native:+'){
    exclude group: 'com.squareup.okhttp3', module: 'okhttp'
    exclude group: 'com.android.support', module: 'support-v4'
    exclude group: 'com.android.support', module: 'support-v7'
  }

重新build一次后,你会发现okhttp已经被剔除掉了:

exclude okhttp

对于本地的module也是可以这样处理的:

compile(project(':react-native-custom-module')) {
    exclude group: 'com.facebook.react', module: 'react-native'
}

利用缓存

开启offline

这个是最简单直接的加速方案了,效果极其明显,谁用谁知道!

offline

用公司的仓库做缓存

我推荐的做法是项目中所有的依赖(私有或第三方)都通过公司的仓库进行获取。公司的仓库应该能自己查找jcenter等仓库,下载好需要的依赖,并进行缓存。这样的好处是:当一个同事引入了新库或者更新库版本后,别的同事在build时可以直接拿缓存好的库,大大减少了下载依赖的时间。这点虽然是小优化,但是对于新人和团队协作来说是相当重要的。

跳过无用的耗时操作

debug时跳过某些task

我们的项目中用到了很多gradle插件,有些插件会在build时运行自己的task:

gradle plugin

  • tiny是用来压缩图片的
  • buildtime是用来检测build时间的
  • dexcount是用来分析方法数的

这些插件对于我们的开发工作带来了巨大的帮助,但也增加了build时间。

我分享下我的做法:

  1. 在每次发版本前开启tiny,直接build一次,压缩完图片后将其关闭。
  2. 在需要检测和诊断build时间的时候启用buildtime,一般的debug时不开启它。
  3. 在release包中开启dexcount,并且让其于Jenkins进行结合。这样既不会影响debug包,又可以进行方法数的持续监控。

关于dexcount是如何和Jenkins结合的,并且是如何产生下面的图表的,请参考:
www.th7.cn/Program/And…

dexcount

关于如何在不同的情况下跳过或启用task的知识,可以参考Gradle配置实践中的内容。

跳过lint

通过gradlew build -profile我们可以得到build的详细报告,最终报告会生成在根目录的build/reports/profile中:

我们可以看到lint的时间占到了build时间的80%之多,所以如果你不在意lint的结果的话,你可以在build时跳过lint检测,比如:

gradle build -x lint -x lintVitalRelease

因为我用命令行build的次数较少,所以我直接在gradle中加了一个跳过lint的task:

tasks.whenTaskAdded { task ->
    if (task.name.equals("lint") || task.name.equals("lintVitalRelease")) {
        task.enabled = false
    }
}

放弃retrolambda,谨慎使用AspectJ

目前android不支持lambda,所以很多人都引入了retrolambda。一旦你引入了这个库,你就必须面临着字节码转换而带来的build慢的问题。它需要在build时执行一个插入代码的task,这个task的执行时间随着你用的越多而会越来越长。所以,我不推荐在目前的阶段使用它,还是等等看看谷歌jack的表现吧(jack目前还是太初级,对库和增量编译的支持很差)。

AspectJ是aop的工具,但因为需要在build时进行代码的插入,所以使用AspectJ后build时间会明显的增加,具体看使用量而定。

AspectJ的优缺点十分明显,我这里只是提出来,具体如何权衡,就看大家自己了。我因为用了UiBlock所以引入了AspectJ,它让我debug是build的速度慢了三秒钟,但UiBlock的好处也十分明显,所以我还是用了这个库。

优化配置方案

在dev环境中设置minSdkVersion为21

因为在debug时,我们不会去开启混淆,所以debug包常常是需要用mulitdex的。

debugApplication

android5.0对于mulitdex做了优化,具体可以参考官方的文章,我就直接说怎么做就好。先在gradle的配置中添加一个flavors,比如叫做dev,在dev中配置最低支持的android版本为21.

gradle

然后在build时选中devDebug,这样你debug的时候就是走最低支持21的编译方式了。

build

特别注意:
现在我们为了提速将最低版本写为21,假设你最终可能支持的是16,这就有个风险点了。因为as会在你写代码的时候认为你的应用就是支持21的,所以对于一些16~21的api不会有版本风险提示的。因此使用16~21之间的api时需要人为的注意,这是最大的风险!!!

升级jdk和gradle

最新的Gradle要比老版本要快,jdk1.8比jdk1.6要快,所以可以跟着官方升级新版本就好。

The same argument goes for Java versions as well. If you haven’t upgraded yet to Java 8, do it now! Well finish reading this blog post, but do it straight afterwards! You don’t even have to move your project to use Java 8, lambdas and so on. Just make sure your build tool executes with the latest and the most performant Java version out there.

调整gradle的编译参数

gradle.properties中允许我们进行各种配置:

配置大内存:

org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8

守护进程

org.gradle.daemon=true

并行编译

org.gradle.parallel=true

开启缓存:

android.enableBuildCache=true

开启孵化模式:

org.gradle.configureondemand=true

以上的配置需要针对自身进行选择,随意配置大内存可能会出现oom。如果想了解这样配置的原理,请移步官方文档

我自己的配置如下:

org.gradle.daemon=true
org.gradle.parallel=true
android.enableBuildCache=true
org.gradle.configureondemand=true
org.gradle.jvmargs=-Xmx3072m -XX:MaxPermSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8

总的来说,除了增加内存这一项感觉还有点用处外,其余配置都不痛不痒。我最后直接加了16g内存,解决了大多数的问题。

配置大内存

android {
    dexOptions {
        // 使用增量模式构建
        incremental true
        // 最大堆内存
        javaMaxHeapSize "4g"
        // 是否支持大工程模式
        jumboMode = true
        // 预编译
        preDexLibraries = true
        // 线程数
        threadCount = 8
        // 进程数
        maxProcessCount 4
    }
}

在app的build.gradle中配置大内存也可以有效的提升build速度。

上面有些配置方案在最新的gradle中已经标记为无效了,可以适当的删除掉。

优化crashlytics的upload

上面讲到的都是build过程中的提速,但打包过程不仅仅包含了build,还包含了混淆,签名等流程。如果你的项目用了crashlytics,crashlytics会在混淆时自动上传map文件到服务器,这样可以帮助你在分析崩溃的时候看到的是混淆前的代码和行数,十分方便。

万事有利有弊,我们项目的map文件为6m左右,crashlytics的服务器又是在国外,所以每次都会需要很长的一段时间。

优化点主要是提升上行带宽和网络速度,前者需要硬件的支持,后者可以通过vpn进行优化。在配置release包打包命令的时候,可以不用每次都把build目录删除,这在一定程度上也可规避此问题。

利用MultiChannelPackageTool进行多渠道打包

我们的应用可能会被分发到多个渠道,而我们又想进行多个渠道的数据分析,这就产生了目前android要打多个渠道包的现状。这篇文章详细的分析了国内最高效的打包方案,文章短小精干,值得一读。

我选择的是MultiChannelPackageTool来进行打包,它的速度是最快的,而且使用方式十分的简单。他的原理是在zip文件的comment中加入渠道号,这样既可以写入渠道号又不会破坏zip的签名,因为apk本身就是一个zip文件,所以这个规则是可靠并完全通用的。

comment

具体的原理和实现方案也不难,这里可以参考赵林写的《一种动态为apk写入信息的方案》进行深入了解。

下面我给大家演示下实际的情况:

package

现在我们可以通过

MCPTool.getChannelId(context, "password", "")

得到渠道名称。如果你用的是友盟来做监控和统计,那么你肯定需要在代码中设置友盟的key和channel名。通过友盟的文档和论坛我发现友盟最新的sdk提供了这样的配置机制,于是就有了如下代码:

// 设置key和渠道号,在application中就需要进行设置
UMAnalyticsConfig config = new UMAnalyticsConfig(context, appKey, channelId);
MobclickAgent.startWithConfigure(config);

// 得到key和渠道号
String appKey = AnalyticsConfig.getAppkey(activity);
String channel = AnalyticsConfig.getChannel(activity);

采用增量编译

instant Run

Instant Run是谷歌的一套解决方案,兼容性肯定是一流的,但是对于奔溃和调试来说经常不尽人意,只能静待其发展了。

jirebel

jirebel

as目前已经支持了增量编译,但是效果真的很差,甚至经常会增加build时间,所以这里我还是推荐一直在更新的Jrebel做增量编译的工具。

我之前写《Android中UI实时预览实践》的时候就有推荐过它,只不过那时候真的太贵了。现在as出了增量编译,它也坐不住了,立刻降价,价钱还算是可以接受。它的优点是成熟稳定,各种配套服务支持的十分完善,但crash 后仍旧需要重新全量编译,单次全量编译、安装的速度非常慢。一个更大的风险点在于每次gradle更新它都是必须要更新的,使用的时候经常要调试这方面的问题。

如果你写的是小型应用的话,效果会更好。现在它已经不用我们单独配置maven仓库了,完全和项目解耦,而且它竟然支持注解和aop,堪称黑科技!所以,如果你有心想要加快打包的速度,我强烈推荐你去试用上21天,看看它是否值得你为之付费。

freeline

如果你想试试免费的工具,那么freeline绝对是你的首选。
几个月来,它发展迅速,也配备了as插件gradle插件,支持各种平台。关于它的原理,可以参考《Freeline - Android平台上的秒级编译方案》进行了解。

freeline只支持python2.7版本,对于重命名等操作支持力度不足,不支持 databinding,不支持删除带id 的资源。实际使用中还是会发现要经常clean后build,对于不同的flavor也是没办法动态切换,只能写死:

freeline {
    hack true
    productFlavor 'dev'
}

总体来说freeline算是目前免费的增量编译中的最优秀的作品了,值得一试。

关于各种增量工具的对比可以参考:Android 加速构建方案对比 - DiyCode

尾声

本文的需求相当简单明了,但实现起来却都是八仙过海各显神通,并没有所谓的一站式解决方案。其实技术的走向也是如此,各个技术的涌现都是为了解决实际需求中的问题,脱离业务而出现的技术方案是走不远的。早期android中app偏向简单,对于打包时间并没有过于在意。随着移动化进程的发展,app过于复杂和庞大,打包提速变得十分重要,这也是纂写本文的初衷。

提速的涉及面很广,涉及到库开发、方法数、电脑平台、app瘦身等多个领域,也希望大家在阅读完毕后能够有所收获,在以后遇到提速需求的时候可以有一些解决思路。

developer-kale@foxmail.com