一、背景
子曰:“工欲善其事,必先利其器”。在Android開發中,IDEA就是我們的工具,想要提高開發效率,就必須把我們的工具打磨“鋒利”。
SDK工程随着功能日益豐富、項目規模也越來越龐大。這時候由于要編譯大量的源代碼和資源檔案,編譯速度也變得越來越慢,甚至有時候發現修改一行代碼,demo編譯很久甚至卡住了。這個時候基本什麼都做不了,隻能清除緩存或者重新開機IDEA。基于以上情況,需要對工程編譯進行優化。
二、編譯分析
針對工程編譯慢和編譯卡住問題,我們将通過以下步驟去分析問題:
- 日志分析
- 堆棧檢視
- 插件調試
- 任務耗時分析
2.1 日志分析
編譯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-----"
}
看到以下這些任務在執行階段卡住了,隻有執行前沒有執行後。
2.2 堆棧檢視
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的堆棧,暫時沒有定位到開發層面上代碼問題。
2.3 插件調試
調試過程中,有時候列印日志不能滿足需求,需要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按鈕 就可以愉快地進行插件調試了
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
}
具體效果如下,會從耗時最長任務開始列印
以上這種方式隻能看到任務執行時候的耗時情況,不過比較友善,無需執行額外的指令,每次編譯demo都能看到。如果想要看到初始化、配置階段的耗時可以用指令行方式去檢視。
例如:./gradlew :androidx:assembleAt37GamesDebug --profile
- Summary:總的建構時間
- Configuration:配置階段花費時間
- Dependency Resolution:依賴解析階段花費時間
- Artifact Transforms: 任務transform花費的時間
- Task Execution:每個任務執行時間
可以看到建構總共耗時8.394s,各個階段的耗時情況也比較清晰。
檢視Configuration這個Tab下
目前執行的是全球平台,發現配置階段有很多無關的任務在執行,這裡可以優化成隻配置任務依賴的子產品而不是工程所有子產品。
更詳細的gradle建構資訊,可以用掃描指令 ./gradlew :androidx:assembleAt37GamesDebug --scan
三、優化配置
3.1 按需配置
在以上分析過程中,定位到很多無關任務在配置階段執行了。是以在gradle.properties啟用按需配置,配置請求任務相關的,即僅執行目前任務依賴的腳本檔案。
# 啟用按需配置
org.gradle.configureondemand=true
配置之後,運作的時候報錯了。
因為現在是按需配置,編譯demo的時候不會去執行merge任務的操作(打包SDK 才會執行的任務)。因為buildSDK插件監聽了gradle執行的生命周期,是以仍會回調,
解決方案就是在生命周期的時候判斷是否在執行打包或同步配置。
3.2 開啟gradle緩存
#嘗試為所有建構重用以前建構的輸出
org.gradle.caching=true
#開啟建構緩存
android.enableBuildCache=true
gradle建構會緩存建構的輸出,這樣後續建構過程中如果輸入内容沒有變化可以直接利用這些緩存加快建構速度。在建構日志中,任務複用緩存會有FROM-CACHE日志。
3.3 開啟kotlin的增量、并行編譯
#開啟kotlin的增量編譯
kotlin.incremental=true
kotlin.incremental.java=true
kotlin.incremental.js=true
kotlin.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。
開啟後看到booster插件不支援,報ConcurrentModificationException異常,暫時選擇不配置。
四、優化效果
優化後編譯demo不再出現卡住的問題,同時每個子產品也能單獨編譯生成aar包。
基于裝置(MacBook Pro i5四核處理器 16GB記憶體)進行測試,每次均clean project後再采集。
以上資料分别采樣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
作者:林俏耿
來源-微信公衆号:三七互娛技術團隊
出處:https://mp.weixin.qq.com/s/f3THdElwVwNTFxQubopUkQ