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 重组导致的事件重复触发问题。
相关推荐
千里马学框架11 小时前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台11 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone11 小时前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc12 小时前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo14 小时前
Kotlin 协程中的 Job 结构化并发与取消
android
sun00770014 小时前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work15 小时前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼15 小时前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work15 小时前
ch21 签名、校验与发版
kotlin
AFinalStone16 小时前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui