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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容