Compose与ViewModel集成:状态管理与导航实战

各位同学,今天我们来聊聊Compose和ViewModel的集成。说实话,这个组合是Jetpack全家桶里最实用的搭配之一。我在好几个项目里都用了这套方案,效果确实不错。

先给大家画个整体图,看看我们今天要讲什么:

Compose + ViewModel 集成知识体系 ViewModel ViewModel 获取 viewModel() / hiltViewModel() StateFlow + collectAsState 响应式状态订阅 副作用管理 LaunchedEffect / DisposableEffect Navigation 集成 NavHost + 路由管理 核心思想:ViewModel 管理状态,Compose 响应式渲染

一、ViewModel 在 Compose 中的获取方式

在传统 View 体系里,我们通过 ViewModelProvider 来获取 ViewModel。到了 Compose 时代,事情变得简单多了。

我个人最常用的方式就是直接用 viewModel() 函数:

@Composable
fun MyScreen(
    viewModel: MyViewModel = viewModel()
) {
    // 直接使用 viewModel
}

嗯,这里有个细节要注意。如果你用了 Hilt,那就换成 hiltViewModel()

@Composable
fun MyScreen(
    viewModel: MyViewModel = hiltViewModel()
) {
    // Hilt 自动注入依赖
}
小技巧:我习惯把 ViewModel 作为 Composable 函数的默认参数。这样在预览时可以直接传 mock 数据,测试也方便。

二、StateFlow 与 collectAsState

ViewModel 里管理状态,Compose 里订阅状态。这个流程说白了就是「数据驱动 UI」。

先看 ViewModel 怎么写:

class MyViewModel : ViewModel() {
    
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
    
    fun updateData() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(
                isLoading = true
            )
            // 模拟网络请求
            delay(1000)
            _uiState.value = _uiState.value.copy(
                isLoading = false,
                data = "加载完成"
            )
        }
    }
}

data class UiState(
    val isLoading: Boolean = false,
    val data: String = ""
)

然后在 Compose 里订阅:

@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    
    when {
        uiState.isLoading -> {
            CircularProgressIndicator()
        }
        else -> {
            Text(text = uiState.data)
        }
    }
}

为什么用 collectAsState()?因为它会把 Flow 转成 Compose 的 State,每次数据变化时自动重组。我在项目中遇到过一个问题:如果直接用 collect 而不转成 State,UI 不会自动更新。这个坑我踩过一次就记住了。

注意:StateFlow 的初始值很重要。如果 ViewModel 创建时没有设置初始状态,Compose 第一次重组时可能拿到 null 值。

三、Compose 中的副作用管理

副作用,说白了就是在 Composable 函数里做一些「不纯」的事情。比如网络请求、数据库操作、或者监听某个事件。

3.1 LaunchedEffect

这个是最常用的。它会在 Composable 进入组合时启动一个协程,离开时自动取消。

@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    
    LaunchedEffect(Unit) {
        // 页面加载时自动请求数据
        viewModel.loadData()
    }
    
    // 或者监听某个 key 的变化
    var userId by remember { mutableStateOf("") }
    
    LaunchedEffect(userId) {
        if (userId.isNotEmpty()) {
            viewModel.loadUserData(userId)
        }
    }
}

我个人习惯把 LaunchedEffect 放在 Composable 函数的开头,这样一眼就能看到这个页面有哪些副作用。

3.2 DisposableEffect

这个用来做清理工作。比如注册监听器、打开传感器等,离开时需要释放资源。

@Composable
fun MyScreen() {
    
    DisposableEffect(Unit) {
        // 注册监听
        val listener = object : SomeListener {
            override fun onEvent() {
                // 处理事件
            }
        }
        registerListener(listener)
        
        // 离开时清理
        onDispose {
            unregisterListener(listener)
        }
    }
}

我曾经在项目里用 DisposableEffect 管理蓝牙连接的生命周期,效果很好。离开页面时自动断开连接,不会造成资源泄漏。

核心区别:LaunchedEffect 用于「启动任务」,DisposableEffect 用于「注册/清理」。前者返回 Job,后者返回 onDispose。

四、Compose 中的 Navigation

Navigation 在 Compose 里用起来比 Fragment 那套舒服多了。没有 FragmentManager,没有事务提交,一切都是 Composable。

先定义路由:

sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Detail : Screen("detail/{id}") {
        fun createRoute(id: String) = "detail/$id"
    }
}

然后搭建 NavHost:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                onNavigateToDetail = { id ->
                    navController.navigate(
                        Screen.Detail.createRoute(id)
                    )
                }
            )
        }
        
        composable(
            route = Screen.Detail.route,
            arguments = listOf(
                navArgument("id") { type = NavType.StringType }
            )
        ) { backStackEntry ->
            val id = backStackEntry.arguments?.getString("id") ?: ""
            DetailScreen(id = id)
        }
    }
}

在 ViewModel 里怎么获取导航参数?我推荐用 SavedStateHandle:

class DetailViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    
    val itemId: String = savedStateHandle.get<String>("id") ?: ""
}
经验之谈:导航参数尽量用字符串传递,避免序列化复杂对象。如果必须传对象,可以考虑用共享 ViewModel 或者 Room 数据库。

五、实战整合:一个完整的例子

最后,我把今天讲的内容串起来,写一个完整的示例:

// ViewModel
class UserViewModel : ViewModel() {
    
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUser(userId: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true)
            try {
                val user = apiService.getUser(userId)
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    user = user
                )
            } catch (e: Exception) {
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    error = e.message
                )
            }
        }
    }
}

// Composable 页面
@Composable
fun UserScreen(
    userId: String,
    viewModel: UserViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    // 页面加载时触发数据请求
    LaunchedEffect(userId) {
        viewModel.loadUser(userId)
    }
    
    // 监听错误,显示 Snackbar
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(uiState.error) {
        uiState.error?.let {
            snackbarHostState.showSnackbar(it)
        }
    }
    
    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { padding ->
        when {
            uiState.isLoading -> {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            }
            uiState.user != null -> {
                UserProfile(user = uiState.user!!)
            }
        }
    }
}

这个例子涵盖了 ViewModel 获取、StateFlow 订阅、LaunchedEffect 副作用管理,以及 Navigation 参数传递。我在实际项目中就是这么用的,代码清晰,维护起来也方便。

好了,今天的内容就到这里。记住一句话:ViewModel 管状态,Compose 管渲染,两者通过 StateFlow 桥接,用副作用处理生命周期相关的事情。这套组合拳打好了,你的 Compose 项目架构就稳了。


公众号:蓝海资料掘金营,微信deep3321