跳到主要内容

Kotlin 2.3.20 现已发布!又有什么好东西?

· 阅读需 14 分钟
法欧特斯卡雷特
可爱小猫咪一枚呀

大家吼哇,这次轮到 Kotlin 2.3.20 登场啦! 本次更新内容可以在 JetBrains 官方的 What's new in Kotlin 2.3.20 查阅, 我照例挑自己比较感兴趣的一些改动聊聊。

一句话总结:语言层面的新玩具不算夸张,但工具链、多平台和互操作体验又被打磨了一圈。对写库、折腾构建、做 KMP 的小伙伴来说,这次还挺香。

注意!这次依旧是「我个人 pick」的更新摘要,覆盖不了全部改动;如果你对某个方向特别感兴趣,记得继续深入官方文档喔。

文中示例如无特殊说明均来自或改写自官方日志。

语言特性

这次语言层面的 headline 不算特别多,不过上来这个我还挺喜欢的:按名称解构

按名称解构(Name-based destructuring)

以前的解构是纯粹按位置来的,也就是说,只要顺序写错,变量名写得再漂亮也没用:

data class User(val username: String, val email: String)

fun main() {
val user = User("alice", "alice@example.com")

val (email, username) = user

println(email)
// alice

println(username)
// alice@example.com
}

看起来像是拿到了 emailusername,实际上完全是反的。 这种情况平时不一定天天能撞上,但只要字段一多、变量名一改,翻车概率就会直线上升。

现在,Kotlin 2.3.20 带来了实验性的按名称解构。显式写法像这样:

fun main() {
val user = User("alice", "alice@example.com")

(val mail = email, val name = username) = user

println(name)
// alice

println(mail)
// alice@example.com
}

简单来说,就是不再依赖 componentN() 的顺序,而是直接按属性名匹配。这个思路我觉得非常合理, 尤其是 data class 字段一长,或者你只是想起个本地变量别名的时候,确实顺手很多。

这个特性目前还是实验性的,可以通过编译器参数启用:

kotlin {
compilerOptions {
freeCompilerArgs.add("-Xname-based-destructuring=only-syntax")
}
}

这个参数有几个模式:

  • only-syntax:只启用显式的按名称解构语法,不改变现有圆括号解构的行为。
  • name-mismatch:如果位置解构时变量名和属性名对不上,会给 warning。
  • complete:连传统的 val (a, b) 也会按名称来匹配,而按位置解构则改用方括号写法。

是的,complete 模式下会变成这样:

val [username, email] = user

有一说一,我第一眼看到方括号的时候脑子里闪过的不是“优雅”,而是“咦?这玩意居然真走到这一步了?”。 不过如果官方真打算长期往“默认按名称解构”这个方向推进,那这套语法倒也说得通。

上下文参数的重载解析调整

除了新特性,这次还有一个挺值得留意的行为变更:带 context parameters 的声明,不再天然比没有 context parameters 的声明更“具体”了。

以前有些重载组合在带上下文的时候会优先挑中带 context 的版本; 而从 2.3.20 开始,这种“优先照顾上下文版本”的规则没了。结果就是: 如果两个重载只有 context parameters 不同,那原本能编译的地方现在可能直接变成歧义错误

官方示例大概是这种感觉:

class Logger {
fun info(msg: String) = println("INFO: $msg")
}

fun saveUser(id: Int) {
println("Saving user $id (no logger)")
}

context(logger: Logger)
fun saveUser(id: Int) {
logger.info("Saving user $id")
}

然后你在 context(logger) { saveUser(1) } 里调用时,2.3.20 会认为这俩候选谁都不该无脑赢,进而报歧义。

这类更新对普通业务代码的体感可能不一定很强,但对库作者、DSL 作者或者喜欢玩上下文参数的小伙伴来说,算是一个需要留意的兼容性点。 顺便一提,kotlin.context 相关重载数量也从 22 个缩到 6 个了,代码补全和解析压力理论上会更轻一些。

标准库

Map.Entry.copy():终于能放心把 Entry 拿出来用了

标准库这次给 Map.Entry 加了一个实验性的 copy() 扩展函数。别看名字普通,这玩意还挺实用。

以前如果你从 map.entries 里拿出一些 Entry,然后再去修改原 map,这些 Entry 往往就不太可靠了。 现在可以先 copy() 成一个不可变副本,再继续干活:

@OptIn(ExperimentalStdlibApi::class)
fun main() {
val map = mutableMapOf(1 to 1, 2 to 2, 3 to 3, 4 to 4)

val toRemove = map.entries
.filter { it.key % 2 == 0 }
.map { it.copy() }

map.entries.removeAll(toRemove)

println("map = $map")
// map = {1=1, 3=3}
}

这类 API 很像那种“你之前不一定天天想得起,但真写集合处理逻辑时会突然觉得:诶这个早该有了吧?”的补丁。

它也是实验性的,使用时需要 @OptIn(ExperimentalStdlibApi::class), 或者加编译器参数 -opt-in=kotlin.ExperimentalStdlibApi

编译器插件

这次编译器插件相关有两个点,我觉得都值得一提。

kotlin.plugin.jpa 终于更像“开箱即用”了

之前如果你用了 kotlin("plugin.jpa"),它主要会帮你开 no-arg 那一套。 但大家都知道,JPA 这边除了无参构造,另一个老生常谈的问题就是:实体类得是 open, 不然懒加载之类的行为很容易歪掉。

现在 2.3.20 把这块也补上了:kotlin.plugin.jpa 会自动连带把 all-open 和新的 JPA preset 一起配置好。 也就是说,常见的这些注解:

  • javax.persistence.Entity
  • javax.persistence.Embeddable
  • javax.persistence.MappedSuperclass
  • jakarta.persistence.Entity
  • jakarta.persistence.Embeddable
  • jakarta.persistence.MappedSuperclass

都会自动获得 open 和 no-arg constructor 的支持,不需要你再手搓一堆额外配置。

虽然我平时并不怎么写 JPA,不过这更新确实很实在。 这种“本来就应该自动帮你做好”的事情,越少让用户记配置越好。

Lombok 插件进入 Alpha

Lombok 编译器插件这次从 Experimental 升到了 Alpha。 对于纯 Kotlin 项目来说,这消息未必多激动; 但如果你是在 Kotlin/Java 混编、历史包袱又比较重的项目里打滚,那这玩意还是挺有存在感的。

官方把它推进到 Alpha,至少说明一个信号:他们是想认真把这条兼容路线继续往前走的,而不是一直把它摆在“试试看”状态。

Kotlin/JVM

这次 Kotlin/JVM 依旧在狠狠干一件很朴素的事:继续抹平和 Java 生态之间的各种细节摩擦。

支持 Vert.x 的 @Nullable 注解

Kotlin 2.3.20 现在可以识别 io.vertx.codegen.annotations.Nullable 了。 如果你在和 Vert.x 相关的 Java API 打交道,这意味着 Kotlin 的空安全检查能更靠谱一些,默认会对空性不匹配给出 warning。

如果你想上更严格的模式,可以加:

kotlin {
compilerOptions {
freeCompilerArgs.add("-Xnullability-annotations=@io.vertx.codegen.annotations:strict")
}
}

这类更新单看不炸裂,但对于框架用户来说,属于很典型的“少踩一点坑就是大胜利”。

支持 Java 的只读集合注解

另一个我觉得更直观的点,是支持 @Unmodifiable@UnmodifiableView 这两个 Java 注解了。

从 2.3.20 开始,如果 Java 声明返回的集合带了这些注解,Kotlin 会把它们当成只读集合来对待。 你如果硬要接到 MutableList 之类的类型上,就会收到类型不匹配 warning。 而这个 warning 计划在 Kotlin 2.5.0 升级成 error。

例如:

// Java
public class Java {
public static @UnmodifiableView List<Object> unmodifiableView() {
return List.of();
}

public static @Unmodifiable List<Object> unmodifiable() {
return List.of();
}
}
fun main() {
// Warning: Java type mismatch
val mutableView: MutableList<Any> = Java.unmodifiableView()
val mutableCopy: MutableList<Any> = Java.unmodifiable()
}

这个挺好。Java 那边已经很努力告诉你“别改”,Kotlin 这边终于也开始正经听话了。

Kotlin/Native

Kotlin/Native 这次依然是熟悉的配方:工程向更新偏多,真正做 KMP / Apple 目标的人会更有感觉

C / Objective-C 互操作的新模式

如果你的 KMP 库或应用里用了 cinteroppod(),这次可以关注一下新的 C / Objective-C 互操作模式。

官方给了一个实验性的 -Xccall-mode direct,目标是改善旧互操作机制带来的一些兼容问题,尤其是那种: 你用新 Kotlin 版本编译了一个带 C / Objective-C 互操作的 KMP 库,结果老版本 Kotlin 工程不好接的问题。

开启方式大概是这样:

kotlin {
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
compilations.configureEach {
cinterops.configureEach {
extraOpts += listOf("-Xccall-mode", "direct")
}
}
}
}

不过要注意,这个模式目前还是实验性的,官方明确说了:先测,先反馈,但先别拿它编译产物去发版。

交叉编译检查器 & 禁用 Native Cache 的新 DSL

另外还有两个更偏工程味儿的更新:

  • 新增 crossCompilationSupported 检查器,用来判断当前 target 是否支持交叉编译。
  • 禁用 Native 编译缓存现在有了新的 DSL,而且必须写明版本和原因。

后者大概像这样:

disableNativeCache(
version = DisableCacheInKotlinVersion.2_3_0,
reason = "Cache bug",
issue = URI("https://youtrack.com/YY-1111")
)

有一说一,这种“你可以关,但必须写清楚为什么关”的设计我觉得挺好。 毕竟 K/N 的 cache 关掉之后性能影响不是开玩笑的,最好别让它变成某种被到处复制粘贴的祖传配置。

Kotlin/Wasm

Kotlin/Wasm 这次属于继续闷声打磨性能和互操作体验。

字符串性能和编译性能继续提升

官方这次给了一组相当好看的数字:

  • 某些基准下字符串插值最高快 4.6 倍
  • KotlinConf 应用的 Wasm 产物体积大约缩小 5%
  • 一些 StringBuilder.append() / 字符串拼接场景至少快 20%
  • Clean build 时间提升 65%
  • 增量 build 提升 21%

原因大体上是两部分:

  • 运行时上,K/Wasm 开始更多利用 JS String builtins 来处理 kotlin.String
  • 编译器上,又做了一轮内存和编译流程优化

如果你之前对 K/Wasm 的印象还是“能跑,但总觉得哪里不太爽”,那这类更新其实非常关键。 它不一定是 headline 级别的新语法,但很可能决定你下一次还愿不愿意继续试它。

支持 @nativeInvoke

这次还给 wasmJs 目标加了一个实验性的 @nativeInvoke,可以把某些 external JS 对象当函数那样直接调用。

import kotlin.js.nativeInvoke

@OptIn(ExperimentalWasmJsInterop::class)
external class JsAction {
@nativeInvoke
operator fun invoke(data: String)
}

fun main() {
val action = JsAction()
action("Run task")
}

这功能本质上是给 JS 互操作先垫一个临时台阶,官方也直说了: 它未来可能调整,甚至可能移除。所以现阶段更适合把它看成一个过渡性的实验工具。

Kotlin/JS

来到我比较感兴趣的部分了。K/JS 这次的两个点,一个偏互操作,一个偏构建链路,我都挺喜欢。

终于可以从 TypeScript 实现 Kotlin 接口了

这点我觉得很有意思。以前 Kotlin 接口导出到 TS 后,你可以“看见它”,但不能真的在 TS 里把它实现出来。 现在这条限制放开了。

例如 Kotlin 这边:

@JsExport
interface DataProcessor {
suspend fun process(): String
}

@JsExport
fun registerProcessor(processor: DataProcessor) { /* ... */ }

TypeScript 这边就可以这么写:

import { DataProcessor, registerProcessor } from "my-kmp-library"

class JsonProcessor implements DataProcessor {
readonly [DataProcessor.Symbol] = true

async process(): Promise<string> {
return "processed JSON data"
}
}

registerProcessor(new JsonProcessor())

而且默认实现也不是完全没法复用。 虽然 TypeScript 本身没有 Kotlin 这种接口默认实现的概念,但可以通过 DefaultImpls 去代理:

class ConsoleLogger implements Logger {
readonly [Logger.Symbol] = true

log(): string {
return Logger.DefaultImpls.log(this)
}

get prefix(): string {
return Logger.DefaultImpls.prefix.get(this)
}
}

开启方式也不复杂:

kotlin {
js {
generateTypeScriptDefinitions()
compilerOptions {
freeCompilerArgs.add("-Xenable-implementing-interfaces-from-typescript")
}
}
}

这个改动对那种“用 Kotlin 写核心逻辑,然后想自然暴露给 TS 生态”的场景帮助非常大。 至少终于不是“导出个类型给你看看,真要实现还得绕路”了。

支持 SWC 作为转译平台

另一个点是 K/JS 现在开始实验性支持 SWC。

简单理解就是:Kotlin/JS 编译器以后可以更专注于产出现代 JS, 而把“把现代 JS 再转成老一点、更兼容的 JS”这件事交给专业工具 SWC 去干。

启用方式是在 gradle.properties 里加:

kotlin.js.delegated.transpilation=true

官方还提到,这会让后续支持更现代的 inline JS 语法、甚至基于 browserslist 的 DSL 变得更自然。

总之这是个很典型的“眼下用户感知未必立刻特别强,但架构方向很对”的更新。 K/JS 这几年一直在和构建链路、产物形态、生态兼容性较劲,把转译能力外包给更成熟的工具,我觉得是好事。

Gradle & Maven

这次构建工具相关的内容不少,而且不少都还挺实用。

Gradle 9.3.0 兼容 & ABI 校验任务更顺手了

先说两个比较直接的:

  • Kotlin 2.3.20 兼容 Gradle 7.6.39.3.0
  • Binary compatibility validation 的任务名更新了,checkLegacyAbi 之类的名字改得更直白了
  • 如果你启用了 ABI 校验,跑 check 时现在也会自动带上 checkKotlinAbi

这几个改动虽然不花哨,但都挺“舒服”的。 尤其是最后一个,本来 check 就应该干完校验类任务,不自动带上 ABI 检查总让人觉得差口气。

Kotlin/JVM 编译默认走 Build Tools API

从 2.3.20 开始,Kotlin Gradle 插件里的 Kotlin/JVM 编译默认使用 Build Tools API 了。

这个变化更偏底层基础设施,对普通业务开发者来说体感可能不算特别明显; 但它能让 Kotlin 团队以后更快地推进编译器和构建工具之间的支持演进,所以本质上算是个“为了未来铺路”的更新。

如果你升级之后遇到奇怪的构建问题,官方也建议直接去 issue tracker 反馈。

Maven 项目配置更省事了

Maven 这边倒是有一个挺直观的改进:现在 Kotlin Maven 插件可以帮你自动配置源码目录和 kotlin-stdlib 依赖。

只需要在 pom.xml 里这样加一个 extensions

<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<extensions>true</extensions>
</plugin>
</plugins>
</build>

之后它会自动:

  • src/main/kotlinsrc/test/kotlin 当作源码目录
  • 在你没手动声明的情况下自动补上 kotlin-stdlib

如果你不想要这套“智能默认值”,也可以在 properties 里关掉:

<project>
<properties>
<kotlin.smart.defaults.enabled>false</kotlin.smart.defaults.enabled>
</properties>
</project>

对于 Gradle 用户来说,这消息可能没有那么让人热血沸腾; 但对于还在 Maven 世界里认真生活的小伙伴们,这波绝对是实打实的减负。

其他值得一提的内容

官方这次其实还更新了不少更偏底层或更偏生态整合的东西,比如:

  • Build Tools API 的构建操作支持取消、支持更一致的指标采集
  • 构建工具可以更自然地配置编译器插件
  • Kotlin/Native 的一些目标支持策略继续调整
  • 文档又补了不少 KMP、Compose、Ktor、Exposed 的内容

另外,官方也列了一些破坏性变更和弃用,这里简单提两个我觉得比较容易让人留意的:

  • 实验性的 context receivers 正式不再支持了,现在请老老实实拥抱 context parameters
  • macosX64tvosX64watchosX64 这些 Intel Apple 目标继续往弃用方向推进;iosX64 暂时还留在 Tier 3

如果你正好在这些边边角角上踩着线,还是建议去官方文档把 breaking changes 那一节完整看一眼。

尾声

这次 2.3.20 给我的感觉是:不是那种一眼看上去“哇新语言革命来了”的大版本,但工具链、多平台和互操作体验都在继续稳扎稳打地变顺手。

我个人最感兴趣的几个点,一个是按名称解构,一个是 K/JS 终于能让 TypeScript 正经实现 Kotlin 接口,另一个则是 Maven 那套更省事的初始化配置。 你呢?这次更新里有没有哪个点正好戳中你?