PAYATHON 2026

如何在 ViewModel 中处理 ApiResponse,并选择 Flow 或 suspend?

数据小陈

一次性查询可以使用 suspend fun,再由 ViewModel 通过 StateFlow 向界面提供状态。

如果保留现有的 Flow<ApiResponse<Day>>,可以直接映射它。映射结果需要表达加载、成功和错误三种情况,不要把每种响应都强行转换成只包含业务数据的 DayState。

为什么当前的转换会出错?

Success 分支返回的是 DayState,但两个空分支 {} 的结果是 Unit。末尾的 as DayState 只是强制类型转换,无法把 Unit 变成 DayState,所以处理加载或错误状态时会出现类型转换异常。

删除这个强制转换,并明确区分业务数据与界面状态。下面假设现有的 DayState 用于保存日期、时间、备注等数据。

保留 Flow 时,映射成完整的界面状态

可以在 DayState 外增加一层状态类型:

sealed interface DayUiState {
    object Loading : DayUiState
    data class Success(val day: DayState) : DayUiState
    data class Error(val message: String?) : DayUiState
}

fun Day.toDayState(): DayState = DayState(
    date = date,
    activeTimeStart = activeTimeStart,
    activeTimeEnd = activeTimeEnd,
    actualActiveTimeStart = actualActiveTimeStart,
    actualActiveTimeEnd = actualActiveTimeEnd,
    note = note
)

fun toDayUiState(response: ApiResponse<Day>): DayUiState =
    when (response) {
        is ApiResponse.Success ->
            DayUiState.Success(response.data.toDayState())

        ApiResponse.Loading -> DayUiState.Loading

        is ApiResponse.Error ->
            DayUiState.Error(response.message)
    }

每个分支都返回 DayUiState,不需要 as。

在 ViewModel 中,可以用 stateIn 将映射后的 Flow 转成供界面观察的 StateFlow:

import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

// 放在现有 ViewModel 内
val uiState: StateFlow<DayUiState> =
    daysRepository
        .getDayStream(input.selectedDate.toString())
        .map(::toDayUiState)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = DayUiState.Loading
        )

这里沿用问题中的 getDayStream 调用;如果仓库实际使用的是 selectDayByDate,替换成对应方法即可。

界面根据状态显示进度、数据或错误提示。加载失败时没有新的 DayState 是正常情况,不需要创建空对象来凑齐返回值。

使用时要注意:

  • flow { ... } 创建的是冷流,收集时才执行查询。上述共享策略在订阅停止超过指定时间后会停止上游,后续重新订阅可能再次查询。5_000 是示例配置,需要按页面需求选择。
  • 这段代码在创建 Flow 时读取所选日期,之后日期发生变化,查询参数不会自动更新。如果需要随日期变化查询,可以将日期作为状态流,再使用 flatMapLatest 切换请求。

一次性查询可以改成 suspend 函数

如果仓库只负责发起一次查询并返回结果,可以将接口简化为 suspend fun,由 ViewModel 管理加载和错误状态。

仓库实现可以沿用原有的 Supabase 查询:

suspend fun selectDayByDate(date: String): Day {
    return supabaseClient.from("public.days").select() {
        filter {
            Day::date eq date
        }
    }.decodeSingle<Day>()
}

仓库成功时返回 Day,失败时抛出异常。ViewModel 使用前面的 DayUiState:

import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

// 放在现有 ViewModel 内
private val _uiState =
    MutableStateFlow<DayUiState>(DayUiState.Loading)

val uiState: StateFlow<DayUiState> = _uiState.asStateFlow()

private var loadJob: Job? = null

fun loadDay(date: String) {
    loadJob?.cancel()
    loadJob = viewModelScope.launch {
        _uiState.value = DayUiState.Loading

        try {
            val day = daysRepository.selectDayByDate(date)
            _uiState.value = DayUiState.Success(day.toDayState())
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            _uiState.value = DayUiState.Error(e.message)
        }
    }
}

页面需要加载或重试时调用 loadDay(date)。示例会取消上一次加载任务,适用于只关心最近一次日期选择的场景。初始化时是否调用它,由页面的加载流程决定。

这个方案中,仓库提供一次性结果,StateFlow 保存界面当前状态。使用 ViewModel 并不要求所有仓库方法都返回 Flow。

原有 Flow 代码还需要保留协程取消语义

原代码的 catch (e: Exception) 也可能捕获 CancellationException。协程取消应继续向上传播,不应转换成普通的 API 错误。

如果保留原来的 Flow 实现,需要调整异常处理:

} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    emit(ApiResponse.Error(e.message))
}

这里需要导入 kotlinx.coroutines.CancellationException。如果能区分网络、解码等具体异常,也可以进一步缩小捕获范围。

应该选哪一种?

对于当前的一次性 Supabase 查询,可以让仓库返回 suspend fun ...: Day,由 ViewModel 暴露 StateFlow<DayUiState>。

现有的 Flow<ApiResponse<Day>> 也能使用,它发出了加载和查询结果两个阶段的事件,但不会因此持续监听数据库变化。如果仓库需要提供数据库观察、实时订阅或其他持续更新,可以使用 Flow 表达这些后续数据。

示例中的互斥状态会在加载或失败时替换成功状态。如果希望刷新时继续展示旧数据,可以改用包含 day: DayState?、isLoading 和 errorMessage 等字段的状态对象,让旧数据与刷新状态同时存在。

备注:内容仅供参考。