子曰:“工欲善其事,必先利其器”。在Android开发中,IDEA就是我们的工具,想要提高开发效率,就必须把我们的工具打磨“锋利”。
SDK工程随着功能日益丰富、项目规模也越来越庞大。这时候由于要编译大量的源代码和资源文件,编译速度也变得越来越慢,甚至有时候发现修改一行代码,demo编译很久甚至卡住了。这个时候基本什么都做不了,只能清除缓存或者重启IDEA。基于以上情况,需要对工程编译进行优化。
针对工程编译慢和编译卡住问题,我们将通过以下步骤去分析问题:
日志分析
堆栈查看
插件调试
任务耗时分析
编译demo 现在执行的是./gradlew :androidx:assembleAt37GamesDebug 这个命令,为了定位问题查看更多日志可以加上-d或者–-debug参数
执行./gradlew :androidx:assembleAt37GamesDebug -d
看到有8个任务同时在进行,同时一直循环等待锁、获取锁、释放锁。
我们看下这些任务是什么意思
prepareGitHookConfig:拷贝pre-commit脚本到hooks目录,处理git提交前的一些代码规范校验
packageDebugRenderscript:处理renderscript
compileDebugAidl:将.aidl文件通过工具转换成编译器能够处理的Java接口文件
processResources :复制生产资源到生产 class 文件目录
compileJava :使用 javac 命令编译产生 java源文件
查看以上任务,并没有定位出什么问题。其实目前我们并不知道gradle 执行到什么阶段,我们不妨看看目前是卡到gradle的哪一个阶段。
Gradle生命周期大概分为初始化(Initialization)、配置(Configuration)、执行(Execution)三个阶段。
在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-----"
}
看到以下这些任务在执行阶段卡住了,只有执行前没有执行后。
jstack工具可以分析线程死循环、线程阻塞、死锁等问题。Java 1.7 及更高版本,可以使用 jcmd
命令,功能更为全面。
具体使用:
首先用jps命令查看进程pid
由于目前工程使用的是java1.8,所以使用jcmd命令,查看进程堆栈命令为 jcmd <pid> Thread.print
堆栈日志很多,只截取部分日志截图。
看到main线程,优先级为5,操作系统优先级31,CPU占用时间为883.70毫米,已经运行78.34秒,线程ID为0x00007fc39b80b000,
线程为等待状态,在同步队列,等待的内存地址是0x0000000701100000。看起来似乎是正在等待其他线程资源的释放或者状态改变。
继续往下看日志,看到线程名称为“File lock request listener”,线程处于运行状态。
根据日志,大概的关系如图:
线程执行的是线程执行的方法是 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的堆栈,暂时没有定位到开发层面上代码问题。
调试过程中,有时候打印日志不能满足需求,需要Debug进行调试
这种方式比较简单,只需要在IDE找到对应的任务选择debug模式,然后在相应地方打上断点就可以进行调试。
自定义插件任务调试的步骤就稍微复杂一些
1.新增一个Remote JVM Debug配置
2.配置插件名字(名字可以任意命名,容易区分就行)
3.gradle 命令执行
IDEA的Configuration切换到上一步新建的配置
然后gradle命令执行任务名称,例如:assembleDebug
./gradlew assembleDebug -Dorg.gradle.debug=true --no-daemon
4.打好断点,开启调试
上一步执行完命令后,gradle处于等待状态
紧接着打好断点,点击5中的debug按钮 就可以愉快地进行插件调试了
利用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
}
具体效果如下,会从耗时最长任务开始打印
以上这种方式只能看到任务执行时候的耗时情况,不过比较方便,无需执行额外的命令,每次编译demo都能看到。如果想要看到初始化、配置阶段的耗时可以用命令行方式去查看。
例如:./gradlew :androidx:assembleAt37GamesDebug --profile
Summary:总的构建时间
Configuration:配置阶段花费时间
Dependency Resolution:依赖解析阶段花费时间
Artifact Transforms: 任务transform花费的时间
Task Execution:每个任务执行时间
可以看到构建总共耗时8.394s,各个阶段的耗时情况也比较清晰。
查看Configuration这个Tab下
目前执行的是全球平台,发现配置阶段有很多无关的任务在执行,这里可以优化成只配置任务依赖的模块而不是工程所有模块。
更详细的gradle构建信息,可以用扫描命令 ./gradlew :androidx:assembleAt37GamesDebug --scan
在以上分析过程中,定位到很多无关任务在配置阶段执行了。所以在gradle.properties启用按需配置,配置请求任务相关的,即仅执行当前任务依赖的脚本文件。
# 启用按需配置
org.gradle.configureondemand=true
配置之后,运行的时候报错了。
因为现在是按需配置,编译demo的时候不会去执行merge任务的操作(打包SDK 才会执行的任务)。因为buildSDK插件监听了gradle执行的生命周期,所以仍会回调,
解决方案就是在生命周期的时候判断是否在执行打包或同步配置。
#尝试为所有构建重用以前构建的输出
org.gradle.caching=true
#开启构建缓存
android.enableBuildCache=true
gradle构建会缓存构建的输出,这样后续构建过程中如果输入内容没有变化可以直接利用这些缓存加快构建速度。在构建日志中,任务复用缓存会有FROM-CACHE
日志
。
kotlin.incremental=true
kotlin.incremental.java=true
kotlin.incremental.js=true
kotlin.caching.enabled=true
kotlin.parallel.tasks.in.project=true
在构建日志中,任务增量编译会显示UP-TO-DATE
#并行运行
kapt.use.worker.api=true
#增量编译
kapt.incremental.apt=true
#如果用kapt依赖的内容没有变化,会完全重用编译内容
kapt.include.compile.classpath=false
kapt.include.compile.classpath控制是否将编译类路径中的类包含在注解处理的输入中。
当此选项为false时,注解处理器只能访问项目的源代码和依赖项中的类,而不能访问编译后的类。
这可以确保注解处理器只依赖于源代码和公共API,并减少了注解处理器对不应公开的类的访问。
#开启Kotlin跨模块增量编译
kotlin.incremental.useClasspathSnapshot=true
Kotlin跨模块增量编译要在Kotlin1.7.0版本以后才能生效,目前我们的Kotlin版本是1.5.32,选择暂不开启。
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。
开启后看到booster插件不支持,报ConcurrentModificationException异常,暂时选择不配置。
优化后编译demo不再出现卡住的问题,同时每个模块也能单独编译生成aar包。
基于设备(MacBook Pro i5四核处理器 16GB内存)进行测试,每次均clean project后再采集。
以上数据分别采样10次,去掉最高和最低求的平均值。看到优化后时间大概减少了41%
本文从项目中遇到编译问题出发,讲解笔者从编译分析到优化配置的一个过程。gradle的编译构建受多个因素的影响,比如硬件配置、项目规模、编译配置、依赖关系和构建脚本的复杂性等等。在实际项目中,可以根据项目具体情况,选择优化方式以提高项目编译构建的性能和速率。