Jetpack Compose :封装 MVVM 框架

在 Jetpack Compose 时代,传统的 MVVM 模式依然适用,但其实现方式和侧重点发生了巨大的变化。如何利用 Compose 的声明式特性,结合 Kotlin Flow、Coroutines 和 Hilt,打造一个代码简洁、状态安全、易于维护的现代化架构?

本文将带你从零开始,封装一个开箱即用的 Compose MVVM 基础框架。


1. 架构核心思想

我们要解决的核心痛点:

  1. MVI 风格的状态管理:单一数据源,杜绝状态不一致。
  2. UI 事件防抖与一次性事件:如"登录成功跳转"这种事件,旋转屏幕后不应再次触发。
  3. 网络请求标准化:统一处理 Loading、Error、Empty 状态。
  4. 基类封装 :减少 ViewModelComposable 中的样板代码。

2. 核心状态封装 (State & Intent)

2.1 定义 UI 状态基类

所有页面的 UI 状态都应包含基本的加载和错误信息。

Kotlin 复制代码
// 基础 UI 状态接口
interface IUiState {
    val isLoading: Boolean
    val errorMessage: String?
}

// 默认实现
data class BaseUiState(
    override val isLoading: Boolean = false,
    override val errorMessage: String? = null
) : IUiState

2.2 定义 UI 事件 (Intent)

用户意图(点击、输入)或一次性事件(Toast、导航)。

Kotlin 复制代码
// UI 事件标记接口
interface IUiIntent

// 一次性副作用(如导航、弹窗)
sealed interface UiEffect {
    data class ShowToast(val message: String) : UiEffect
    data class Navigate(val route: String) : UiEffect
}

3. BaseViewModel 封装

这是框架的核心。我们需要处理 StateFlow 的更新、Effect 的发送以及协程的异常捕获。

Kotlin 复制代码
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

abstract class BaseViewModel<S : IUiState, I : IUiIntent>(initialState: S) : ViewModel() {

    // 1. UI State: 使用 StateFlow 保证线程安全和粘性
    private val _uiState = MutableStateFlow(initialState)
    val uiState: StateFlow<S> = _uiState.asStateFlow()

    // 2. UI Effect: 使用 Channel 处理一次性事件(非粘性)
    private val _effect = Channel<UiEffect>()
    val effect: Flow<UiEffect> = _effect.receiveAsFlow()

    // 3. 供子类更新状态
    protected fun setState(reduce: S.() -> S) {
        _uiState.update { it.reduce() }
    }

    // 4. 供子类发送一次性事件
    protected fun setEffect(builder: () -> UiEffect) {
        viewModelScope.launch { _effect.send(builder()) }
    }

    // 5. 抽象方法:处理 UI 意图
    abstract fun sendIntent(intent: I)

    // 6. 统一网络请求启动器(带 Loading 和 Error 处理)
    protected fun launchNetwork(
        errorBlock: ((Throwable) -> Unit)? = null,
        block: suspend () -> Unit
    ) {
        viewModelScope.launch {
            try {
                // 开启 Loading
                setState { this.copyUiState(isLoading = true) as S }
                block()
            } catch (e: Exception) {
                // 统一错误处理
                errorBlock?.invoke(e) ?: setEffect { UiEffect.ShowToast(e.message ?: "Unknown Error") }
            } finally {
                // 关闭 Loading
                setState { this.copyUiState(isLoading = false) as S }
            }
        }
    }
    
    // 辅助扩展方法,需要 S 实现 copy 方法(Data Class 默认支持)
    // 这里为了演示简单,实际项目中通常使用反射或特定接口来处理 copy
    abstract fun S.copyUiState(isLoading: Boolean = this.isLoading, errorMessage: String? = this.errorMessage): S
}

4. UI 层封装 (BaseScreen)

在 Compose 中,我们需要一个统一的入口来收集 State 和 Effect。

Kotlin 复制代码
@Composable
fun <S : IUiState, I : IUiIntent, VM : BaseViewModel<S, I>> BaseScreen(
    viewModel: VM,
    onEffect: (UiEffect) -> Unit = {}, // 处理导航等副作用
    content: @Composable (S, (I) -> Unit) -> Unit
) {
    // 1. 收集状态
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    
    // 2. 收集副作用(利用 LaunchedEffect 保证生命周期安全)
    LaunchedEffect(viewModel.effect) {
        viewModel.effect.collect { effect ->
            onEffect(effect)
        }
    }

    // 3. 渲染内容,并透传 Intent 发送函数
    content(state) { intent -> viewModel.sendIntent(intent) }
}

5. 实战使用示例

假设我们要写一个"登录页面"。

5.1 定义 Contract

把 State 和 Intent 放在一起,一目了然。

Kotlin 复制代码
data class LoginState(
    override val isLoading: Boolean = false,
    override val errorMessage: String? = null,
    val username: String = "",
    val isLoginSuccess: Boolean = false
) : IUiState

sealed class LoginIntent : IUiIntent {
    data class UpdateUsername(val name: String) : LoginIntent()
    object LoginClick : LoginIntent()
}

5.2 实现 ViewModel

Kotlin 复制代码
@HiltViewModel
class LoginViewModel @Inject constructor(
    private val repo: UserRepository
) : BaseViewModel<LoginState, LoginIntent>(LoginState()) {

    // 实现基类的 copy 辅助方法
    override fun LoginState.copyUiState(isLoading: Boolean, errorMessage: String?): LoginState {
        return copy(isLoading = isLoading, errorMessage = errorMessage)
    }

    override fun sendIntent(intent: LoginIntent) {
        when (intent) {
            is LoginIntent.UpdateUsername -> setState { copy(username = intent.name) }
            is LoginIntent.LoginClick -> login()
        }
    }

    private fun login() {
        launchNetwork {
            // 这里自动处理了 isLoading = true
            val result = repo.login(uiState.value.username)
            if (result.success) {
                setEffect { UiEffect.Navigate("/home") }
            }
            // finally 块会自动处理 isLoading = false
        }
    }
}

5.3 实现 UI (Composable)

Kotlin 复制代码
@Composable
fun LoginScreen(
    viewModel: LoginViewModel = hiltViewModel(),
    navController: NavController
) {
    BaseScreen(
        viewModel = viewModel,
        onEffect = { effect ->
            when (effect) {
                is UiEffect.Navigate -> navController.navigate(effect.route)
                is UiEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
            }
        }
    ) { state, sendIntent ->
        
        // 你的 UI 代码
        if (state.isLoading) {
            CircularProgressIndicator()
        }
        
        TextField(
            value = state.username,
            onValueChange = { sendIntent(LoginIntent.UpdateUsername(it)) }
        )
        
        Button(onClick = { sendIntent(LoginIntent.LoginClick) }) {
            Text("Login")
        }
    }
}

6. 总结

  1. 标准化 :所有页面都遵循 State -> ViewModel -> UI 的单向数据流。
  2. 自动化:网络请求的 Loading 和 Error 状态由基类统一管理,业务代码只关注成功逻辑。
  3. 安全性 :使用 Channel 处理副作用,完美解决 Compose 重组导致的事件重复触发问题。
相关推荐
阿巴斯甜8 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker9 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952710 小时前
Andorid Google 登录接入文档
android
黄林晴11 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android