← 返回学习路线

第 6 章:状态管理与 ViewModel

用 Flow + StateFlow 构建响应式 UI 状态,在 KMP 中共享 ViewModel,处理 iOS 端的 Flow 消费问题

⚡ Flow 在 KMP 中的使用

Kotlin Flow 是 KMP 中实现响应式编程的核心工具。在 commonMain 中编写的 Flow 代码可以在所有平台上运行,iOS 端通过特殊方式消费。

6.1 Flow 类型对比

类型特点典型用途
Flow 冷流,订阅时才执行;可有多个消费者,各自独立 数据库查询结果、网络请求序列
StateFlow 热流,保存最新值;新订阅者立即收到当前值 UI 状态、屏幕状态
SharedFlow 热流,不保存值(或可配置 replay);多播 事件(Toast、导航)

6.2 常用 Flow 操作符

commonMain import kotlinx.coroutines.flow.* // map:转换每个元素 repository.getArticles() .map { articles -> articles.sortedByDescending { it.publishedAt } } // filter:过滤 .filter { it.isNotEmpty() } // combine:合并多个 Flow combine( repository.getArticles(), searchQueryFlow ) { articles, query -> if (query.isBlank()) articles else articles.filter { it.title.contains(query, ignoreCase = true) } } // stateIn:Flow 转 StateFlow .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), // 5s 后无订阅者自动停止 initialValue = emptyList() ) // catch:异常处理 .catch { e -> emit(emptyList()) } // debounce:防抖(搜索框) searchQueryFlow.debounce(300) // distinctUntilChanged:避免重复值 .distinctUntilChanged() // flatMapLatest:取消旧请求,只处理最新 searchQueryFlow .debounce(300) .flatMapLatest { query -> repository.search(query) }

🔄 StateFlow / SharedFlow

6.3 StateFlow — UI 状态载体

commonMain // 私有可变 StateFlow,公开只读版本 private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() // 更新状态:用 copy() 保持不可变性 _uiState.update { current -> current.copy(isLoading = true) }

6.4 SharedFlow — 单次事件

commonMain — 导航事件 // SharedFlow 用于不需要重放的事件(如导航、Toast) private val _events = MutableSharedFlow<UiEvent>( replay = 0, // 不重放 extraBufferCapacity = 1 // 缓冲 1 个事件(防丢失) ) val events = _events.asSharedFlow() sealed class UiEvent { data class NavigateTo(val route: String) : UiEvent() data class ShowToast(val message: String) : UiEvent() object Logout : UiEvent() } // ViewModel 中发出事件 fun onArticleClick(id: String) { viewModelScope.launch { _events.emit(UiEvent.NavigateTo("article/$id")) } }

📦 共享 ViewModel

6.5 使用 lifecycle-viewmodel

lifecycle 2.8.0 开始,Android 的 ViewModel 类已支持 KMP,可以在 commonMain 中直接使用。

libs.versions.toml lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version = "2.8.7" }
commonMain/build.gradle.kts commonMain.dependencies { implementation(libs.lifecycle.viewmodel) }
commonMain — 共享 ViewModel import androidx.lifecycle.* import kotlinx.coroutines.flow.* class HomeViewModel(private val getArticles: GetArticlesUseCase) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() init { loadArticles() } private fun loadArticles() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } getArticles() .catch { e -> _uiState.update { it.copy( isLoading = false, error = e.message ?: "加载失败" )} } .collect { articles -> _uiState.update { it.copy( isLoading = false, articles = articles, error = null )} } } } fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } try { // refreshArticles 是 suspend 函数 _uiState.update { it.copy(isRefreshing = false) } } catch (e: Exception) { _uiState.update { it.copy(isRefreshing = false, error = e.message) } } } } fun clearError() = _uiState.update { it.copy(error = null) } }

🎭 UI 状态机设计

6.6 推荐:单一状态类(Single State)

commonMain // 单一状态类:所有 UI 状态集中在一个 data class data class HomeUiState( val articles: List<Article> = emptyList(), val isLoading: Boolean = false, val isRefreshing: Boolean = false, val error: String? = null, val searchQuery: String = "" ) // 便利属性 val HomeUiState.isEmpty: Boolean get() = articles.isEmpty() && !isLoading val HomeUiState.showEmptyState: Boolean get() = isEmpty && error == null

6.7 另一种方案:sealed class 状态

commonMain // sealed class:加载/成功/错误 三种明确状态 sealed class ArticleListState { object Loading : ArticleListState() data class Success(val articles: List<Article>) : ArticleListState() data class Error(val message: String) : ArticleListState() } // UI 消费:when 必须处理所有分支 when (val state = uiState) { is ArticleListState.Loading -> LoadingSpinner() is ArticleListState.Success -> ArticleList(state.articles) is ArticleListState.Error -> ErrorView(state.message) }

🛡️ 错误处理:Result 类型

6.8 使用 Result 包装异步操作

commonMain // 通用的挂起函数包装:捕获异常,返回 Result suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> = try { Result.success(block()) } catch (e: ApiException.Unauthorized) { Result.failure(Exception("请重新登录")) } catch (e: ApiException.NetworkError) { Result.failure(Exception("网络连接失败,请检查网络")) } catch (e: Exception) { Result.failure(e) } // ViewModel 中使用 fun refresh() { viewModelScope.launch { val result = safeApiCall { repository.refreshArticles() } result .onSuccess { _uiState.update { it.copy(error = null) } } .onFailure { e -> _uiState.update { it.copy(error = e.message) } } } }

📰 完整示例:新闻列表功能

commonMain — 完整 HomeViewModel class HomeViewModel( private val newsRepository: NewsRepository ) : ViewModel() { private val _searchQuery = MutableStateFlow("") private val _isRefreshing = MutableStateFlow(false) private val _error = MutableStateFlow<String?>(null) // 合并多个 Flow 为单一 UI 状态 val uiState: StateFlow<HomeUiState> = combine( newsRepository.getArticles(), _searchQuery, _isRefreshing, _error ) { articles, query, isRefreshing, error -> val filtered = if (query.isBlank()) articles else articles.filter { it.title.contains(query, ignoreCase = true) } HomeUiState( articles = filtered, isRefreshing = isRefreshing, searchQuery = query, error = error ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = HomeUiState(isLoading = true) ) init { refresh() } fun refresh() { viewModelScope.launch { _isRefreshing.value = true _error.value = null try { newsRepository.refreshArticles() } catch (e: Exception) { _error.value = e.message ?: "加载失败" } finally { _isRefreshing.value = false } } } fun onSearchQueryChange(query: String) { _searchQuery.value = query } fun toggleBookmark(articleId: String) { viewModelScope.launch { newsRepository.toggleBookmark(articleId) } } fun dismissError() { _error.value = null } }

🍎 iOS 中消费 Flow

6.9 问题:Swift 不能直接订阅 Kotlin Flow

Kotlin Flow 是 Kotlin-specific 的概念,Swift 不能直接 collect。有以下几种解决方案:

方案 1:SKIE 库(推荐)

SKIE(Swift Kotlin Interface Enhancer)自动将 Kotlin Flow 暴露为 Swift AsyncSequence,代码最简洁,体验最接近原生 Swift。

方案 2:手动包装

在 shared 模块中创建 StateFlowWrapper,将 StateFlow 转为带回调的普通 Kotlin 类,Swift 端直接调用。

方案 3:CMP(Compose Multiplatform)

如果使用 Compose Multiplatform,UI 代码在 commonMain 中,直接用 collectAsState(),无需处理 Swift 端 Flow 消费问题。

6.10 使用 SKIE 库

build.gradle.kts — 安装 SKIE plugins { id("co.touchlab.skie") version "0.9.0" } // SKIE 是 KMP 插件,无需其他依赖
Swift — 使用 SKIE 消费 Flow(如 AsyncSequence) // SKIE 将 StateFlow 变为 Swift AsyncSequence // Swift 端: Task { for await state in viewModel.uiState { // 每次 uiState 更新,这里都会被调用 self.articles = state.articles.map { $0.toSwift() } self.isLoading = state.isLoading } }

6.11 手动包装方案(不使用 SKIE)

commonMain — StateFlowWrapper // 将 StateFlow 包装为 iOS 友好的回调式 API class StateFlowWrapper<T>(private val flow: StateFlow<T>) { val value: T get() = flow.value fun subscribe( scope: CoroutineScope, onValue: (T) -> Unit ): Job = scope.launch { flow.collect { onValue(it) } } } // ViewModel 暴露包装后的 Flow val uiStateWrapper = StateFlowWrapper(uiState)
Swift — 消费包装后的 Flow class HomeViewController: UIViewController { let viewModel = HomeViewModel(...) var job: Kotlinx_coroutines_coreJob? = nil override func viewDidLoad() { job = viewModel.uiStateWrapper.subscribe( scope: viewModel.viewModelScope, onValue: { [weak self] state in DispatchQueue.main.async { self?.updateUI(state) } } ) } override func viewDidDisappear(_ animated: Bool) { job?.cancel() } }
推荐使用 SKIE: 手动包装繁琐且容易出现内存泄漏,SKIE 是目前最优雅的解决方案。如果你的项目使用 Compose Multiplatform 共享 UI,则根本不需要在 Swift 中消费 Flow。

🎯 实践任务

  1. 定义 HomeUiState 数据类,包含 articles/isLoading/error 字段
  2. 在 ViewModel 中用 combine 合并多个 Flow 为单一状态
  3. 实现 refresh() 和 toggleBookmark() 方法
  4. 添加 SKIE 插件,在 Swift 端用 for await 消费 StateFlow