三七互娱技术团队

Android SDK工程编译优化

一、背景

子曰:“工欲善其事,必先利其器”。在Android开发中,IDEA就是我们的工具,想要提高开发效率,就必须把我们的工具打磨“锋利”。

SDK工程随着功能日益丰富、项目规模也越来越庞大。这时候由于要编译大量的源代码和资源文件,编译速度也变得越来越慢,甚至有时候发现修改一行代码,demo编译很久甚至卡住了。这个时候基本什么都做不了,只能清除缓存或者重启IDEA。基于以上情况,需要对工程编译进行优化。

二、编译分析

针对工程编译慢和编译卡住问题,我们将通过以下步骤去分析问题:

    • 日志分析

    • 堆栈查看

    • 插件调试

    • 任务耗时分析

2.1 日志分析

编译demo 现在执行的是./gradlew :androidx:assembleAt37GamesDebug 这个命令,为了定位问题查看更多日志可以加上-d或者–-debug参数

执行./gradlew :androidx:assembleAt37GamesDebug -d

Image

Image

看到有8个任务同时在进行,同时一直循环等待锁、获取锁、释放锁。

我们看下这些任务是什么意思

    • prepareGitHookConfig:拷贝pre-commit脚本到hooks目录,处理git提交前的一些代码规范校验

    • packageDebugRenderscript:处理renderscript

    • compileDebugAidl:将.aidl文件通过工具转换成编译器能够处理的Java接口文件

    • processResources :复制生产资源到生产 class 文件目录

    • compileJava :使用 javac 命令编译产生 java源文件

查看以上任务,并没有定位出什么问题。其实目前我们并不知道gradle 执行到什么阶段,我们不妨看看目前是卡到gradle的哪一个阶段。

Gradle生命周期大概分为初始化(Initialization)、配置(Configuration)、执行(Execution)三个阶段。

Image

在setting.gradle配置监听gradle生命周期

//初始化阶段gradle.settingsEvaluated {    println "-----初始化阶段->回调方法:settingsEvaluated-----"}    //初始化阶段执行完毕gradle.projectsLoaded {    println "-----初始化阶段->回调方法:projectsLoaded-----"}


//配置阶段 //build.gradle 执行前gradle.beforeProject {Project project -> println "-----配置阶段->回调方法:beforeProject,${project.name}配置前-----"} //build.gradle 执行后gradle.afterProject {Project project -> println "-----配置阶段->回调方法:afterProject,${project.name}配置后-----"}gradle.projectsEvaluated { println "-----配置阶段->回调方法:projectsEvaluated,所有项目的build.gralde执行完毕-----"}//配置阶段完毕gradle.taskGraph.whenReady { println "-----配置阶段->回调方法:whenReady 任务依赖关系建立完毕-----"}

//执行阶段gradle.taskGraph.beforeTask { Task task -> println "-----执行阶段->回调方法:beforeTask ,${task.name}执行前-----"}
gradle.taskGraph.afterTask { Task task -> println "-----执行阶段->回调方法:afterTask ,${task.name}执行后-----"}

gradle.buildFinished { println "-----buildFinished-----"}

看到以下这些任务在执行阶段卡住了,只有执行前没有执行后。

Image

2.2 堆栈查看

 jstack工具可以分析线程死循环、线程阻塞、死锁等问题。Java 1.7 及更高版本,可以使用 jcmd 命令,功能更为全面。

具体使用:

首先用jps命令查看进程pid

Image

由于目前工程使用的是java1.8,所以使用jcmd命令,查看进程堆栈命令为 jcmd <pid> Thread.print

Image

堆栈日志很多,只截取部分日志截图。

看到main线程,优先级为5,操作系统优先级31,CPU占用时间为883.70毫米,已经运行78.34秒,线程ID为0x00007fc39b80b000,

线程为等待状态,在同步队列,等待的内存地址是0x0000000701100000。看起来似乎是正在等待其他线程资源的释放或者状态改变。

继续往下看日志,看到线程名称为“File lock request listener”,线程处于运行状态。

Image

根据日志,大概的关系如图:

Image

线程执行的是线程执行的方法是 java.net.PlainDatagramSocketImpl.receive0,该方法是 Java 原生方法(Native Method)。

该线程持有了锁 <0x0000000700cbd520>(java.net.PlainDatagramSocketImpl),并且该锁被其他线程所等待。

该线程还持有了两个锁,分别是 <0x00000007240b6be8>(java.net.DatagramPacket)和 <0x0000000700cbd668>(java.net.DatagramSocket)。

线程最后在 org.gradle.cache.internal.locklistener.FileLockCommunicator.receive 方法执行。

根据上述分析,该线程是一个文件锁请求监听器(File lock request listener),它正在运行并且持有一些锁。

搜索了一遍,目前看到的都是Java sdk的堆栈,暂时没有定位到开发层面上代码问题。

2.3 插件调试

调试过程中,有时候打印日志不能满足需求,需要Debug进行调试

可视化界面调试

这种方式比较简单,只需要在IDE找到对应的任务选择debug模式,然后在相应地方打上断点就可以进行调试。

Image

自定义插件调试

自定义插件任务调试的步骤就稍微复杂一些

1.新增一个Remote JVM Debug配置

Image

2.配置插件名字(名字可以任意命名,容易区分就行)

Image

3.gradle 命令执行

IDEA的Configuration切换到上一步新建的配置

Image

然后gradle命令执行任务名称,例如:assembleDebug

./gradlew assembleDebug -Dorg.gradle.debug=true --no-daemon

4.打好断点,开启调试

上一步执行完命令后,gradle处于等待状态

Image

紧接着打好断点,点击5中的debug按钮 就可以愉快地进行插件调试了

Image

2.4 任务耗时分析

利用gradle生命周期的钩子,在buildSrc目录下新增gradle插件BuildTimeStatisticPlugin, 用于统计任务执行耗时插件。在gradle.properties配置BUILD_TASK_TIME=true,可以打开任务统计开关。

class BuildTimeStatisticPlugin : AbstractPlugin() {    //任务执行情况    val taskRunTimeMap: MutableMap<String, TaskRunTimeEntity> by lazy { HashMap() }
// 插件执行开关 private var buildTimeSwitch: Boolean = true
override fun applyPlugin(target: Project) { buildTimeSwitch = rootProject.properties["BUILD_TASK_TIME"] == "true"
if (!buildTimeSwitch) { return }
saveTaskExecuteTime(target)
outputTaskExecuteTime(target) }
private fun outputTaskExecuteTime(project: Project) { project.gradle.buildFinished { println("#########################################") println("build finish,print all task execute time")
val sortList: MutableList<TaskRunTimeEntity> = ArrayList(taskRunTimeMap.values) with(sortList){ //排序,耗时时间大的在前 sortByDescending { it.totalTime } // 打印task执行时间 forEach { task -> if (task.totalTime > 0) { println("${task.path} [${task.totalTime} ms]") } } }
println("#########################################") } }
//保存task构建时间 private fun saveTaskExecuteTime(project: Project) { project.gradle.addListener(object : TaskExecutionListener { override fun beforeExecute(task: Task) { val taskExecTimeInfo = TaskRunTimeEntity().apply { startTime = System.currentTimeMillis() path = task.path } taskRunTimeMap[task.path] = taskExecTimeInfo }
override fun afterExecute(task: Task, state: TaskState) { taskRunTimeMap[task.path]?.let { it.endTime = System.currentTimeMillis() it.totalTime = it.endTime - it.startTime } } }) }}

class TaskRunTimeEntity { //task执行总时长 var totalTime: Long = 0
//任务路径(工程路径+任务名称) var path: String? = null
//开始时间 var startTime: Long = 0
//结束时间 var endTime: Long = 0}

具体效果如下,会从耗时最长任务开始打印

Image

以上这种方式只能看到任务执行时候的耗时情况,不过比较方便,无需执行额外的命令,每次编译demo都能看到。如果想要看到初始化、配置阶段的耗时可以用命令行方式去查看。

例如:./gradlew :androidx:assembleAt37GamesDebug --profile

Image

    • Summary:总的构建时间

    • Configuration:配置阶段花费时间

    • Dependency Resolution:依赖解析阶段花费时间

    • Artifact Transforms: 任务transform花费的时间

可以看到构建总共耗时8.394s,各个阶段的耗时情况也比较清晰。

查看Configuration这个Tab下

Image

目前执行的是全球平台,发现配置阶段有很多无关的任务在执行,这里可以优化成只配置任务依赖的模块而不是工程所有模块。

更详细的gradle构建信息,可以用扫描命令 ./gradlew :androidx:assembleAt37GamesDebug --scan 

三、优化配置

3.1 按需配置

在以上分析过程中,定位到很多无关任务在配置阶段执行了。所以在gradle.properties启用按需配置,配置请求任务相关的,即仅执行当前任务依赖的脚本文件。

# 启用按需配置org.gradle.configureondemand=true

配置之后,运行的时候报错了。

Image

因为现在是按需配置,编译demo的时候不会去执行merge任务的操作(打包SDK 才会执行的任务)。因为buildSDK插件监听了gradle执行的生命周期,所以仍会回调,

解决方案就是在生命周期的时候判断是否在执行打包或同步配置。

Image

3.2 开启gradle缓存

#尝试为所有构建重用以前构建的输出org.gradle.caching=true#开启构建缓存android.enableBuildCache=true

gradle构建会缓存构建的输出,这样后续构建过程中如果输入内容没有变化可以直接利用这些缓存加快构建速度。在构建日志中,任务复用缓存会有FROM-CACHE日志。

3.3 开启kotlin的增量、并行编译

#开启kotlin的增量编译kotlin.incremental=truekotlin.incremental.java=truekotlin.incremental.js=truekotlin.caching.enabled=true#开启kotlin并行编译kotlin.parallel.tasks.in.project=true

在构建日志中,任务增量编译会显示UP-TO-DATE

3.4 优化kapt

#并行运行kapt.use.worker.api=true#增量编译kapt.incremental.apt=true#如果用kapt依赖的内容没有变化,会完全重用编译内容kapt.include.compile.classpath=false
kapt.include.compile.classpath控制是否将编译类路径中的类包含在注解处理的输入中。

当此选项为false时,注解处理器只能访问项目的源代码和依赖项中的类,而不能访问编译后的类。

这可以确保注解处理器只依赖于源代码和公共API,并减少了注解处理器对不应公开的类的访问。

3.5 其他的配置

1.Kotlin跨模块增量

#开启Kotlin跨模块增量编译kotlin.incremental.useClasspathSnapshot=true

Kotlin跨模块增量编译要在Kotlin1.7.0版本以后才能生效,目前我们的Kotlin版本是1.5.32,选择暂不开启。

2.配置缓存

org.gradle.unsafe.configuration-cache=true# Use this flag carefully, in case some of the plugins are not fully compatible.org.gradle.unsafe.configuration-cache-problems=warn

配置缓存可让 Gradle 记录有关构建任务图的信息,并在后续 build 中重复使用该任务图,而不必再次重新配置整个 build。

Image

开启后看到booster插件不支持,报ConcurrentModificationException异常,暂时选择不配置。

四、优化效果

优化后编译demo不再出现卡住的问题,同时每个模块也能单独编译生成aar包。

基于设备(MacBook Pro   i5四核处理器 16GB内存)进行测试,每次均clean project后再采集。

Image

以上数据分别采样10次,去掉最高和最低求的平均值。看到优化后时间大概减少了41%

五、总结

本文从项目中遇到编译问题出发,讲解笔者从编译分析到优化配置的一个过程。gradle的编译构建受多个因素的影响,比如硬件配置、项目规模、编译配置、依赖关系和构建脚本的复杂性等等。在实际项目中,可以根据项目具体情况,选择优化方式以提高项目编译构建的性能和速率。


引用资料:
1.https://doc.yonyoucloud.com/doc/wiki/project/GradleUserGuide-Wiki/the_java_plugin/java_plugin_tasks.html
2.https://developer.android.com/studio/build/optimize-your-build?hl=zh-cn
3.https://docs.gradle.org/6.7.1/userguide/multi_project_configuration_and_execution.html