Android 官方的 twitter 账号最近发布了一条消息:Jetpack 将要支持 KMM 了,目前已经发布了预览版本。首批的预览版本中仅支持了 Collections 和 DataStore 两个组件库,并且在 GitHub 上也开源了全新的项目 kotlin-multiplatform-samples ,来帮助大家更好的理解使用 Jetpack Multiplatform。KMM 由于 Jetpack 的加入,后续的迭代速度应该也会上一个台阶,同时也可能会结束 KMM 三方库百家争鸣的局面。
下面就以 kotlin-multiplatform-samples 新仓库来体验下使用 Jetpack DataStore 来开发 KMM App 的大致流程。
项目分析
下面我就以 kotlin-multiplatform-samples 项目讲解下如何使用 Jetpack Multiplatform 来开发 KMM 项目。示例是一个摇骰子的游戏,可以设置骰子的个数及形状(几面体的骰子),并且可以把上述设置持久化(使用 DataStore)下来。UI 大致如下:
项目整体架构
整个项目的架构大致如下:
注:带「:」表示的是 Android 的模块,其他表示的是文件夹。
项目中整体有三大部分,分别是 :androidApp
、:shared
模块以及 iosApp
Xcode 工程。
:androidApp
是 Android Application 模块,是整个 Android App 的入口,整体采用的是 MVVM 架构,View 使用 Compose 编写;iosApp
是 iOS 的项目工程,可以使用 Xcode 打开编译为 iOS App,整体采用的是 MVVM 架构,View 使用 SwiftUI 编写,使用了 Combine 库;:shared
是 KMM 的共享代码库,统一提供给:androidApp
与iosApp
使用;
下面从数据层至 UI 层的方式看下项目的代码细节:
通用数据层的实现
下面就看一下 shared 模块中通用部分的逻辑。
class DiceSettingsRepository(
private val dataStore: DataStore
) {
private val scope = CoroutineScope(Dispatchers.Default)
// 提供可观察的数据流供 UI 使用
val settings: Flow = dataStore.data.map {
DiceSettings(
it[diceCountKey] ?: DEFAULT_DICE_COUNT,
it[sideCountKey] ?: DEFAULT_SIDES_COUNT,
it[uniqueRollsOnlyKey] ?: DEFAULT_UNIQUE_ROLLS_ONLY,
)
}
// 使用 DataStore 持久化数据
fun saveSettings(
diceCount: Int,
sideCount: Int,
uniqueRollsOnly: Boolean,
) {
scope.launch {
dataStore.edit {
it[diceCountKey] = diceCount
it[sideCountKey] = sideCount
it[uniqueRollsOnlyKey] = uniqueRollsOnly
}
}
}
}
DataStore 的实例化在 Android 和 iOS 有一些差别,所有这里差异化处理,首先是在 commonMain
中定义了一个通用的函数
/**
* 获取一个单例的 DataStore 实例,传入的是一个存储文件的路径
*/
fun getDataStore(producePath: () -> String): DataStore =
synchronized(lock) {
if (::dataStore.isInitialized) {
dataStore
} else {
PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() } )
.also { dataStore = it }
}
}
androidMain
中的提供的 getDataStore
函数定义如下:
/**
* 调用 commonMain 中的方法,传入文件路径,需要调用者传入 Context
*/
fun getDataStore(context: Context): DataStore = getDataStore(
producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath }
)
其中获取文件存储路径是 Android 平台特有的 API,是 iOS 平台不同的。下面就是 iOS 平台封装这部分差异的逻辑。
iosMain
中的提供的 getDataStore
函数定义如下:
/**
* 使用 NSFileManager 构建文件路径,用于 DataStore 内容的存储
*/
fun createDataStore(): DataStore = getDataStore(
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
requireNotNull(documentDirectory).path + "/$dataStoreFileName"
}
)
Android UI 层的实现
Android UI 层的代码入口实现大致如下:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DiceRollerTheme {
// Compose 函数,具体绘制 UI 的逻辑
DiceApp(viewModel = diceViewModel(LocalContext.current))
}
}
}
@Composable
private fun diceViewModel(context: Context) = viewModel {
// 实例化 ViewModel
DiceViewModel(
roller = DiceRoller(),
// 实例化 shared 模块中的 DiceSettingsRepository
settingsRepository = DiceSettingsRepository(getDataStore(context))
)
}
}
保存按钮相关逻辑如下:
@Composable
private fun Settings(
viewModel: DiceViewModel,
settings: DiceSettings,
modifier: Modifier = Modifier,
) {
var diceCount by remember { mutableStateOf(settings.diceCount) }
var sideCount by remember { mutableStateOf(settings.sideCount) }
var uniqueRollsOnly by remember { mutableStateOf(settings.uniqueRollsOnly) }
Column(
//...
) {
// ...
Button(
// 将事件传递给 ViewModel
onClick = { viewModel.saveSettings(diceCount, sideCount, uniqueRollsOnly) } ,
enabled = unsavedNumber || unsavedSides || unsavedUnique,
) {
Text(stringResource(R.string.save_settings))
}
}
}
ViewModel 中保存数据逻辑如下:
class DiceViewModel(
private val roller: DiceRoller,
private val settingsRepository: DiceSettingsRepository,
) : ViewModel() {
// ...
// 提供可观察的数据流供 UI 使用
val settings: StateFlow = settingsRepository
.settings
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000L),
null
)
// 调用 Repository 中存储数据的逻辑
fun saveSettings(
number: Int,
sides: Int,
unique: Boolean,
) = settingsRepository.saveSettings(number, sides, unique)
}
iOS UI 层的实现
iOS UI 层的代码入口实现大致如下:
@main
struct iOSApp : App {
var body: some Scene {
WindowGroup {
ContentView ()
}
}
}
保存按钮的相关逻辑:
struct SettingsView: View {
@EnvironmentObject var viewModel: SettingsViewModel
var body: some View {
VStack {
Form {
Section {
Stepper("settings_dice_count_label (viewModel.diceCount)", value: $viewModel.diceCount, in: 1...10)
Stepper("settings_side_count_label (viewModel.sideCount)", value: $viewModel.sideCount, in: 3...100)
Toggle("settings_unique_numbers_label", isOn: $viewModel.uniqueRollsOnly)
}
Section {
Button("settings_save_button", action: {
// 将保存事件传递给 ViewModel
viewModel.saveSettings()
}).disabled(!viewModel.isSettingsModified)
}
}
}
}
}
ViewModel 中的保存数据的相关逻辑如下:
@MainActor
final class SettingsViewModel: ObservableObject {
private let repository = DiceSettingsRepository(dataStore: CreateDataStoreKt.createDataStore())
private var roller = DiceRoller()
// 将 repository 中的 setting 数据流转换成单一的属性供 UI 使用
func startObservingSettings() async {
do {
let stream = asyncStream(for: repository.settingsNative)
for try await settings in stream {
self.diceCount = Int(settings.diceCount)
self.sideCount = Int(settings.sideCount)
self.uniqueRollsOnly = settings.uniqueRollsOnly
self.rollButtonLabel = String.localizedStringWithFormat(NSLocalizedString("game_roll_button", comment: ""), settings.diceCount, settings.sideCount)
self.currentSettings = settings
}
} catch {
print("Failed with error: (error)")
}
}
// 调用 Repository 保存数据
func saveSettings() {
repository.saveSettings(diceCount: Int32(diceCount), sideCount: Int32(sideCount), uniqueRollsOnly: uniqueRollsOnly)
}
}
Kotlin Multiplatform
上面介绍了使用 Jetpack DataStore 来开发 KMM App 的关键流程,除了 DataStore 之外,这次一起发布的还有 Collections 组件。
Jetpack for multiplatform
目前 Jetpack Multiplatform 仅仅支持了 Collections 和 DataStore 两个组件:
Collections :Collections 是一个用 Java 编程语言编写的库示例,它没有特定于 Android 的依赖项,但实现了 Java 集合 API。
DataStore:完全用 Kotlin 编写,它在 API 定义和实现中都使用协程。
而且 Jetpack Multiplatform 还处于早期的预览阶段,不建议在线上版本使用。其实这两个组件并不是什么全新的库,而是基于现在的 Android Jetpack 版本之上进行迭代开发的,源码也在 androidx 仓库中。
两个仓库的二进制结构大致如下:
Collections
DataStore
Collections 是全平台都已实现,DataStore 虽然也已经支持平台,但是并没有找到对应的源码信息,可以在 Google Maven 仓库查看这部分的支持情况。下面就以 Collections 库做一个简单的讲解。
Jetpack Multiplatform(JMP) 本身还是基于 Kotlin Multiplatform(KMP) 的开发规范来实现的。想要了解 JMP 的一些底层实现,就需要先了解 KMP 的一些基本概念。
Kotlin 多平台实现步骤
多平台绕不过去的一个点就是:如何使用同一的 API 来提供多个平台的具体逻辑实现。KMP 的定义也是相对简单,使用 expect
、actual
两个关键字就能搞定,也是比较好理解:
expect
:期望的意思,也就是接口定义的部分,可以修饰类与函数;actual
:实际的意思,也就是在各个平台上对expect
的具体实现,可以修饰类与函数;
在 Android 与 iOS 上的定义大致如下:
Kotlin 多平台实现示例
我们以一个具体例子来详细讲解下,比如我们要实现是个多平台的 UUID 方法。那么首先 common 层的定义如下:
// Common
expect fun randomUUID(): String
Android 侧的实现如下:
// Android
import java.util.*
actual fun randomUUID() = UUID.randomUUID().toString()
iOS 侧实现如下:
// iOS
import platform.Foundation.NSUUID
actual fun randomUUID(): String = NSUUID().UUIDString()
整体架构图如下:
工程结构如下:
其中 commonMain
是接口定义的部分,androidMain
是 Android 侧的具体实现,iosMain
是 iOS 侧的具体实现。
其实androidMain
、iosMain
除了写 actual
的具体实现外,也可以写单端的特有业务逻辑。比如在 iosMain
中可以定义普通的类及函数,这里定义的内容在 Android App 中就无法访问,反之亦然。所以通用的业务逻辑还是需要定义在 commonMain
目录中。
除了 Android 和 iOS 平台之间共享代码之外,其他平台也是通过相同的方式进行代码共享。
更多的匹配规则可以到官网就行查看。
KMM 与 Flutter 的对比
如果使用 Flutter 实现上述摇骰子的游戏的话,那么大致的核心类以及架构如下图(右侧)
Flutter 整体实现思路大致如下:
- UI 层中使用 Flutter 方式实现 Android 与 iOS 双端的 UI 绘制;
- Data Layer 中的 Repository 也是使用 Dart 来进行编写,也是双端只实现一份;
- Data Layer 中的 DataSource 是双平台特有的 API,需要使用 platform-channels 来实现,首先需要在 plugin 模块中定义对应的方法、传参及返回值,然后在双端各自实现对应的协议。这部分采用的接口约定的方式,编译器并不能检查是否实现以及实现是否正确。当然,这部分仍然是可以使用一些三方库来解决。
从上述逻辑来看,单纯从共享代码的占比来看,Flutter 整体上是优于 KMM 的。
除了复用程度之外,两者在实现平台特有 API 上也是有差异的。
KMM :是基于 Kotlin 编译器将对应的代码编译为目标平台的字节码,这种方式性能损耗较少;
Flutter:是通过 Channel (IPC)的方式进行通信,这种方式会有一定的性能损耗;
从语言层面来看,KMM 使用的是 Kotlin 语言,Flutter 使用的是 Dart 语言。虽然说各自语言有各自的优势,但是 Dart 整体上看是介于 Java 和 Kotlin 之间的一门语言,它虽然解决了 Java 语言当中的一些冗余语法,提供了一些现代语言的设计(可空性、扩展等),但是在整体设计上还是达不到 Kotlin 这门语言的水平。Dart 这门语言借助于 Flutter 起死回生,同样它也帮助 Flutter 能够快速实现自己的想法,在目前整个时间点来看是一种双赢的结果。如果在站在一个更大的时间尺度上看(其他跨平台技术发展的好的话),Dart 对 Flutter 而言可能更像是“成也萧何败萧何”的情况。虽然前期 Flutter 借着声明式 UI 编程方式快速崛起,但是等到 Compose、SwiftUI 这些后来者追上的时候,Dart 语言可能就会成为一种劣势。从 TIOBE 的编程语言排行榜中也能窥见一二。
除了平台之外,一些基建(三方库)配套是否齐全也是平台是否能够持续发展的重要原因。目前 Dart/Flutter 相关的三方库可以在 pub.dev 上进行查看,想要使用的一些功能基本上都能找到对应三反库。KMM 这部分则是没有官方的 hub 仓库来汇总所有的 SDK,不过在 kmm-awesome 这个仓库已经统计了一些 SDK。个人感觉,目前来看两者的社区状态是差不多的。
总结
我们从 Jetpack 支持多平台引出 KMP 的基本开发流程:
将通用的业务逻辑写在
commonMain
目录中,各个平台特有的内容写在自己平台中,如androidMain
、iosMain
等;涉及到平台差异的部分,可以在
commonMain
中定义expect
修饰的类或函数,然后分别在各自平台的目录中进行实现并添加actual
修饰;
针对 KMM 开发,Android 也给出了一个使用 Jetpack Multiplatform 组件 DataStore 进行持久化的示例。整体架构如下:
下面讲一下我对 Jetpack 支持 Kotlin Multiplatform 的一点理解,个人观点,欢迎讨论。自从 2017 年 Android 宣布 Kotlin First 以来,Kotlin 语言本身、Jetpack 中的 ktx 库以及 Compose 等都取得了一些不错的反响。反观 JetBarins 的 Kotlin Multiplatform Mobile 现在才刚刚发布第一个 Beta 版本,相比之下节奏确实有点慢。
Android 想要做这件事情,思路也是比较简单,把自己成功的经验复制一下就可以了。把自己当时怎么在 Android 上“扶持” Kotlin 的,现在就怎么“扶持” Kotlin Multiplatform Mobile。除了这套成功方法论之外,也是基于目前 KMM 的现状来决定的,现在的 KMM 只是一个基础的通信平台,至于在这个平台上怎么通信,并没有好的规范及解决方案,所以也导致社区中对这块儿也是处于一个“百家争鸣”的阶段。这样就导致只有一些相对的激进的开发者才有兴趣去尝试 KMM 技术,发展自然也就慢了下来。
Android 想要解决这个问题就比较简单了,那就是制定一套规范并且提供一些开箱即用的 SDK,尽可能降低开发者使用 KMM 的门槛。那这套规范目前虽然没有,但是 Android 可以抄自己的作业呀,Android 上就有一套现成的开发规范,那就是 Jetpack 组件。那让 Jetpack 组件支持 KMM 也是顺理成章的事情了。
关于 KMP 的更多内容
Kotlin 官方文档
Announcing an Experimental Preview of Jetpack Multiplatform Libraries
Compose for Multiplatform - 王鹏
《Kotlin 移动端跨平台技术的当下及未来》乔禹昂
Getting started with Kotlin Multiplatform Mobile | KMM Beta
github.com/terrakok/km…
作者:RethinkAndroid
链接:https://juejin.cn/post/7158463807126241287