⚡ 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。
🎯 实践任务
- 定义
HomeUiState数据类,包含 articles/isLoading/error 字段 - 在 ViewModel 中用
combine合并多个 Flow 为单一状态 - 实现 refresh() 和 toggleBookmark() 方法
- 添加 SKIE 插件,在 Swift 端用
for await消费 StateFlow