Kotlin/Native 跨平台实战从入门到“放弃”

JetBrains 推出的 Kotlin 语言相信 Android 和 Java 开发者都不陌生。
作为一门现代编程语言,不仅支持函数式、Coroutine 等特性,而且拥有良好的 Java 互操作性,将 Java 社区海量的开源项目为我所用。
所以,Google 官方已将其列为 Android 的首选语言,对标 Apple 的 Swift。

而最近推出的 Kotlin/Native 则是基于 LLVM 编译器后端,能脱离 JVM,将 Kotlin 编译为目标平台二进制代码。
相比 React/Native 和 Flutter 这种偏向 UI 库、只能通过消息接口和底层交互的框架,Kotlin/Native 显然在跨平台方面更具有吸引力。

与 native 代码互操作

Kotlin/Native 对 native 代码的支持主要依靠 cinterop 这个工具,它能够分析 C 头文件,并将类型、函数、常量生成到 Kotlin 环境的映射。
而且对于各个 native 平台的内建库,Kotlin/Native 已经默认做了绑定,直接 import 即可:

import platform.posix.strlen
import platform.darwin.ByteVar
import platform.Foundation.NSDate

引用 C 代码

Cinterop 命令行

首先通过一个类似 makefile 的 .def 文件声明编译参数:

headers = png.h
headerFilter = png.h
package = png
staticLibraries = libfoo.a 
libraryPaths = /opt/local/lib /usr/local/opt/curl/lib

然后通过 cinterop 命令行生成绑定:

cinterop -def png.def -compiler-option -I/usr/local/include -o png

Gradle 插件

当然我们也可以直接使用 Gradle 插件:

components.main {
    dependencies {
        cinterop('interop-name') {
            packageName 'org.sample'

            // The default path is src/nativeInterop/cinterop/<interop-name>.def
            defFile project.file("def-file.def")

            compilerOpts 'Options for native stubs compilation'
            linkerOpts 'Options for native stubs'
            extraOpts '-verbose'

            headers project.files('header1.h', 'header2.h')

            includeDirs {
                allHeaders 'path1', 'path2'

                // Additional directories to search headers listed in the 'headerFilter' def-file option.
                // -headerFilterAdditionalSearchPrefix command line option analogue.
                headerFilterOnly 'path1', 'path2'
            }
        }
    }
}

详细的参数解释可参考 Cinterop - Gradle Plugin

指针操作

当我们操作 native 指针的时候。通常涉及到内存分配,这些操作需要放到 memScoped 代码块,然后用 nativeHeap.allocXXX() 分配内存,用完之后还要记得调用 nativeHeap.free() 释放内存:

actual fun MD5(src: String): String? {
    memScoped {
        val charP_src = src.cstr.getPointer(this)
        val targetLen = CC_MD5_DIGEST_LENGTH
        val charP_Result: CArrayPointer<ByteVar> = nativeHeap.allocArray(targetLen)
        CC_MD5(charP_src, strlen(src).toUInt(), charP_Result)
        val hex = byteArrayPointerToHexString(charP_Result, targetLen)
        nativeHeap.free(charP_Result)
        return hex
    }
}

函数回调

staticCFunction(::kotlinFunc) 可以将 Kotlin 的函数转换成 C 函数;

class CUrl(url: String)  {
    private val curl = curl_easy_init()

    init {
        val headerFunc = staticCFunction(::header_callback)
        curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, headerFunc)
    }
}

fun header_callback(buffer: CPointer<ByteVar>?, size: size_t, nitems: size_t, userdata: COpaquePointer?): size_t {
    if (buffer == null) return 0u
    if (userdata != null) {
        val header = buffer.toKString((size * nitems).toInt()).trim()
        val curl = userdata.asStableRef<CUrl>().get()
        curl.header(header)
    }
    return size * nitems
}

而对于函数参数,调用 StableRef.create().asCPointer() 得到 void*

val voidPtr = StableRef.create(this).asCPointer()
curl_easy_setopt(curl, CURLOPT_HEADERDATA, voidPtr)

反过来得到 Kotlin 对象:

val stableRef = voidPtr.asStableRef<KotlinClass>()
val kotlinReference = stableRef.get()

引用 iOS 代码

类型映射

因为 ObjC 是 C 的超集,还是通过 Cinterop 来分析头文件做映射;
先来看类型映射:

通过 Cinterop 引用第三方库

对于第三方库,仍然可以用 Gradle 脚本来配置 cinterop:

interop('AFNetworking') {
    defFile 'src/nativeInterop/cinterop/AFNetworking.def'
    compilerOpts "-F${iOSPath}"
    linkerOpts "-F${iOSPath}"
    includeDirs "${iOSPath}/AFNetworking.framework/Headers"
}

framework('app') {
    libraries {
        artifact 'AFNetworking'
    }
    linkerOpts '-rpath', '@executable_path/Frameworks'
    linkerOpts "-F${iOSPath}"
}

对应的 .def 文件:

language = Objective-C
package = com.afnetworking
headers = AFNetworking.h
headerFilter = **
compilerOpts = -framework AFNetworking
linkerOpts = -framework AFNetworking
linkerOpts = -F /absolute/path/for/AFNetworking.framework

通过 Cocoapods 插件引用第三方库

由于 Kotlin/Native 也提供了 Cocoapods 的 Gradle 插件,所以对于 Cocoapods 项目可以不用 cinterop:

// Apply plugins.
plugins {
    id("org.jetbrains.kotlin.multiplatform") version "1.3.30"
    id("org.jetbrains.kotlin.native.cocoapods") version "1.3.30"
}

// CocoaPods requires the podspec to have a version.
version = "1.0"

kotlin {
    cocoapods {
        // Configure fields required by CocoaPods.
        summary = "Some description for a Kotlin/Native module"
        homepage = "Link to a Kotlin/Native module homepage"
        pod("AFNetworking", "~> 3.2.0")
    }
}

而对于 Swift 库,如果要能被 Kotlin/Native 引用,需要将接口用 @objc 导出。

构建 iOS APP

由于 Kotlin/Native 默认绑定了 iOS 的系统库,所以我们甚至可以完全用 Kotlin 写 iOS APP:

package sample.uikit

import kotlinx.cinterop.*
import platform.Foundation.*
import platform.UIKit.*

fun main(args: Array<String>) {
    memScoped {
        val argc = args.size + 1
        val argv = (arrayOf("konan") + args).map { it.cstr.ptr }.toCValues()
        autoreleasepool {
            UIApplicationMain(argc, argv, null, NSStringFromClass(AppDelegate))
        }
    }
}

class AppDelegate : UIResponder, UIApplicationDelegateProtocol {
    @OverrideInit constructor() : super()

    companion object : UIResponderMeta(), UIApplicationDelegateProtocolMeta {}

    private var _window: UIWindow? = null

    override fun window() = _window

    override fun setWindow(window: UIWindow?) { _window = window }
}

@ExportObjCClass
class ViewController : UIViewController {
    @OverrideInit constructor(coder: NSCoder) : super(coder)

    @ObjCOutlet
    lateinit var label: UILabel

    @ObjCOutlet
    lateinit var textField: UITextField

    @ObjCOutlet
    lateinit var button: UIButton

    @ObjCAction
    fun buttonPressed() {
        label.text = "Konan says: 'Hello, ${textField.text}!'"
    }
}

完整代码可参考:kotlin-native/samples/uikit

平台间代码复用

我们使用 Kotlin/Native 的一个最重要的原因就是期望能多平台代码复用,但是标准库只提供了 IO、网络、序列化等,很多功能都需要分平台单独实现。
Kotlin/Native 支持通过 expect 定义平台无关的接口,然后各个平台通过 actual 关键字去做实现。

commonMain 模块定义接口:

expect object DateTimeUtil {
    fun currentTime(): Double
    fun currentTimeStrFormat(format: String): String
}

androidMain 模块实现:

import java.text.SimpleDateFormat
import java.util.*

actual object DateTimeUtil {
    actual fun currentTime(): Double {
        return System.currentTimeMillis().toDouble()
    }

    actual fun currentTimeStrFormat(format: String): String {
        return SimpleDateFormat(format, Locale.ENGLISH).format(Date())
    }
}

iOSMain 模块实现:

import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.date
import platform.Foundation.timeIntervalSince1970

actual object DateTimeUtil {
    actual fun currentTime(): Double {
        return current().timeIntervalSince1970
    }

    actual fun currentTimeStrFormat(format: String): String {
        val formatter = NSDateFormatter()
        formatter.setDateFormat(format)
        return formatter.stringFromDate(current())
    }

    private fun current(): NSDate {
        return NSDate.date()
    }
}

目前存在的问题

不支持同步锁

Kotlin/Native 从设计上就不支持同步锁(Java 和 iOS 中的 synchronized),认为它容易出错、不可靠:

Kotlin/Native runtime doesn’t encourage a classical thread-oriented concurrency model with mutually exclusive code blocks and conditional variables, as this model is known to be error-prone and unreliable.

他们在 native 平台另起炉灶搞了一套 Worker API,但是这套 API 仅仅支持 native 平台,而且和同步锁机制差异很大,因此很难实现跨平台。
官方的想法是,保证 Runtime 中的对象只能被一个 thread/worker 持有,或者是不可修改的,这样同步锁就没必要存在了:

An important invariant that Kotlin/Native runtime maintains is that the object is either owned by a single thread/worker, or it is immutable (shared XOR mutable).
This ensures that the same data has a single mutator, and so there is no need for locking to exist.

有意思的是,官方说同步锁容易出错/不可靠/不需要,然而不仅第三方库 Ktor 内部有实现官方也有实现,只不过标记为 @InternalAPI 外部无法调用。
虽然我们可以 copy Ktor 或者官方的实现源码,但可能影响稳定性,官方不推荐:

API marked with this annotation is ktor internal and it is not intended to be used outside. It could be modified or removed without any notice.
Using it outside of ktor could cause undefined behaviour and/or any strange effects.
We are strongly recommend to not use such API.

有兴趣的可进一步阅读:

Coroutine 仅支持主线程

官方的 coroutine 库虽然支持多平台,但是你会发现下面这三段代码在 iOS 上均不会执行:

GlobalScope.launch {
    delay(1000L)
}

GlobalScope.launch(Dispatchers.Main) {
    delay(1000L)
}

GlobalScope.launch(Dispatchers.Default) {
    delay(1000L)
}

我们需要调用 iOS 的 GCD 自己实现 CoroutineDispatcher:

actual object CoroutineDispatchers {

    actual val main: CoroutineDispatcher = AsyncCoroutineDispatcher(dispatch_get_main_queue())

    actual val default: CoroutineDispatcher = AsyncCoroutineDispatcher(
        dispatch_queue_create(
            "KotlinDefaultSerialDispatcher",
            dispatch_queue_attr_make_with_qos_class(null, QOS_CLASS_DEFAULT, 0)
        )
    )

    private class AsyncCoroutineDispatcher(queue: dispatch_queue_t) : CoroutineDispatcher() {
        private val queue: dispatch_queue_t = queue
        override fun dispatch(context: CoroutineContext, block: Runnable) {
            dispatch_async(queue) {
                block.run()
            }
        }
    }
}

改完之后,如果我们调用 CoroutineDispatchers.default,仍然会 crash:

因为 Coroutine 在 native 平台暂不支持多线程,只能跑在主线程:

Currently, coroutines are supported only on the main thread.
You cannot have coroutines off the main thread due to the way the library is currently structured.

而且在构建 iOS APP 过程中还出现了编译失败的问题(找不到 CommonKotlinCoroutineContextElement 定义),最后发现是 Kotlin/Native SDK 本身的问题,最新的 1.3.41 才刚修复(由此也可看出 Kotlin/Native 的不成熟)。

有兴趣的可进一步阅读:

其他问题

除了上面的两个比较严重的问题,Kotlin/Native 还有一些其他不足:

  • 不支持 iOS 的 NSMutableString,相关代码只能改为指针操作;
  • Ktor 上传 Multipart 在 iOS 平台存在问题;

更多问题有兴趣的可阅读:The Dos and Dont’s of Mobile Development with Kotlin Multiplatform

总结

Kotlin/Native 目前不支持同步锁、Coroutine 仅支持主线程,这在拥有大型项目中显然是不可接受的,而且本身标准库的支持也有限,因此如果运用于工业级多平台项目需要谨慎。

: 相关 demo