适用于 KMP 的纯 Kotlin UIModel 模式 ------ 在 SwiftUI、Kobweb 和 Compose 上使用相同的类。无需 AndroidX。
在不同的 KMP 项目中,我一直遇到同样的问题(我想你也是):我在 ViewModel 中编写 UI 逻辑,但当需要复用它时 ------ 无论是在 iOS、网页端,还是仅仅在测试中 ------ androidx.lifecycle 的导入就会成为阻碍。SwiftUI 并不关心 ViewModel,Kobweb 不需要它,而测试最终也总是在与 Dispatchers.setMain() 作斗争。
但逻辑本身------那些 Flow、状态以及命令处理------全都是纯 Kotlin 代码。包裹在外的 ViewModel 类才是 Android 特有的部分。所以在某个时刻,我直接把逻辑提取了出来,让 ViewModel 回归它的本质:一个包装器。
kotlin
class MusicDiscoveryViewModel(
musicDiscoveryUIModel: MusicDiscoveryUIModel,
) : ViewModel(musicDiscoveryUIModel.scope), UIModel<MusicDiscoveryUIState, MusicDiscoveryCommand> by musicDiscoveryUIModel
这就是整个 ViewModel 的全部内容。它什么都不做。ViewModel(musicDiscoveryUIModel.scope) 将作用域(scope)交给 AndroidX,以便在 onCleared() 时被取消(由于 Lifecycle 2.8.0+ 将作用域存储为 Closeable,这种方式是可行的)。而 by 关键字则负责委派其他所有事务。
所有实际的工作都运行在 MusicDiscoveryUIModel 中,这是一个没有任何 Android 依赖的纯 Kotlin 类。它在每个平台上运行的效果完全一致。你可以在这个配套项目中查看完整的工程。
The UIModel Interface
一切都始于一个简单的接口,我将其用于我的 UDF(单向数据流)ViewModel,并决定在其中加入作用域(scope):
kotlin
interface UIModel<UIState, UICommand> {
val scope: CoroutineScope
val uiState: StateFlow<UIState>
fun sendCommand(command: UICommand)
}
uiState是一个包含屏幕状态的StateFlow------ UI 收集并渲染它。sendCommand是用户操作以类型化命令(typed commands)形式输入的地方。scope驱动协程运行,并被传递给 Android 端的ViewModel()。
命令输入,状态输出,这与 MVI 非常相似。然而,这个接口并不会规定你如何实现状态------无论是通过 combine、状态机,还是单个 MutableStateFlow。该接口只关心是否存在一个代表唯一事实来源(truth)的 StateFlow。
State and Commands
让我们从一个例子开始:接口上的 UIState 和 Commands,它们都位于 commonMain 中,是纯 Kotlin 代码:
kotlin
data class MusicDiscoveryUIState(
val genres: ImmutableList<Genre>,
val selectedGenre: Genre?,
val artists: ImmutableList<Artist>,
val selectedArtist: Artist?,
val albums: ImmutableList<Album>,
val selectedAlbum: Album?,
val tracks: ImmutableList<Track>,
) {
companion object {
val Default = MusicDiscoveryUIState(
genres = persistentListOf(),
selectedGenre = null,
artists = persistentListOf(),
selectedArtist = null,
albums = persistentListOf(),
selectedAlbum = null,
tracks = persistentListOf(),
)
}
}
sealed interface MusicDiscoveryCommand {
data class SelectGenre(val genre: Genre) : MusicDiscoveryCommand
data class SelectArtist(val artist: Artist) : MusicDiscoveryCommand
data class SelectAlbum(val album: Album) : MusicDiscoveryCommand
}
Default 伴生对象是初始状态 ------ 即 stateIn 在加载任何数据之前发射的状态。密封接口(sealed interface)使 sendCommand 中的 when 表达式具备完备性,从而让编译器能捕获到任何缺失的情况。
使用 Flow 串联 UDF
现在,让我们在 UIModel 接口的一个具体实现中使用我们的 UIState 和 Commands。在这个例子中:在我们虚构的音乐探索界面上,用户选择一个流派,获取艺术家信息;选择一位艺术家,获取专辑信息;选择一张专辑,获取曲目。每一次选择都会清除其下层的所有内容。
这种级联依赖关系非常常见 ------ 比如过滤链、主从视图界面或联动下拉框。同时,这些逻辑应当是"响应式"的,这样一旦数据源发生任何变化,我们无需额外操作就能自动获取最新数据。
构造函数接收一个 CoroutineScope 和四个用例(use cases)。全程不涉及任何 Android 依赖。
kotlin
class MusicDiscoveryUIModel(
override val scope: CoroutineScope,
getGenres: GetGenresUseCase,
getArtistsForGenre: GetArtistsForGenreUseCase,
getAlbumsForArtist: GetAlbumsForArtistUseCase,
getTracksForAlbum: GetTracksForAlbumUseCase,
) : UIModel<MusicDiscoveryUIState, MusicDiscoveryCommand> {
private val genres = getGenres()
private val selectedGenre = MutableStateFlow<Genre?>(null)
private val selectedArtist = MutableStateFlow<Artist?>(null)
private val selectedAlbum = MutableStateFlow<Album?>(null) private val artists = selectedGenre.flatMapLatest { genre ->
if (genre != null) getArtistsForGenre(genre.id)
else flowOf(persistentListOf())
}
private val albums = selectedArtist.flatMapLatest { artist ->
if (artist != null) getAlbumsForArtist(artist.id)
else flowOf(persistentListOf())
}
private val tracks = selectedAlbum.flatMapLatest { album ->
if (album != null) getTracksForAlbum(album.id)
else flowOf(persistentListOf())
} override val uiState: StateFlow<MusicDiscoveryUIState> = combine(
genres,
selectedGenre,
artists,
selectedArtist,
albums,
selectedAlbum,
tracks,
::MusicDiscoveryUIState,
).stateIn(scope, SharingStarted.WhileSubscribed(5_000), MusicDiscoveryUIState.Default)
三个 MutableStateFlow 用于保存用户当前的选项。三个 flatMapLatest 块会对这些选项做出响应并获取下一级数据。当用户选择一个新流派时,flatMapLatest 会取消之前的艺术家获取操作并启动一个新的操作。旧数据会自动消失。
combine 合并了所有七个流,并通过 ::MusicDiscoveryUIState 对它们进行映射 ------ 这里可以使用数据类的构造函数引用,因为参数顺序是匹配的。stateIn 则将其转换为一个 StateFlow。
sendCommand 函数负责处理级联重置:
kotlin
override fun sendCommand(command: MusicDiscoveryCommand) {
when (command) {
is MusicDiscoveryCommand.SelectGenre -> {
selectedGenre.value = command.genre
selectedArtist.value = null
selectedAlbum.value = null
}
is MusicDiscoveryCommand.SelectArtist -> {
selectedArtist.value = command.artist
selectedAlbum.value = null
}
is MusicDiscoveryCommand.SelectAlbum -> {
selectedAlbum.value = command.album
}
}
}
当用户选择一个新流派时,选中的艺术家和专辑会被置空。这些空值会通过下游的 flatMapLatest 链条进行传播 ------ 专辑和曲目列表会自动清空,无需手动清理。
当接收到命令时,我们只需修改其中一个变量。得益于我们的响应式属性,最终会自动获得更新后的结果
这个类是纯 Kotlin 编写的。没有 ViewModel,没有 Android。它可以在不作任何修改的情况下,为每个 KMP 目标 平台进行编译。
作用域(Scope)是唯一的变量
这就是 KMP 的优势显现之处。
在 Android 和 Jetpack Compose UI 上,文中所提到的那行三行代码的 ViewModel 提供了作用域(scope)并挂载到 Jetpack 生命周期中。这非常有用------它能跨越配置变更(如旋屏)持续存在。而且,这是唯一 会出现 ViewModel 的平台。
在 Kobweb (Compose for Web)上,没有 ViewModel 的概念。浏览器标签页就是其生命周期。你直接使用 UIModel 即可。
在 SwiftUI 上,思路相同,只是多了一个薄薄的 @Observable 包装层。SKIE 工具会将 Kotlin 的 StateFlow 转换为 Swift 的 AsyncSequence,因此你可以直接对其进行 for await 操作。
通过这种方式,我们实现了真正的代码复用:MusicDiscoveryUIModel 在所有三个平台上都是同一个类。相同的状态、相同的命令、相同的 Flow。
唯一改变的是每个平台所需的 CoroutineScope 以及各平台订阅 uiState 的方式。
在 Android 上,由于我们要将其与 ViewModel 关联,应当使用带有 Main.Immediate 调度器的 CoroutineContext。你可以查看项目,了解根据每个使用 Compose UI 的平台如何定义 Scope。
在 Kobweb 上,依赖注入(DI)会注入一个带有 SupervisorJob 的作用域。在 iOS 上,通过 KoinHelper 获取 UIModel,其余部分则由 Swift 的结构化并发处理。
逻辑部分无需 expect/actual。无需共享 ViewModel 库。一个位于 commonMain 中的类,运行在任何地方。
总结
我推荐这种方法的原因在于:同一个类可以运行在 Android、iOS、JS 和 JVM 上。无需抽象层,也无需多平台 ViewModel 库。AndroidX 完全被排除在逻辑层之外。
测试也变得更加简洁。MusicDiscoveryUIModel 在构造函数中接收一个 CoroutineScope,因此在测试时,你只需传入 TestScope().backgroundScope 并直接检查状态即可。无需 Dispatchers.setMain(),也无需任何框架配置。
ViewModel 负责生命周期,UIModel 负责逻辑。当你两者都需要时,Kotlin 委托只需三行代码就能将它们连接起来。而当你不需要时------比如在 Web、iOS、桌面端或测试中------直接跳过 ViewModel 即可。
你的 ViewModel 是一个生命周期容器,而非逻辑容器。在 KMP 中,它是一个可选的生命周期容器。
由于 UIModel 是纯 Kotlin 编写的,测试它不需要 Dispatchers.setMain(),不需要 Robolectric,也不需要 Mock 框架。第二部分将展示具体做法。