Android 开发从 Java 8 到 17 的迁移,还有多少老项目没动
Android 开发从 Java 8 到 17 的迁移,还有多少老项目没动
AGP 8.0 发布已经一年半了,Google 那把刀早就磨好。从 2023 年 4 月开始,Android Gradle Plugin 8.0 明确要求构建环境必须跑在 JDK 17 上,Android Studio Flamingo 也跟着直接捆绑了 OpenJDK 17。但你去翻 GitHub 上那些 star 数过万的 Android 项目,或者拉一下国内各大厂的开源 Demo,会发现一个挺魔幻的现实:build.gradle 里的 sourceCompatibility JavaVersion.VERSION_1_8 和 targetCompatibility JavaVersion.VERSION_1_8 依然稳如泰山,甚至有些项目连 AGP 都还在 7.4.2 苟着,Gradle 7.5 一跑就是三年。
这不是技术债,这是技术房贷,而且很多人连首付都不想交。
AGP 8.0 的刀,架在谁脖子上
Google 推进这件事的手法向来粗暴。AGP 8.0 的 release notes 写得明明白白:你必须用 Java 17 来跑 Gradle Daemon,否则直接抛错,连 configure 阶段都过不了。很多开发者第一次碰到这个报错是在升级 Android Studio 之后,IDE 自动提示"建议升级 AGP",一点同意,整个项目直接炸成烟花。控制台里那句 Unsupported class file major version 61 成为了很多 Android 工程师 2023 年的噩梦。major version 61 对应的就是 Java 17,你的构建工具链只要有一个环节还在用 Java 11 或 Java 8,AGP 8.0 连看都不想看你的代码一眼。
但这里有个绝大多数人混淆的概念:Google 逼你用的是 JDK 17 来"构建",不是逼你把代码写成 Java 17 的语法。Android runtime 对 Java 语言级别的支持完全是另外一回事。你完全可以在 AGP 8.2 + Gradle 8.2 + JDK 17 的环境下,继续写你的 Java 8 语法,甚至你的 compileOptions 里可以继续填 VERSION_1_8。这种分裂感贯穿了整个迁移过程——我们被强制换上了新的施工队,但盖房子的砖还是二十年前的款式。
更讽刺的是,现在主流的 Android 新项目根本不用 Java 写业务代码。Kotlin 早就成了事实标准,Jetpack Compose 甚至把 Kotlin 的语法特性当成了框架设计的核心假设。一堆写 Kotlin 的开发者,为了能让 Gradle 跑起来,不得不去折腾 JAVA_HOME、Jenkins Agent 镜像、GitHub Actions 的 setup-java action,把 JDK 从 11 换成 17,然后看着自己满屏的 val 和 suspend fun,思考自己到底为什么要为 Java 的升级买单。
语法糖和运行时,完全是两码事
Java 17 作为 LTS 版本,带了不少语法层面的甜头。record 类、sealed 类、pattern matching for switch,还有 instanceof 的模式匹配,这些东西在服务端开发里已经被吹了好几轮。但 Android 不是服务端,Dalvik/ART 不是 HotSpot,你的 .java 文件最终要过一遍 javac,再过 D8/R8,变成 dex 字节码,这个过程中会发生大量的脱糖(desugaring)。
Google 的脱糖工具确实做了不少工作。AGP 7.0 之后,D8 开始支持把 Java 11 和 Java 17 的部分语言特性翻译成旧版本 Android 能理解的字节码。比如 record 类,你可以在 minSdk 21 的项目里写 public record User(String name, int age) {},编译器不会直接报错。但代价是什么?脱糖生成的字节码往往伴随着大量的合成方法和类,记录类会被拆成 final class + 构造函数 + getter + equals/hashCode/toString 的样板代码。对于服务端来说这无足轻重,但对于 APK 体积和低端机的 dex2oat 编译时长来说,这就是实实在在的负担。
至于 pattern matching for switch,这玩意在 Java 17 里还是 preview 特性,直到 Java 21 才正式定型。Android 官方文档里对此的支持态度一直暧昧不清。Android 14(API 34)确实提升了对 Java 17 语言级别的兼容性,但你翻翻 android.jar 里的 java.base 模块,跟标准 OpenJDK 17 的差异依然巨大。很多开发者兴冲冲地把 sourceCompatibility 改成 VERSION_17,写了段带 switch 模式匹配的代码,结果在 Android Studio 里标红,或者编译能通过但运行在 Android 13 设备上直接崩个 VerifyError。这种"薛定谔的支持"比直接不支持还要命。
所以现实是,那些已经完成 JDK 17 构建迁移的项目,绝大多数并没有真的在写 Java 17 语法。他们的代码库里依然满是 final class 而不是 sealed class,依然是冗长的 if (obj instanceof String) 而不是模式匹配,依然是手写 POJO 而不是 record。升级 JDK 纯粹是为了满足构建工具链的暴力要求,跟语言特性的演进基本没关系。
老项目到底卡在哪
真正难搞的从来不是改 compileOptions 那两行。任何一个有半年经验的 Android 开发都能花五分钟把 VERSION_1_8 改成 VERSION_17,但改完之后那几百个编译错误才是地狱的开始。
首先是 Gradle 本身的跨越。AGP 8.0 要求 Gradle 8.0+,而从 Gradle 6.x 甚至 7.x 跳到 8.x,中间横跨的 breaking changes 多到能写一本小册子。maven 插件在 Gradle 7 就废弃了,jcenter() 早就坟头长草,dependencyResolutionManagement 成了新项目的标配但老项目根本没这个。很多祖传项目的 buildSrc 里躺着 2019 年写的 Groovy 脚本,用了大量内部 API,Gradle 8 一跑直接告诉你 Could not find method xxx。更要命的是 AGP 8.0 强制要求每个模块在 build.gradle 里显式声明 namespace,以前靠 AndroidManifest.xml 里的 package 属性混日子的项目,现在得逐模块梳理。
其次是 AndroidX 和第三方依赖的连锁反应。Google 在 AGP 8.0 里默认开启了 android.nonTransitiveRClass=true 和 android.nonFinalResIds=true。这俩选项本意是好的,R 类不再 transitive 能大幅减少编译内存占用,但老项目里那些 switch (R.id.xxx) 的代码会直接炸,因为 R.id 默认不再是 final 常量了。国内很多 SDK,特别是某些推送、地图、支付厂商的 aar,构建时还是基于 AGP 4.x 甚至 3.x。你把主工程升级到 AGP 8.0,这些 SDK 里的 BuildConfig 引用方式、resources 合并逻辑、甚至 jniLibs 的打包路径都可能出问题。更绝的是某些 SDK 在 AndroidManifest.xml 里声明了 package 属性,AGP 8.0 处理这种老格式时偶尔会抽风,报一些你根本定位不到的 manifest merge 错误。
再来是 CI/CD 环境的沉没成本。小团队可能就在一台 Jenkins 上跑构建,那台机器的 JAVA_HOME 指向 /usr/lib/jvm/java-11-openjdk 已经三年了。升级 JDK 17 意味着你要重构整个 Docker 镜像,检查所有构建脚本里的 hardcode 路径。如果你用的是 GitHub Actions,把 actions/setup-java@v3 的 java-version 从 '11' 改成 '17' 确实只要一秒,但跑起来之后如果发现某个祖传 Gradle 插件不兼容 Java 17 的 module system 访问控制,你可能得在深更半夜调试 gradle.plugin 的 classloader 隔离机制。我见过最离谱的情况是某个项目依赖了一个 2017 年的 Gradle 插件,内部用反射访问了 sun.misc.BASE64Encoder,这在 Java 17 的强封装下直接 IllegalAccessException,而那个插件早已无人维护。
Kotlin 项目的荒诞剧
这场迁移里最荒诞的,莫过于 Kotlin 项目的处境。
Kotlin 自己的 JVM target 可以设置成 1.8、11、17 甚至 21。Android Kotlin 项目里,compileOptions 和 kotlinOptions.jvmTarget 长期以来都是分开配置的。很多项目为了保险,长期保持 jvmTarget = '1.8',因为 Kotlin 1.8 之前的版本,哪怕你跑在 JDK 17 上,只要 jvmTarget 设成 1.8,生成的字节码就是 Java 8 兼容的。这种情况下,你的开发机装着 Java 17,但 Kotlin 编译器输出的东西跟 Java 8 时代没什么两样。
Google 推 AGP 8.0 的时候,顺带把 Kotlin 的版本门槛也抬了一手。Jetpack Compose 的 Compiler Extension 版本严格绑定 Kotlin 版本,而新版 Compose 要求 Kotlin 1.8 甚至 1.9,这些 Kotlin 版本又"建议"你把 jvmTarget 调到 11 或 17 以获得更好的性能优化。于是你就看到了一条强制升级链:Google Play 要求 targetSdk 34 -> targetSdk 34 建议用新版 AGP -> AGP 8.0 要求 JDK 17 -> 新版 Compose 要求 Kotlin 1.9 -> Kotlin 1.9 建议 jvmTarget 17。这条链上只要有一个环节你不想跟,后面的全部卡住。
但跟上之后呢?你的代码里全是 Kotlin,没有 record,没有 sealed class(Kotlin 自己的 sealed class 跟 Java 17 的 sealed 完全是两套实现),没有 switch 表达式(Kotlin 用 when)。你费了九牛二虎之力把构建环境迁到 JDK 17,结果写代码的体验跟 JDK 8 时期一模一样。唯一的"收益"是 Gradle 构建速度可能因为 Gradle 8 的并行配置缓存提升了 10%,但为了这 10%,你花了一个月修各种 breaking changes。
生态撕裂:大厂能扛,小厂认命
不同体量的团队面对这场迁移,完全是两个世界。
大厂里有专门的基础架构团队,AGP 8.0 的 release candidate 刚放出来,就有人做全量回归测试。他们把 Gradle