使用 Kotlin Multiplatform 编写 iOS/Android 应用这一年,做一个小总结

2,923 阅读6分钟

自从上一篇文章发布已经过去小半年了,这半年我还是一直在使用上篇文章所提及的方法,用 Compose(Molecule) 编写跨平台的业务逻辑,使用 Compose UI 和 SwiftUI 来完成各个平台的 UI 实现。这u但时间还是有一些可以作为经验总结下来的东西,在这里稍做记录,希望对你也有帮助。 如果你对具体的项目代码感兴趣的话:github.com/DimensionDe…

背景

先简单说一下技术背景,项目的架构是 MVI 架构,使用 Kotlin Multiplatform 来完成业务逻辑的编写,然后下发 UI 状态到各个平台,各个平台只需要根据这个 UI 状态进行渲染就可以,大多数情况平台不会再有独自的业务逻辑。
这样做法的好处就是,解决了同一个业务逻辑要两端各写一遍的问题,并且能够在两端保持业务逻辑一致的同时,能享受到原生 UI 的性能体验,而且因为 Kotlin Multiplatform 是编译到静态库/动态库的,不存在类似 Javascript 那样的运行时,在性能上也会好不少。
而没有选择使用 Compose Multiplatform 作为跨平台 UI 的原因在于:之前确实有尝试过,并且编写了一个 Navigation 库,因为试过了所以这一次想尝试一下用原生 UI。
用图表表示项目结构的话的话,大体是这样子的:

classDiagram
Shared <|-- iOS
Shared <|-- Android
Shared : Networking
Shared : Database
Shared : Paging
Shared : Molecule Presenter
class iOS{
Skie
SwiftUI
}
class Android{
Jetpack Compose
}

在 iOS 上还会使用 SKIE 作为 iOS 平台代码生成的一个中间件,给 Kotlin Multiplatform 生成出来的 iOS 库加了不少糖,比如 Flow 的调用更为简单,sealed class/sealed interface 可以直接用 Swift 的 switch 来使用等等一系列的类似“语法糖”的内容。

接下来就来记录一下在写的过程中遇到的一些问题。
先说一下我不是专业的 iOS 程序员,所以当遇到 iOS 相关的问题的时候还是会很抓瞎的。

泛型 interface

在之前的文章中也有提及,在 Kotlin 中的泛型 interface 经过编译之后在 Swift 中泛型会被擦除,但是 class 的没有问题,所以很多涉及到输出泛型 interface 给 Swift 的地方都需要做 workaround,目前有两种办法:1. 写 Wrapper 加一层封装,2. 如果是自己定义的类型,可以改成 class。
项目中也有原本是 sealed interface 的类型因为这个问题都被换成了 sealed class。

Pgaing 3 在 SwiftUI 中的调用

得益于业务逻辑使用的是 Molecule Presenter,项目是可以直接使用 Paging-Compose 来给 UI 层提供 Paging 状态。在 Jetpack Compose 中,Paging 3 的LazyPagingItems的使用我想很多人都已经都比较熟悉了,而在 SwiftUI 中还没有任何的文档。结合 SwiftUI 的特性,我们可以这样使用:

List {
    ForEach(0..<state.listState.itemCount, id: \.self) { index in
        // 在这里我们使用 peek 来避免 paging 过早加载,因为 ForEach 会直接展开所有项目
        let item = state.listState.peek(index)
        Text(item).onAppear {
            // 在这里需要使用一次 get,来通知 paging 这个项目已经被显示
            _ = state.listState.get(index)
        }
    }
}

不过目前官方 Jetpack 的 Paging-Compose 还只有 Android 的版本,所以项目中就只能直接源码引入了。希望官方能够在日后添加至少 iOS 版本的支持,因为毕竟 Paging 3 已经支持了,Compose 也已经有了 iOS 版本。

跨语言调试

因为 XCode 不太可能官方支持调试 KMP 项目中的 Kotlin 代码,所以想要在 XCode 里面跨语言调试还是比较难的。不过社区里面还是提供了插件:github.com/touchlab/xc… ,这样就可以在 XCode 里面直接调试 Kotlin 代码。
还有另一个选择就是使用 Jetbrains Fleet,这个也是我目前需要编写 Swift 代码并进行调试的时候使用的 IDE,好处就在于:

  • 你可以直接编写 Kotlin 代码,不需要切换到 Android Studio/Intellij,因为 Fleet 的后端其实是和 Intellij 一样的,写起来的体验也是完全一样。
  • 支持从 Swift 代码直接直接跳转到 Kotlin 代码,找符号这方面还是比较方便的。但是如果是 SKIE 生成的代码的话,有可能是没法跳转的。
  • 可以直接写 Swift,虽然体验上来说目前还是比不上 XCode,比如在代码提示的速度上。

不过目前我还没法将 Fleet 作为主力 IDE 使用,Fleet 目前还不支持插件,也就没法使用 Github Copilot,虽然 Jetbrains AI 也不是不能用,但是目前的效果确实是还有待提高。

kotlin.native.cacheKind=none

这是一个困扰了我一个多月的问题。
一开始项目在结合 SKIE 使用 Flow 的时候,并没有出现问题。然而随着项目发展以及各种依赖库版本的更新,在某一个时间节点之后,在 Molecule Presenter 中使用LazyPagingItems并 collect 会导致其中有一个内部的 Flow 会在 iOS 上报空指针然后崩溃,而 Android 是没有这个问题的。我一开始以为是 SKIE 或者 Kotlin Coroutines 的问题,但是并没有在官方或者其他地方有人提及类似的问题,而且在 Kotlin 升级到 2.0 之后这个问题仍然存在,接着我试着从 Paging 3 源码中寻找问题,而从源码中来看这个地方是不可能有空指针的情况的。然后某天我无意中看到网上(来源有有些不清了,可能是 Slack 或者 Youtrack)有人提到添加这么一行代码可以解决另一个问题: kotlin.native.cacheKind=none,于是我也试了一下,然后问题就解决了,虽然这会增加编译时间。
真的就是有时候困扰了你很久的问题其实有个很简单的解。

结语

就我的开发体验而言,Kotlin Multiplatform 的开发体验还是非常好的,在我看来解决了这些痛点:

  • 双端逻辑一致,一套业务逻辑不需要重复写两次,也就不存在以往需要花费人力和时间来解决双端不一致的问题
  • 可以使用原生 UI,虽然文章只提到了 SwiftUI 和 Jetpack Compose,但是还是可以使用 UIKit 和 Android View 来编写 UI 层,相比于其他跨平台方案还是会有一定的优势的。
  • 与 Swift 的交互比较方便。相较于其他跨平台方案,能够简单直接的使用 Swift 与 Kotlin 进行交互这一点还是非常舒服的,类似的解决方案也有,比如 Rust+UniFFI,而无论 JavaScript 还是 Dart 都无法像这样直接交互。

所以我还是非常推荐可以尝试一下 Kotlin Multiplatform。并且未来还可以更进一步将代码扩展到 WASM 上,到时候不仅仅是 Android 和 iOS,甚至可以给网页共享业务逻辑。