Android ViewModel数据加载:基于Flow架构的最佳实践

本文译自「Android ViewModel Data Loading: Best Practices and Flow-Based Architecture」,原文链接funkymuse.dev/posts/prope...,由FunkyMuse于2025年8月29日。

Android 开发中的架构讨论经常引发激烈的争论------有时褒贬不一。撰写这些主题的文章并不容易,但这正是它的价值所在。

本文阐述了我对数据加载模式的独到见解,这些见解源于我的经验以及近期手术后的恢复(其中一次手术仍在进行中)。

不妨将此视为我在 2025 年对数据加载模式的理解和技能的概述。

我可能比其他人更晚加入这场讨论,但迟做总比不做好。

挑战:Android ViewModel 中常见的数据加载反模式

"大多数"Android 开发者使用 ViewModel 来管理 UI 状态,这些状态由视图(Fragment、Activity 或可组合组件)收集。为了显示有意义的内容,你需要从真实数据源加载数据,将其转换为视图状态,然后公开以供使用。

以前是 LiveData,现在是 Flow,它充当视图和 ViewModel 之间的粘合剂(大多数情况下)。有一些解决方案使用 molecule,但这超出了我们的讨论范围。

正如这篇 Twitter 讨论 所示,大多数开发者在 ViewModel 的 init {} 块中加载数据。虽然这种方法看似合乎逻辑,但它带来了一些架构问题,Ian Lake 和其他人认为这是反模式------包括使用 LaunchedEffect 进行数据加载。

讽刺的是,即使是官方示例有时也会与这些最佳做法相矛盾:

开发者为何选择 init {} 块(以及它为何存在问题)

ViewModel 的 init {} 块的吸引力显而易见------它确保数据加载在配置更改后依然有效,从而避免了不必要的 API 调用或数据库读取。然而,这种方法也带来了四个关键问题:

问题 1:导航返回栈复杂化

使用 init {} 进行数据加载时,返回到包含现有 ViewModel 的屏幕不会触发重新初始化。这迫使开发者在 onStart 或 onResume 中添加变通逻辑来检查数据新鲜度,从而创建难以维护的意大利面条式代码。

问题 2:调度程序竞争条件

在 init {} 中加载数据通常使用 viewModelScope,它在 Dispatchers.Main.immediate 上运行。这种即时调度程序可能会导致竞争条件,即数据处理在 UI 组合之前就已完成,尤其是在 Jetpack Compose 应用中。

问题 #3:数据过期问题

现代 CRUD 应用程序需要更新数据。用户可能会从其他屏幕返回,或者在相当长一段时间后从暂停状态恢复。init {} 方法没有提供内置的数据新鲜度验证机制。

问题 #4:测试困难

每次运行测试时,你都必须构建 ViewModel 才能成功运行该特定测试用例的 init {} 代码块。

基于 Flow 的解决方案:将冷流转换为热流

该解决方案利用 Kotlin Flows------具体来说,使用 StateFlow 和适当的共享策略将冷流转换为热流。可以将其视为 Katy Perry 的"Hot N Cold"方法,但所有边缘情况的行为都是可预测的。

构建基础:用例和 ViewModel 结构

请注意,此代码仅用于演示目的,如何构建由你决定,除加载部分外,并非最佳实践

kotlin 复制代码
inline fun <reified T : ViewModel> provideFactory(
    crossinline creator: () -> T
) = viewModelFactory {
    initializer {
        creator()
    }
}

注意:此工厂模式仅用于演示目的

我们的用例处理数据检索、格式化和业务逻辑转换:

kotlin 复制代码
class GetUserDetailsUseCase private constructor(
    private val authRepository: AuthRepository = AuthRepository(),
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
    private val billingCache: BillingCache = BillingCache.create(),
    private val dateFormatter: DateFormatter = DataFormatter()
) {
    suspend fun execute(): Result<UserDetails> =
        withContext(dispatcher) {
            val userDetails: Result<UserDetailsResponseModel> = authRepository.getUserDetails()

            userDetails.map { details ->
                UserDetails(
                    creationDate = dateFormatter.format(
                        details.creationDate,
                        DateFormatter.Format.UTC_SHORT
                    ).getOrNull(),
                    avatarUrl = details.avatar,
                    isPremium = billingCache.isPremium(),
                    email = details.email
                )
            }
        }

    companion object {
        fun create() = GetUserDetailsUseCase()
    }
}

此用例封装了从存储库检索数据、日期格式化、高级状态验证以及准备用户信息以供显示。

kotlin 复制代码
internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel() {
    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )
    }

    val userDetails: Flow<ViewState> = flow {
        emit(
            getUserDetailsUseCase.execute()
                .fold(
                    onSuccess = {
                        ViewState(
                            isLoading = false,
                            isError = false,
                            userInfo = ViewState.UserInfo(
                                displayEmail = it.email,
                                avatarUrl = it.avatarUrl,
                                showPremiumBadge = it.isPremium,
                                memberSince = it.creationDate?.toString()
                            )
                        )
                    },

                    onFailure = {
                        ViewState(isLoading = false, isError = true)
                    }
                )
        )
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        ViewState(isLoading = true, isError = false)
    )

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

这种方法有几个关键优势:

  • 数据新鲜度:5 秒超时时间与 Android 的 ANR 阈值一致,确保在超时后收集器重新出现时数据能够刷新。
  • 配置变更处理:在超时窗口内,即使配置发生变化,数据也能持久保存。
  • 资源效率:避免不必要的网络调用,以实现快速导航模式。

专业提示:对于需要实时数据新鲜度的应用程序,请将超时时间设置为 0

添加用户交互:实现刷新功能

实际应用程序需要用户主动发起的数据刷新功能。产品经理喜欢滑动刷新,但我们的基本流程无法适应这种模式。让我们来增强我们的架构:

我们使用在主流程收集器中触发的 MutableSharedFlow 来实现这一点:

kotlin 复制代码
internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents> {
    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
        }
    }

    private val refreshListener = MutableSharedFlow<Unit>()

    val userDetails: Flow<ViewState> = flow {
        emit(getUserDetailsState())
        refreshListener.collect {
            emit(ViewState(isLoading = true, isError = false))
            emit(getUserDetailsState())
        }
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        ViewState(isLoading = true, isError = false)
    )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()
        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },

            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                viewModelScope.launch {
                    refreshListener.emit(Unit)
                }
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

完美!我们成功重构了重复的数据加载逻辑,并实现了刷新功能。然而,这还不算完。

优化状态管理:消除冗余状态输出

为了避免不必要的 UI 更新,我们将添加 distinctUntilChanged() 来过滤重复的状态输出。

处理复杂的状态更新

对于意图修改 UI 状态而不需要重新加载数据的场景,我们需要在流程操作中访问当前状态。例如,切换电子邮件可见性------这需要修改状态而不是重新加载数据。

kotlin 复制代码
data class ViewState(
    val isLoading: Boolean = false,
    val isError: Boolean = false,
    val isEmailVisible: Boolean = false,
    val userInfo: UserInfo? = null
) {
    data class UserInfo(
        val displayEmail: String,
        val avatarUrl: String?,
        val showPremiumBadge: Boolean,
        val memberSince: String?
    )

    sealed class Intents {

        data object Refresh : Intents()
        data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
    }

    sealed class StateParameters {
        data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateParameters()
        data object Refresh : StateParameters()
    }
}

private val refreshListener = MutableSharedFlow<ViewState.StateParameters>()

val userDetails: Flow<ViewState> = flow {
    emit(getUserDetailsState())

    refreshListener.collect { refreshParams ->
        when (refreshParams) {
            is ViewState.StateParameters.EmailVisibilityChanged -> {
                //do some changes here
            }

            ViewState.StateParameters.Refresh -> {
                emit(ViewState(isLoading = true, isError = false))
                emit(getUserDetailsState())
            }
        }
    }
}
    .distinctUntilChanged()
    .stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        ViewState(isLoading = true, isError = false)
    )

这里的挑战在于如何在流程中访问当前状态。让我们通过内部跟踪状态来解决这个问题:

kotlin 复制代码
internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents> {
    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val isEmailVisible: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
            data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
        }

        sealed class StateTriggers {
            data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateTriggers()
            data object Refresh : StateTriggers()
        }
    }

    private var currentState = ViewState(isLoading = true, isError = false)

    private val refreshListener = MutableSharedFlow<ViewState.StateTriggers>()

    val userDetails: Flow<ViewState> = flow {
        emit(getUserDetailsState())

        refreshListener.collect { refreshParams ->
            when (refreshParams) {
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }
                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    }
        .distinctUntilChanged()
        .onEach {
            currentState = it
        }
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000),
            currentState
        )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()
        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },

            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.Refresh)
                }
            }

            is ViewState.Intents.ToggleEmailVisibility -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.EmailVisibilityChanged(intent.isEmailVisible))
                }
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

智能数据缓存:条件加载策略

现在我们可以实现复杂的缓存行为了。由于 currentState 在 ViewModel 生命周期内持续存在,我们可以立即发出缓存数据,并仅在必要时有条件地加载新数据:

kotlin 复制代码
internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents> {
    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val isEmailVisible: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        val isDataLoaded get() = userInfo != null

        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
            data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
        }

        sealed class StateTriggers {
            data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateTriggers()
            data object Refresh : StateTriggers()
        }
    }

    private var currentState = ViewState(isLoading = true, isError = false)

    private val refreshListener = MutableSharedFlow<ViewState.StateTriggers>()

    val userDetails: Flow<ViewState> = flow {
        emit(currentState)

        //i added error check just because this is for demonstration of this edge case

        if (currentState.isDataLoaded.not() || currentState.isError) {
            emit(getUserDetailsState())
        }

        refreshListener.collect { refreshParams ->
            when (refreshParams) {
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }

                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    }
        .distinctUntilChanged()
        .onEach {
            currentState = it
        }

        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000),
            currentState
        )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()
        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },

            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.Refresh)
                }
            }

            is ViewState.Intents.ToggleEmailVisibility -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.EmailVisibilityChanged(intent.isEmailVisible))
                }
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

此模式提供智能缓存------即使在 5 秒超时后,你也可以根据具体需求选择是否重新获取数据:

  • 昂贵的 API 调用:在 ViewModel 中缓存数据以减少网络开销
  • 静态后端数据:避免对很少更改的信息进行不必要的请求
  • 实时需求:强制刷新需要更新数据的应用程序

创建可复用的抽象

重复编写此模式会变得非常繁琐。让我们从 ViewModel 扩展函数开始,创建可复用的抽象:

kotlin 复制代码
fun <T, R> ViewModel.loadData(
    initialState: T,
    loadData: suspend FlowCollector<T>.(currentState: T) -> Unit,
    refreshMechanism: SharedFlow<R>? = null,
    timeout: Long = 5_000,
    refreshData: (suspend FlowCollector<T>.(currentState: T, refreshParams: R) -> Unit)? = null,
): StateFlow<T> {

    if (refreshMechanism != null) {
        requireNotNull(refreshData) {
            "You've provided a refresh mechanism but no way to refresh the data"
        }
    }

    if (refreshData != null) {
        requireNotNull(refreshMechanism) {
            "You've provided a refresh data but no mechanism to refresh the data"
        }
    }

    var latestValue = initialState

    return flow {
        emit(latestValue)

        loadData(latestValue)

        refreshMechanism?.collect { refreshParams ->
            if (refreshData != null) {
                refreshData(latestValue, refreshParams)
            }
        }
    }
        .distinctUntilChanged()
        .onEach {
            latestValue = it
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = initialState
        )
}

fun <T> ViewModel.loadData(
    initialState: T,
    loadData: suspend FlowCollector<T>.(currentState: T) -> Unit,
    timeout: Long = 5_000,
): StateFlow<T> {
    var latestValue = initialState

    return flow {
        emit(latestValue)
        loadData(latestValue)
    }
        .onEach {
            latestValue = it
        }
        .distinctUntilChanged()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = initialState
        )
}

有了我们抽象的扩展函数,ViewModel 变得更加简洁:

注意:此抽象涵盖了 90% 的常见用例,但不支持复杂的流程链式操作

kotlin 复制代码
internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModel(), IntentAware<UserAccountDetailsViewModel.ViewState.Intents> {
    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val isEmailVisible: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        val isDataLoaded get() = userInfo != null

        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
            data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
        }

        sealed class StateTriggers {
            data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateTriggers()
            data object Refresh : StateTriggers()
        }
    }

    private val refreshListener = MutableSharedFlow<ViewState.StateTriggers>()

    val userDetails = loadData(
        initialState = ViewState(isLoading = true, isError = false),
        loadData = { currentState ->
            if (currentState.isDataLoaded.not() || currentState.isError.not()) {
                emit(getUserDetailsState())
            }
        },
        refreshMechanism = refreshListener,
        refreshData = { currentState, refreshParams ->
            when (refreshParams) {
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }

                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()
        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },

            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.Refresh)
                }
            }

            is ViewState.Intents.ToggleEmailVisibility -> {
                viewModelScope.launch {
                    refreshListener.emit(ViewState.StateTriggers.EmailVisibilityChanged(intent.isEmailVisible))
                }
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

我们可以通过创建更复杂的基类来消除重复的 refreshListener 声明:

kotlin 复制代码
abstract class ViewModelLoader<State : Any, Intent : Any, Trigger : Any> : ViewModel() {
    private val _trigger by lazy { MutableSharedFlow<Trigger>() }

    fun <T> loadData(
        initialState: T,
        loadData: suspend FlowCollector<T>.(currentState: T) -> Unit,
        triggerData: (suspend FlowCollector<T>.(currentState: T, triggerParams: Trigger) -> Unit)? = null,
        timeout: Long = 5000L, //matching ANR timeout in Android
    ): StateFlow<T> {
        var latestValue = initialState

        return flow {
            emit(latestValue)

            loadData(latestValue)

            if (triggerData != null) {
                _trigger.collect { triggerParams ->
                    triggerData(this, latestValue, triggerParams)
                }
            }
        }
            .distinctUntilChanged()
            .onEach {
                latestValue = it
            }

            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(timeout),
                initialValue = initialState
            )
    }

    abstract val state: StateFlow<State>

    val currentState get() = state.value

    open fun onIntent(intent: Intent) {}

    protected fun sendTrigger(trigger: Trigger) {
        viewModelScope.launch {
            _trigger.emit(trigger)
        }
    }
}

我们的最终实现将变得非常简洁且易于维护:

kotlin 复制代码
internal class UserAccountDetailsViewModel private constructor(
    private val getUserDetailsUseCase: GetUserDetailsUseCase = GetUserDetailsUseCase.create(),
) : ViewModelLoader<UserAccountDetailsViewModel.ViewState, UserAccountDetailsViewModel.ViewState.Intents, UserAccountDetailsViewModel.ViewState.StateTriggers>() {
    data class ViewState(
        val isLoading: Boolean = false,
        val isError: Boolean = false,
        val isEmailVisible: Boolean = false,
        val userInfo: UserInfo? = null
    ) {
        val isDataLoaded get() = userInfo != null

        data class UserInfo(
            val displayEmail: String,
            val avatarUrl: String?,
            val showPremiumBadge: Boolean,
            val memberSince: String?
        )

        sealed class Intents {
            data object Refresh : Intents()
            data class ToggleEmailVisibility(val isEmailVisible: Boolean) : Intents()
        }

        sealed class StateTriggers {
            data class EmailVisibilityChanged(val isEmailVisible: Boolean) : StateTriggers()
            data object Refresh : StateTriggers()
        }
    }

    override val state = loadData(
        initialState = ViewState(isLoading = true, isError = false),
        loadData = { currentState ->
            if (currentState.isDataLoaded.not() || currentState.isError.not()) {
                emit(getUserDetailsState())
            }
        },

        triggerData = { currentState, refreshParams ->
            when (refreshParams) {
                is ViewState.StateTriggers.EmailVisibilityChanged -> {
                    emit(currentState.copy(isEmailVisible = refreshParams.isEmailVisible))
                }

                ViewState.StateTriggers.Refresh -> {
                    emit(ViewState(isLoading = true, isError = false))
                    emit(getUserDetailsState())
                }
            }
        }
    )

    private suspend fun getUserDetailsState(): ViewState = getUserDetailsUseCase.execute()

        .fold(
            onSuccess = {
                ViewState(
                    isLoading = false,
                    isError = false,
                    userInfo = ViewState.UserInfo(
                        displayEmail = it.email,
                        avatarUrl = it.avatarUrl,
                        showPremiumBadge = it.isPremium,
                        memberSince = it.creationDate?.toString()
                    )
                )
            },

            onFailure = {
                ViewState(isLoading = false, isError = true)
            }
        )

    override fun onIntent(intent: ViewState.Intents) {
        when (intent) {
            ViewState.Intents.Refresh -> {
                sendTrigger(ViewState.StateTriggers.Refresh)
            }

            is ViewState.Intents.ToggleEmailVisibility -> {
                sendTrigger(ViewState.StateTriggers.EmailVisibilityChanged(intent.isEmailVisible))
            }
        }
    }

    companion object {
        fun factory() = provideFactory { UserAccountDetailsViewModel() }
    }
}

处理 UI 状态复杂性

此抽象使用布尔标志(isLoadingisError),这些标志可能会创建模糊状态。为了更清晰地管理状态,可以考虑使用密封类:

kotlin 复制代码
@Immutable
sealed interface UIState {
    @Immutable
    data object Success : UIState
    @Immutable
    data object Error : UIState
    @Immutable
    data object Idle : UIState
    @Immutable
    data object Loading : UIState
}

对于需要同时显示错误信息(例如 Snackbars)和现有数据的场景,你可以创建更复杂的状态持有者:

kotlin 复制代码
@Immutable
data class UIStateHolder<out T>(
    val uiState: UIState = UIState.Idle,
    val payload: T? = null
)

这种方法可以实现灵活的 UI 状态管理,同时保持 UI 状态和数据负载之间的明确分离,但可能会增加认知负荷,并引入更多的映射行为和解包逻辑。

超越 ViewModel

此模式不仅限于 ViewModel。通过提供自定义协程作用域,你可以在任何组件(可组合组件、存储库或业务逻辑层)中使用此数据加载方法。

流组合模式

这种方法的优点还在于可以组合多个数据源。以下是处理单流和双流的示例:

kotlin 复制代码
inline fun <reified T, R> ViewModel.loadFlow(
    initialState: R,
    flow: Flow<T>,
    crossinline transform: suspend CoroutineScope.(newValue: T, currentState: R) -> R,
    timeout: Long = 0,
): StateFlow<R> {

    var latestValue = initialState

    return flow
        .map { newValue ->
            coroutineScope {
                transform(newValue, latestValue)
            }
        }

        .onEach {
            latestValue = it
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = latestValue
        )
}

组合双流的示例:

kotlin 复制代码
inline fun <reified T1, reified T2, R> ViewModel.loadFlow(
    initialState: R,
    flow1: Flow<T1>,
    flow2: Flow<T2>,
    crossinline transform: suspend CoroutineScope.(newValue1: T1, newValue2: T2, currentState: R) -> R,
    timeout: Long = 0,
): StateFlow<R> {

    var latestValue = initialState

    return combine(flow1, flow2) { value1, value2 ->
        coroutineScope {
            transform(value1, value2, latestValue)
        }
    }
        .onEach {
            latestValue = it
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(timeout),
            initialValue = latestValue
        )
}

这些扩展允许你轻松组合多个数据源,同时保持相同的智能缓存和状态管理原则。

测试基于流的 ViewModel

在结束之前,让我们探索如何使用 fakes 和 Turbine 进行流测试,正确地测试我们的 UserAccountDetailsViewModel 实现。

使用 Fakes 和 Turbine 设置测试依赖项

kotlin 复制代码
@OptIn(ExperimentalCoroutinesApi::class)
class UserAccountDetailsViewModelTest {
    private val testDispatcher = StandardTestDispatcher()

    private val fakeGetUserDetailsUseCase = FakeGetUserDetailsUseCase()

    private lateinit var viewModel: UserAccountDetailsViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
        viewModel = UserAccountDetailsViewModel(fakeGetUserDetailsUseCase)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
}

// Fake implementation for realistic testing, this sounds funny to write haha
class FakeGetUserDetailsUseCase {
    private var shouldReturnError = false

    private var userDetailsToReturn: UserDetails? = null

    private var executionCount = 0

    fun setSuccessResponse(userDetails: UserDetails) {
        this.userDetailsToReturn = userDetails
        this.shouldReturnError = false
    }

    fun setErrorResponse() {
        this.shouldReturnError = true
        this.userDetailsToReturn = null
    }

    // Expose execution count when testing caching/performance behavior
    fun getExecutionCount() = executionCount

    fun reset() {
        executionCount = 0
    }

    suspend fun execute(): Result<UserDetails> {
        executionCount++

        delay(50) // Simulate network delay

        return if (shouldReturnError) {
            Result.failure(Exception("Network error"))
        } else {
            Result.success(userDetailsToReturn ?: createDefaultUserDetails())
        }
    }

    private fun createDefaultUserDetails() = UserDetails(
        email = "default@example.com",
        avatarUrl = null,
        isPremium = false,
        creationDate = "2023-01-01"
    )
}

成功和失败场景的参数化测试

kotlin 复制代码
@ParameterizedTest
@ValueSource(booleans = [true, false])
fun `should handle both success and error scenarios`(shouldSucceed: Boolean) = runTest {
    // Given

    if (shouldSucceed) {
        fakeGetUserDetailsUseCase.setSuccessResponse(
            UserDetails(
                email = "success@example.com",
                avatarUrl = "https://avatar.url",
                isPremium = true,
                creationDate = "2023-01-01"
            )
        )
    } else {
        fakeGetUserDetailsUseCase.setErrorResponse()
    }

    // When

    viewModel.state.test {
        advanceUntilIdle()

        // Then Focus on behavior, not implementation details

        if (shouldSucceed) {
            awaitItem() // Loading state

            val successState = awaitItem()

            assertThat(successState.isLoading).isFalse()

            assertThat(successState.isError).isFalse()

            assertThat(successState.userInfo?.displayEmail).isEqualTo("success@example.com")

            assertThat(successState.userInfo?.showPremiumBadge).isTrue()
        } else {
            awaitItem() // Loading state

            val errorState = awaitItem()

            assertThat(errorState.isLoading).isFalse()

            assertThat(errorState.isError).isTrue()

            assertThat(errorState.userInfo).isNull()
        }
    }
}

@Test
fun `should refresh data when refresh intent is triggered`() = runTest {
    // Given Initial successful load
    fakeGetUserDetailsUseCase.setSuccessResponse(
        UserDetails(
            email = "initial@example.com",
            avatarUrl = null,
            isPremium = false,
            creationDate = "2022-01-01"
        )
    )

    viewModel.state.test {
        advanceUntilIdle()

        awaitItem() // Loading

        val initialState = awaitItem() // Success

        assertThat(initialState.userInfo?.displayEmail).isEqualTo("initial@example.com")

        // Change response and trigger refresh
        fakeGetUserDetailsUseCase.setSuccessResponse(
            UserDetails(
                email = "refreshed@example.com",
                avatarUrl = "https://new-avatar.url",
                isPremium = true,
                creationDate = "2023-01-01"
            )
        )

        viewModel.onIntent(ViewState.Intents.Refresh)

        advanceUntilIdle()

        // Then
        awaitItem() // Loading during refresh

        val refreshedState = awaitItem() // New Success

        assertThat(refreshedState.isLoading).isFalse()

        assertThat(refreshedState.userInfo?.displayEmail).isEqualTo("refreshed@example.com")

        assertThat(refreshedState.userInfo?.showPremiumBadge).isTrue()

        // Verify both initial load and refresh were called
        assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(2)
    }
}

测试仅 UI 状态变化

kotlin 复制代码
@Test
fun `should toggle email visibility without triggering data reload`() = runTest {
    // Given Successful initial load
    fakeGetUserDetailsUseCase.setSuccessResponse(
        UserDetails(
            email = "test@example.com",
            avatarUrl = null,
            isPremium = false,
            creationDate = "2023-01-01"
        )
    )

    viewModel.state.test {
        advanceUntilIdle()

        awaitItem() // Loading

        val loadedState = awaitItem() // Success

        assertThat(loadedState.userInfo?.displayEmail).isEqualTo("test@example.com")

        assertThat(loadedState.isEmailVisible).isFalse()

        // When Toggle email visibility

        viewModel.onIntent(ViewState.Intents.ToggleEmailVisibility(isEmailVisible = true))

        advanceUntilIdle()

        // Then
        val toggledState = awaitItem()

        assertThat(toggledState.isEmailVisible).isTrue()

        assertThat(toggledState.userInfo?.displayEmail).isEqualTo("test@example.com")

        assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(1)
    }
}

测试数据缓存行为

kotlin 复制代码
@Test
fun `should use cached data when returning to screen quickly`() = runTest {
    // Given

    fakeGetUserDetailsUseCase.setSuccessResponse(
        UserDetails(
            email = "cached@example.com",
            avatarUrl = null,
            isPremium = true,
            creationDate = "2023-01-01"
        )
    )

    // When First collection

    viewModel.state.test {
        advanceUntilIdle()

        awaitItem() // Loading

        val firstState = awaitItem() // Success

        assertThat(firstState.userInfo?.displayEmail).isEqualTo("cached@example.com")

        cancel() // Simulate leaving screen
    }

    // When Quick return (simulating navigation back within timeout)

    viewModel.state.test {
        advanceUntilIdle()

        // Then Should have cached data immediately (no Loadin)

        val cachedState = awaitItem()

        assertThat(cachedState.isLoading).isFalse()

        assertThat(cachedState.userInfo?.displayEmail).isEqualTo("cached@example.com")

        expectNoEvents()
    }

    assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(1)
}

测试错误恢复

kotlin 复制代码
@Test
fun `should recover from Error on successful refresh`() = runTest {
    // Given Initial error

    fakeGetUserDetailsUseCase.setErrorResponse()

    viewModel.state.test {
        advanceUntilIdle()

        awaitItem() // Loading

        val errorState = awaitItem() // Error

        assertThat(errorState.isError).isTrue()

        // When Fix the response and refresh

        fakeGetUserDetailsUseCase.setSuccessResponse(
            UserDetails(
                email = "recovered@example.com",
                avatarUrl = null,
                isPremium = false,
                creationDate = "2023-01-01"
            )
        )

        viewModel.onIntent(ViewState.Intents.Refresh)

        advanceUntilIdle()

        // Then Should recover successfully

        awaitItem() // Loading during refresh

        val recoveredState = awaitItem() // Success

        assertThat(recoveredState.isError).isFalse()

        assertThat(recoveredState.userInfo?.displayEmail).isEqualTo("recovered@example.com")

        assertThat(fakeGetUserDetailsUseCase.getExecutionCount()).isEqualTo(2)
    }
}

为什么要测试基于流的 ViewModel 的原则

哦......这可能会引发一些争论,但这里有一篇很棒的文章,它提供了更好的描述,并且不会影响本文关于我为什么使用 Fakes 的目的。这里有一个简短的总结,可以补充文章顶部

  1. 使用 Fakes 而非 Mocks:Fake 提供逼真的行为,并且更易于维护,尤其是在如今 LLM 的帮助下......
  2. 参数化测试:用一个测试用例同时测试成功和失败路径
  3. 使用 Turbine 进行流程测试:简洁、富有表现力的流程测试,并进行适当的状态验证
  4. 测试状态转换:验证完整的状态流程,而不仅仅是最终状态
  5. 谨慎执行计数:仅在测试缓存、性能或重试行为时验证调用计数
  6. 关注行为:测试用户体验,而不是实现细节(我想这一点显而易见)

结论

本文对 Android ViewModel 中基于流程的数据加载的探索,解决了传统 init {} 块方法的根本挑战。虽然涵盖所有架构变体会很繁琐,但该模式成功处理了大约 90% 的常见用例。

抽象基类方法(ViewModelLoader)提供了最可预测和可维护的解决方案,它提供:

  • 可预测的状态管理:清晰的状态触发器和意图处理
  • 内置测试支持:具有适当协程处理的可测试架构
  • 灵活性:易于扩展效果和混合 MVI 模式
  • 缓存和刷新:缓存和刷新机制(本文将尽可能详细地介绍)

关键要点

  1. 基于流的加载 消除了竞争条件,提高了测试的简易性,并解决了 init {} 块中固有的回栈问题
  2. 具有适当共享策略的 StateFlow 提供了最佳的生命周期感知数据管理
  3. 抽象层减少了样板代码,同时保持了灵活性
  4. 全面的测试确保所有用例的可靠性

请记住,这只是众多架构方法中的一种,它解决了我的问题,但可能无法解决你的问题。我们的目标是了解潜在问题并评估此解决方案是否符合你的特定需求。随着时间的推移,这个解决方案将经历许多变化,甚至可能被淘汰。最近,我更多地投入到使用 Ktor 进行后端开发,这本身就是一次很棒的体验。

该架构成功地为 iOS 和 Android 平台上的 WallHub 提供了支持,证明了其在现实世界中的"可行性"以及跨平台适应性(如果可以这么说的话)。

继续潜水,直到下一篇文章......

相关推荐
darkb1rd1 天前
五、PHP类型转换与类型安全
android·安全·php
gjxDaniel1 天前
Kotlin编程语言入门与常见问题
android·开发语言·kotlin
csj501 天前
安卓基础之《(22)—高级控件(4)碎片Fragment》
android
峥嵘life1 天前
Android16 【CTS】CtsMediaCodecTestCases等一些列Media测试存在Failed项
android·linux·学习
stevenzqzq1 天前
Compose 中的状态可变性体系
android·compose
似霰1 天前
Linux timerfd 的基本使用
android·linux·c++
darling3311 天前
mysql 自动备份以及远程传输脚本,异地备份
android·数据库·mysql·adb
你刷碗1 天前
基于S32K144 CESc生成随机数
android·java·数据库
TheNextByte11 天前
Android上的蓝牙文件传输:跨设备无缝共享
android
野生技术架构师1 天前
Java 21虚拟线程 vs Kotlin协程:高并发编程模型的终极对决与选型思考
java·开发语言·kotlin