彻底告别 AndroidX 依赖:如何在 KMP 中构建 100% 复用的 UI 逻辑层?

69 阅读6分钟

medium.com/@felix.lf/y…

适用于 KMP 的纯 Kotlin UIModel 模式 —— 在 SwiftUI、Kobweb 和 Compose 上使用相同的类。无需 AndroidX。

在不同的 KMP 项目中,我一直遇到同样的问题(我想你也是):我在 ViewModel 中编写 UI 逻辑,但当需要复用它时 —— 无论是在 iOS、网页端,还是仅仅在测试中 —— androidx.lifecycle 的导入就会成为阻碍。SwiftUI 并不关心 ViewModel,Kobweb 不需要它,而测试最终也总是在与 Dispatchers.setMain() 作斗争。

但逻辑本身——那些 Flow、状态以及命令处理——全都是纯 Kotlin 代码。包裹在外的 ViewModel 类才是 Android 特有的部分。所以在某个时刻,我直接把逻辑提取了出来,让 ViewModel 回归它的本质:一个包装器。

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):

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

让我们从一个例子开始:接口上的 UIStateCommands,它们都位于 commonMain 中,是纯 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 接口的一个具体实现中使用我们的 UIStateCommands。在这个例子中:在我们虚构的音乐探索界面上,用户选择一个流派,获取艺术家信息;选择一位艺术家,获取专辑信息;选择一张专辑,获取曲目。每一次选择都会清除其下层的所有内容。

这种级联依赖关系非常常见 —— 比如过滤链、主从视图界面或联动下拉框。同时,这些逻辑应当是“响应式”的,这样一旦数据源发生任何变化,我们无需额外操作就能自动获取最新数据。

构造函数接收一个 CoroutineScope 和四个用例(use cases)。全程不涉及任何 Android 依赖。

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 函数负责处理级联重置:

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 框架。第二部分将展示具体做法。