Kotlin 编译缓存 Bug

问题

项目最近遇到一个奇怪的问题, 设置了 Log 的开关为 true, 但是实际上却不生效, 需要每次 clear 后才会生效

断点调试到对应的地方:

_001.png

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

_002.png

断点进入 Plog 的方法里, 发现此时的值变成了 false :

_003.png

由于项目开发的过程中, 需要经常对该值进行修改, 则每次 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 的构建缓存规则, 可直接在看文最后的参考链接, 其中有一个比较重要的规则, 即: 输入没有变化, 所以 compileDebugKotlin 跳过了此次任务.

而输入的内容, 也包含很多, 比如 kotlin 文件是否有更改, 路径有没有变化, 以及它关联的类有没有变化等等.

导致该 bug 的原因是:

kotlin 文件(Mainactivity.kt) 本身并没有变化, 它关联的类 AppBuildConfig 也没有变化, 所以 compileDebugKotlin 这个任务跳过了编译, 直接使用了上一次的编译结果, 而 kotlin 在编译的时候, 又会自动将常量引用直接替换成值, 所以哪怕 AppBuildConfig 关联的类 BuildConfig 发生变化了, 但是没有影响到 Mainactivity.kt, 从而导致 它传了一个错误的值给 Plog, 这也是为什么 clear 后即可, 因为 clear 会将上一次的缓存清理掉.

扩展

根据上面的结论, 我测试发现, 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 文件较多的时候, 我们可以监听配置文件有没有改变, 如果有改变的时候才强制任务不可跳过:

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 会发现上一次的产物和存下来的哈希值不一样, 则会自动重新编译整个 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")
}

方案二的优化的速度要比方案一快上不少, 最主要是的是仅编译目标 kotlin 文件

工程

https://github.com/siyehua/KotlinCompilerBug

参考资料

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

gradle task up-to-date : https://www.jianshu.com/p/eb3fb33e4287

你可能感兴趣的:(Kotlin 编译缓存 Bug)