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

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 回归它的本质:一个包装器。

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

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

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

构造函数接收一个 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 框架。第二部分将展示具体做法。

相关推荐
Hello小赵2 小时前
C语言如何自定义链接库——编译与调用
android·java·c语言
IT枫斗者3 小时前
构建具有执行功能的 AI Agent:基于工作记忆的任务规划与元认知监控架构
android·前端·vue.js·spring boot·后端·架构
用户69371750013843 小时前
XChat 为什么选择 Rust 语言开发
android·前端·ios
林栩link3 小时前
【车载 Android】实践跨进程 UI 融合渲染
android
Paxon Zhang4 小时前
MySQL 大师之路**数据库约束,表设计,CRUD**
android·数据库·mysql
Indoraptor4 小时前
SurfaceFinger FrameTimeline 分析
android·源码阅读
zh_xuan4 小时前
Android 待办事项增加事项统计
android
zopple5 小时前
Laravel 10.x新特性全解析
android
鬼先生_sir5 小时前
MySQL进阶-SQL高级语法全解析
android