解读 Kotlin 1.4

Posted by rarnu on 09-02,2020

Kotlin 1.4 终于正式 release 了,作为其爱好者,肯定要第一时间享用了。在此之前已经试用了好久,从 Early Access 起,持续关注着,但是 EA 的版本无法用于生产也是众所周知的事情,好在现在终于可以起飞了。

对于 1.4 版本,我关注的改进如下:

  • 接口 SAM 转换
  • 混合命名和位置参数
  • 可调用的引用改进
  • 对 when 的改进
  • Web全栈
  • Kotlin/Multiplatform
  • iOS Application

接口 SAM 转换本质上是对一个拥有 单个非默认抽象方法的接口 进行 lambda 转换,对于符合这个条件的接口,称之为 SAM Type。在此之前,官方曾表示过,Kotlin 本身拥有高阶函数,不再需要 SAM 转换,然而在开发者强烈的呼声下,还是妥协了。

这个特性简单做个比较就一目了然:

Kotlin 1.3

interface Action<T> {
    fun run(x: T)
}

fun<T> runAction(x: T, a: Action3<T>) = a.run(x)

fun main() {
    runAction("2333", object: Action3<String> {
        override fun run(x: String) {
            println(x)
        }
    })
}

Kotlin 1.4

fun interface Action<T> {
    fun run(x: T)
}

fun<T> runAction(x: T, a: Action<T>) = a.run(x)

fun main() {
		runAction("2333") {
        println(it)
    }
}

混合命名和位置参数是又一项牛逼的改进,之前有一些讨论中,部分人认为它没有意义,但是实际上这是一个让 Kotlin 更适合用来描述科学计算的特性,还是来看个例子:

Kotlin 1.3

fun f(x: Int = 1, y: Int = 1) { ... }

fun main() {
	f(1, y = 2)		// 合法
	f(x = 1, 2)		// 不合法
}

Kotlin 1.4

fun f(x: Int = 1, y: Int = 1) { ... }

fun main() {
	f(1, y = 2)		// 合法
	f(x = 1, 2)		// 合法
}

Kotlin 1.4 还增加了对于函数调用能力的提升,具体的就引用几个官方的代码片段吧,一看就明白的那种:

1)不再需要做带有默认值的重载

Kotlin 1.3

fun foo(i: Int = 0): String = "$i!"

fun apply(func: () -> String): String = func()

fun applyInt(func: (Int) -> String): String = func(0)

fun main() {
    println(apply(::foo))	// 不合法
    println(applyInt(::foo))	// 合法
}

Kotlin 1.4

fun foo(i: Int = 0): String = "$i!"

fun apply(func: () -> String): String = func()

fun main() {
    println(apply(::foo))	// 合法
}

2) 更强的方法引用

Kotlin 1.3

fun foo(f: () -> Unit) { ... }
fun returnsInt(): Int = 42

fun main() {
    foo { returnsInt() } // 合法
    foo(::returnsInt)    // 不合法
}

Kotlin 1.4

fun foo(f: () -> Unit) { ... }
fun returnsInt(): Int = 42

fun main() {
    foo { returnsInt() } // 合法
    foo(::returnsInt)    // 合法
}

3)支持重载 varargs 参数的方法调用

Kotlin 1.4

fun foo(x: Int, vararg y: String) { ... }

fun use0(f: (Int) -> Unit) {}
fun use1(f: (Int, String) -> Unit) {}
fun use2(f: (Int, String, String) -> Unit) {}

fun test() {
    use0(::foo) 
    use1(::foo) 
    use2(::foo) 
}

4) 支持类型引用的智能转换

Kotlin 1.4

sealed class Animal
class Cat : Animal() {
    fun meow() {
        println("meow")
    }
}

class Dog : Animal() {
    fun woof() {
        println("woof")
    }
}

fun perform(animal: Animal) {
    val kFunction: KFunction<*> = when (animal) {
        is Cat -> animal::meow
        is Dog -> animal::woof
    }
    kFunction.call()
}

fun main() {
    perform(Cat())
}

要说 Kotlin 1.4 另一个有用的改进,就是循环内的 when 了,一直以来,在循环场景下,when 内要写 break/continue 的的情况,就必须使用 label,而 label 形如 goto,会给人以流程混乱的印象。Kotlin 1.4 的 when 终于可以自由的在循环里为所欲为了:

Kotlin 1.3

fun test(xs: List<Int>) {
    LOOP@for (x in xs) {
        when (x) {
            2 -> continue@LOOP
            17 -> break@LOOP
            else -> println(x)
        }
    }
}

Kotlin 1.4

fun test(xs: List<Int>) {
    for (x in xs) {
        when (x) {
            2 -> continue
            17 -> break
            else -> println(x)
        }
    }
}

好了,上面讲了那么多语法上的问题,下面才是重头戏,这次 Kotlin 1.4 带来了全新的项目向导,可以建立的项目种类非常之多。

其中最为人瞩目的就是 Web 全栈能力,下面也将详细讲述。

先来打开新的项目向导,来选中 Full-Stack Web Application,在右侧可以看到项目的结构,即这个项目将会由公共部分,后端部分和前端部分组成。

点击下一步之后,会提示为每一个组成部分选择相应的组件,其中公共部分就是 Kotlin/Native 的代码,不能选择。后端部分固定为 Ktor,可以选择要部署到的容器,如 Tomcat、Netty 或 Jetty,默认的选项是 Netty。而前端部分的渲染引擎选择就尤为重要了,从选项可以看出来,Kotlin/JS 目前已经与 React 有了深度的绑定,也就是说我们的前端部分是要用 React 来开发的。

建立完项目后,大致看一下代码结构就能明白它的工作原理了,先从后端代码开始看吧:

server.kt

fun HTML.index() {
    head {
        title("Hello from Ktor!")
    }
    body {
        div {
            +"Hello from Ktor"
        }
        div {
            id = "root"
        }
        script(src = "/static/sample.js") {}
    }
}

fun main() {
    embeddedServer(Netty, port = 8080, host = "127.0.0.1") {
        routing {
            get("/") {
                call.respondHtml(HttpStatusCode.OK, HTML::index)
            }
            static("/static") {
                resources()
            }
        }
    }.start(wait = true)
}

这段代码是一个标准的微服务,其中有一个路由指向了 /,返回经由 HTML.index() 组装的页面,另一个路由指向了静态资源,即 /static 目录。

有这段代码,我们即可以顺利的跑起服务器,并且渲染出一个基本的页面。

那再来看前端代码,前端的入口是 client.kt,其代码也很简单:

fun main() {
    window.onload = {
        render(document.getElementById("root")) {
            child(Welcome::class) {
                attrs {
                    name = "Kotlin/JS"
                }
            }
        }
    }
}

是不是和你们想得不一样,前端依然是 Kotlin 代码,而不是 JavaScript。这段代码的含义为,在 windows.onload 事件里,对页面上的 root 组件(即是后端代码里由 HTML.index() 组装的组件之一)进行渲染,渲染的内容为 Welcome 组件,并传入 name="Kotlin/JS" 作为参数。

Welcome 组件是一个标准的 React 组件,在 Kotlin/JS 内,它的写法是这样的:

external interface WelcomeProps : RProps {
    var name: String
}

data class WelcomeState(val name: String) : RState

@JsExport
class Welcome(props: WelcomeProps) : RComponent<WelcomeProps, WelcomeState>(props) {

    init {
        state = WelcomeState(props.name)
    }

    override fun RBuilder.render() {
        // 具体的渲染方案
    }
}

注意此处的 @JsExport 注解,意为要将这个组件导出为 JS,如果没有这个注解,编译器将不会把组件进行导出,从而引起能编译但是运行起来报 JS 异常的问题。

最后讲一下公共部分代码的问题,很多写惯了 Kotlin/JVM 的同学,会上手就在这里写上很多 Java 里才有的东西,也会有部分人上手就在这里写 dynamic,其实这些东西都不被支持。

理由也很简单,因为这个项目是两端的,一端在 JVM,一端在 JS,如果写了 JVM 的东西,那如何编译到 JS 去呢?反过来也是一样的,说白了就是不同平台所拥有的库不一样,光是有语言和语法,是没有办法跨平台编译的。

那是不是说公共代码就没有意义呢,其实也不是,公共代码提供的是一种更高层次的跨平台抽象,比如说我要实现 MD5 加密,这个算法在 JVM 和 JS 侧的实现是不一样的,所以我们可以这样做:

common.kt

expect fun String.md5(): String

server.kt

actual fun String.md5(): String = hash("MD5")

client.kt

external fun require(module: String): dynamic
val md5Impl = require("md5")
actual fun String.md5(): String = md5Impl(this) as String

在很早的时候,我就说过了在 Kotlin/Native 下,需要用 expect/actual 来指出预期现实,在这里再一次用到了。通过这种方式,就可以抽象出在 JVM/JS 同时可用的函数了。


在我们研究的过程中,有不止一个人问过我,这玩意怎么编译怎么运行啊,总不能永远 gradle run 吧,这样运维会炸的。然而用 gradle build 这种,曾经在 Ktor 项目是都好使的东西,怎么到了这就不好使了呢?

经过一番验证,我证实了这里确实有坑,官方在发布最新 Kotlin/Native 插件时,还是挖坑了。

简单来说,就是 gradle task 内,有一个 distZip,这个 task 会从编译结果内选取它想要的东西然后打成 dist 包,坏就坏在这个文件选取上。

distZip 的规则,被选取的文件必须满足文件名的条件,即:

${project.name}-${project.version}.jar

然而在 Kotlin/Native 编译的过程中,该文件被命名为:

${project.name}-jvm-${project.version}.jar

因此 distZip 就找不到文件了,所以打出来的 dist 包永远都不能运行。

那么要修复这个问题,只需要在 distZip 之前,把文件名改了,让它能找到就行,这种修改一下编译脚本就好了:

jvmJar.doLast {
    copy {
        from("build/libs/${project.name}-jvm-${project.version}.jar")
        into('build/libs/')
        rename{ "${project.name}-${project.version}.jar" }
    }
}

distZip {
    into("${project.name}-${project.version}") {
        from '.'
        include 'lib*.*'
    }
}

现在我们就可以通过 gradle distZip 命令,来得到一个可部署的程序包,不再需要依赖 gradle 了。当然对于某些需求场景,也可以直接使用 gradle installDist 命令来直接部署。


本次 Kotlin 1.4 对跨平台的改进也很不错,终于从要设置一堆的 target 改成了对单一平台的编译,似乎是向 CodeTyphon 靠拢了,而且编译脚本的写法也简单了许多。

例如以下配置:

kotlin {
    def hostOs = System.getProperty("os.name")
    def isMingwX64 = hostOs.startsWith("Windows")
    KotlinNativeTargetWithTests nativeTarget
    if (hostOs == "Mac OS X") nativeTarget = macosX64('native')
    else if (hostOs == "Linux") nativeTarget = linuxX64("native")
    else if (isMingwX64) nativeTarget = mingwX64("native")
    else throw new GradleException("Host OS is not supported in Kotlin/Native.")
    nativeTarget.with {
        binaries {
            executable {
                entryPoint = 'main'
            }
        }
    }
    sourceSets {
        nativeMain {
        }
        nativeTest {
        }
    }
}

这次的更新,使得 Kotlin/Multiplatform 不再需要去寻找对应平台的依赖和相应的 posix,插件会自动加载全部的依赖,比以前方便太多了。

Kotlin/Multiplatform 当前的不足之处在于,编译时对内存的要求实在是高,按默认配置来编译居然会经常出现 Out of Memory ,而且我编译的程序是只有几行代码的 Sample。另外,由于对跨平台机制的重新设计,使得原本跨树莓派,跨 WebAssembly 等能力丢失,而且目前要加载一个第三方的 C/C++ 库依然是一个非常头疼的问题。

针对编译 OOM 的情况,解决方案是给予 gradle 更大的内存,以避免这样的情况发生:

org.gradle.parallel=true
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError

在将内存改大后,可以明显发现编译变快,但是 OOM 的情况依然时有出现,只能说这东西对内存实在太苛刻了。


最后说一下 iOS App 的开发吧,这次更新的项目向导已经可以建立 iOS App 了,但是割裂感很重,主要体现在,没有办法直接引用 shared library,没有办法编辑 storyboard,也没有 xcode 那样的界面预览,在开发完 shared library 后,依然需要去 xcode 进行编译和调试,可以跨平台但是没有 IDE 的支持,实质上造成的反而是开发成本的提高。至少目前我还不会使用 Kotlin/Native 来开发 iOS App,那太蛋疼了。

总的来说,Kotlin 1.4 的更新在很大程度上方便了开发,我指的是 JVM/JS,也让我更加愿意在前端上花费一些力气,比如说再多学一下 React,但是对于跨平台端,原生端,或许还真的不到时候,还有很长的路要走。