Kotlin 編譯緩存 Bug

問題

項目最近遇到一個奇怪的問題, 設置了 Log 的開關為 true, 但是實際上卻不生效, 需要每次 clear 后才會生效

斷點調試到對應的地方:

_001.png

此時通過 Debug 窗口, 查看 ApBuildCofig.LOGCAT_DISPLAY 的值是 true :

_002.png

斷點進入 Plog 的方法里, 發(fā)現此時的值變成了 false :

_003.png

由于項目開發(fā)的過程中, 需要經常對該值進行修改, 則每次 clear + build 的時間, 會變得很長

一次完整的編譯大型項目, 時間可能超過 10+ 分鐘, 這是完全無法接受的.

分析

此問題是最近才出現的, 之前并沒有出現過

考慮是最新修改了 gradle 版本, kotlin 版本, 或者升級了 IDE 引起的, 或相關的代碼改動引起

需要 clear 才能正常, 不影響完整的編譯打包

說明該問題和編譯有關, 準確說和編譯緩存有關系

還原問題

此問題是最近才出現的, 之前并沒有出現過

這個問題比較好解, 查看了最近的 kotlin 版本, 上一次升級是在兩個月前, 說明不是 kotlin 版本的問題.

再看看 gradle 版本也是如此.

IDE 的情況, 自己確實升級了最新的 Android Studio 4.1 版本, 不過有另外同事的 IDE 版本沒有升級, 也出現了這個問題, 可排除由于編譯版本升級更新導致的問題.

剩下的是改動了某段代碼引起的問題, 但由于近期修改提交較多, 較難定位, 而且問題的表現可能還是和編譯有關, 先看看第二個問題有沒有結果, 再反推改動的代碼

需要 clear 才能正常, 不影響完整的編譯打包

首先通過 IDE 直接反編譯 kotlin ,得到編譯后的 java 文件:


kotlin_showbyte.jpg
kotlin_decompile.png
public final class MainActivity extends AppCompatActivity {
   protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      this.setContentView(1300051);
      //可以看到, 編譯后的結果, 是直接設置了一個值, 而不是將 ApBuildCofig.LOGCAT_DISPLAY 傳入
      Plog.setLogcatSwitch(false);
   }
}

到了這一步, 已經很好的解釋了文章最開頭的問題:

ApBuildCofig.LOGCAT_DISPLAY 的值是 true, 但是進入的 Plog 里面, 得到的值是 false.

因為 kotlin 編譯 static final 屬性(即常量) 的時候, 認為此常量的值是不會變化的, 則直接將常量的值取出來, 不再需要引用該常量.

至此, 問題已經很清晰了: 應該是在編譯 kotlin 的時候, 對應的 gradle task 認為所引用的常量(ApBuildCofig.LOGCAT_DISPLAY)沒有變化, 則不需要重新編譯當前 kotlin 文件, 從而導致 Plog 得到的是一個舊的值.
而對于第一個問題也比較清晰了, 改動的代碼之前是用 java 語言寫的, 近期才改用 kotlin

測試還原場景

問題雖然已經定位清楚, 但是還沒有找到根本原因, 即:
為什么 kotlin 會認為 ApBuildCofig.LOGCAT_DISPLAY 值沒有變化, 從而跳過了重新編譯階段, 直接使用了上一個的緩存?

相關的類

為此, 我特地將項目的情況直接用一個 demo 還原. 下面是還原 demo 的文件, 建議直接下載 demo 查看關系, 或者直接看類關系圖:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Plog.setLogcatSwitch(AppBuildConfig.LOGCAT_DISPLAY)
    }
}
public class Plog {
    private static boolean logcatSwitch;
    public static void setLogcatSwitch(boolean logcatSwitch) {
        Plog.logcatSwitch = logcatSwitch;
    }
}
public class AppBuildConfig {
    public static final boolean LOGCAT_DISPLAY = BuildConfig.LOG;
}

其中, BuildConfig 這個類, 是通過 IDE 編譯自動生成的:

//自動生成的類
public final class BuildConfig {
  //other.....
  // Field from default config.
  public static final boolean LOG = false;
}

gradle 寫入該值:

image

/**
 * 獲取當前 Log 開關
 */
private String getCurrentProperties() {
    Properties property = new Properties()
    File propertyFile = new File(rootDir.getAbsolutePath(), "project.properties")
    property.load(propertyFile.newDataInputStream())
    return property.getProperty("log")
}

而對應的 project.properties 是整個項目的配置文件, 里面的內容:

log=false

類關系圖

類關系圖.png

其中 MainActivity 是 kotlin 編寫. 根據上面的分析, 由于 MainActivity.kt 沒有重新編譯, 導致當我們修改 project.properties 的值時, Plog 得到的還是上一次 MainActivity.kt 的編譯值.

查看編譯任務

為了驗證上面的結論, 修改 project.properties 的內容:

log=true

改動后, 點擊 Run 運行, 查看 Build 窗口:

uptodate.png

可以看到, kotlin 的 task 任務后面直接顯示: UP-TO-DATE, 即跳過了編譯, 直接使用緩存.

眾所周知, kotlin 在 1.2.20 的版本后, 開始支持 Gradle 的構建緩存, 對應的 compileDebugKotlin 這個 task , 會根據計算, 看是否需要跳過運行, 直接使用上一次的編譯結果.

Gradle 的構建緩存規(guī)則, 可直接在看文最后的參考鏈接, 其中有一個比較重要的規(guī)則, 即: 輸入沒有變化, 所以 compileDebugKotlin 跳過了此次任務.

而輸入的內容, 也包含很多, 比如 kotlin 文件是否有更改, 路徑有沒有變化, 以及它關聯的類有沒有變化等等.

導致該 bug 的原因是:

kotlin 文件(Mainactivity.kt) 本身并沒有變化, 它關聯的類 AppBuildConfig 也沒有變化, 所以 compileDebugKotlin 這個任務跳過了編譯, 直接使用了上一次的編譯結果, 而 kotlin 在編譯的時候, 又會自動將常量引用直接替換成值, 所以哪怕 AppBuildConfig 關聯的類 BuildConfig 發(fā)生變化了, 但是沒有影響到 Mainactivity.kt, 從而導致 它傳了一個錯誤的值給 Plog, 這也是為什么 clear 后即可, 因為 clear 會將上一次的緩存清理掉.

擴展

根據上面的結論, 我測試發(fā)現, kotlin → A.常量 → B.常量. 如果修改 B 的常量值, kotlin 的編譯任務無法察覺到此時輸入已經改變了, kotlin 需要重新編譯, 這大概是 kotlin 構建緩存的一個 Bug

解決方案

找到了問題, 其實已經很好解決, 最好的方式就是讓編譯 kotlin 的任務 compileDebugKotlin 能夠識別這種變化, 這種需要修改 kotlin 的編譯插件.

方案一

比較簡單的解決方法是, 直接讓 kotlin 的編譯任務緩存失效:

this.afterEvaluate { Project project ->
    //獲取編譯 kotlin 的任務
    def buildTask = project.tasks.getByName('compileDebugKotlin')
     //要求該任務不可跳過
    buildTask.outputs.upToDateWhen {
        false
    }
}

上面的方式簡單粗暴, 但是每次都需要重新編譯 kotlin, 代價也很高, 特別是當項目中的 kotlin 文件較多的時候, 我們可以監(jiān)聽配置文件有沒有改變, 如果有改變的時候才強制任務不可跳過:

this.afterEvaluate { Project project ->
    //獲取編譯 kotlin 的任務
    def buildTask = project.tasks.getByName('compileDebugKotlin')
    //讀取上一次的值
    def (String logCat, File propertyFile) = getLastProperties()
    //讀取當前值
    def currentLog = getCurrentProperties()
    System.out.println("upToDateWhen:" + (logCat == currentLog))
    //對比這兩個值是否相等, 如果相等, 允許 UP-TO-DATE, 即允許使用緩存, 跳過 kotlin 編譯
    buildTask.outputs.upToDateWhen {
        logCat == currentLog
    }
    //寫入當前的 logcat 值, 供下一次編譯判斷
    propertyFile.write("log=$currentLog")
}

方案二

kotlin 的編譯任務, 之所以使用緩存, 是因為它的輸入時一致的, 我們只需要破壞它的輸入即可

有兩個修改點, 一個是修改編譯后的產物, 直接將 app/build/tmp/kotlin-class 對應的文件刪除, 則 kotlin 會發(fā)現上一次的產物和存下來的哈希值不一樣, 則會自動重新編譯整個 kotlin, 但是這種速度較慢, 和上面一個強制任務不使用緩存的原理是一樣的

還有一個修改點是, 直接修改源文件, 在目標文件里追加一些注釋, 則 kotlin 認為目標文件改動了, 就僅編譯指定的 kotlin 文件:

this.afterEvaluate { Project project ->
    //讀取上一次的值
    def (String logCat, File propertyFile) = getLastProperties()
    //讀取當前值
    def currentLog = getCurrentProperties()
    System.out.println("upToDateWhen:" + (logCat == currentLog))
    
   //第二種方案
    File file = new File(rootDir.getAbsolutePath() + "/app/src/main/java/com/siyehua/kotlincomplierbug", "MainActivity.kt")
    System.out.println("upToDateWhen:" + file.path)

    if (logCat != currentLog && file.exists()) {
        //開關不不一樣, 且緩存存在, 則直接將緩存刪除
        def list = file.text
        if (!list.endsWith("\n/*gradle change file*/")) {
            file.append("\n/*gradle change file*/")
            System.out.println("upToDateWhen:" + "change targe file1")
        } else {
            list = list.replace("\n/*gradle change file*/", "")
            file.write(list.toString())
            System.out.println("upToDateWhen:" + "change cache file2")
        }

    }


    if(logCat != currentLog &&file.exists()){
        //開關不不一樣, 且緩存存在, 則直接將緩存刪除
        file.delete()
        System.out.println("upToDateWhen:" + "delete cache file")
    }   
    //寫入當前的 logcat 值, 供下一次編譯判斷
    propertyFile.write("log=$currentLog")
}

方案二的優(yōu)化的速度要比方案一快上不少, 最主要是的是僅編譯目標 kotlin 文件

工程

https://github.com/siyehua/KotlinCompilerBug

參考資料

kotlin 構建緩存特性: https://www.oschina.net/news/92528/kotlin-1-2-20-released

gradle task up-to-date : http://www.itdecent.cn/p/eb3fb33e4287

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容