Compose 页面跳转中的状态保留:ViewModel + StateHolder 架构模式
问题背景
在使用 Jetpack Compose 进行页面导航开发时,我们经常遇到一个经典问题:页面跳转后,返回时 UI 控件的内部状态会丢失。
例如,一个计数器页面,用户手动增加了计数,然后跳转到其他页面,再返回时,计数器回到了初始值。这是因为 Composable 的重组机制导致的——页面重建时,所有状态都被重新初始化。
传统解决方案
在 Android 传统开发中,我们习惯使用 savedInstanceState 来保存状态。但在 Compose 中,这仍然面临挑战:
- 需要手动序列化和反序列化状态
- 对于复杂的业务逻辑对象,处理起来很繁琐
- 代码侵入性较强
我们的解决方案:ViewModel + StateHolder 架构
核心思想
将状态管理和业务逻辑从 UI 层分离出来,使用:
- ViewModel:管理生命周期,持有 StateHolder
- StateHolder:纯 Kotlin 类,封装业务逻辑和状态
这样,即使页面跳转,ViewModel 仍然存活,StateHolder 中的状态得以保留。
代码实现
1. StateHolder - 业务逻辑层
首先创建一个纯 Kotlin 类来管理业务逻辑和状态:
package com.ehi.cmp_demo.page.router_test.logic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* State Holder for Counter logic.
* Pure Kotlin class, unaware of Android Lifecycle.
*/
class CounterManager(private val scope: CoroutineScope) {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
private val _isAutoIncrementing = MutableStateFlow(false)
val isAutoIncrementing: StateFlow<Boolean> = _isAutoIncrementing.asStateFlow()
private var timerJob: Job? = null
fun increment() {
_count.value++
}
fun decrement() {
_count.value--
}
fun toggleAutoIncrement() {
if (_isAutoIncrementing.value) {
stopAutoIncrement()
} else {
startAutoIncrement()
}
}
private fun startAutoIncrement() {
if (_isAutoIncrementing.value) return
_isAutoIncrementing.value = true
timerJob?.cancel()
timerJob = scope.launch {
while (isActive && _isAutoIncrementing.value) {
delay(1000)
increment()
}
}
}
private fun stopAutoIncrement() {
_isAutoIncrementing.value = false
timerJob?.cancel()
timerJob = null
}
}
关键点:
- 纯 Kotlin 类,不依赖 Android API
- 使用
StateFlow暴露响应式状态 - 业务逻辑清晰,易于测试
2. ViewModel - 生命周期管理层
创建 ViewModel 来持有 StateHolder:
package com.ehi.cmp_demo.page.router_test
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ehi.cmp_demo.page.router_test.logic.CounterManager
class RouterPageAViewModel : ViewModel() {
// Composition: ViewModel holds the StateHolder
val counterManager = CounterManager(viewModelScope)
}
关键点:
- ViewModel 使用
viewModelScope传递给 StateHolder - 简洁明了,只负责持有和管理 StateHolder
- 避免膨胀成 God ViewModel
3. UI 层 - 状态展示
在 Composable 中使用:
@Composable
fun RouterPageA(navController: NavController) {
// 引入 ViewModel (官方架构推荐)
// 此时 ViewModel 的生命周期跟随 NavigationBackStackEntry,
// 即便跳转到 B 页面,A 页面在后台栈中,ViewModel 依然存活,数据(CountManager)被保留。
val viewModel: RouterPageAViewModel = androidx.lifecycle.viewmodel.compose.viewModel { RouterPageAViewModel() }
// 观察 StateHolder (CounterManager) 中的状态
val count by viewModel.counterManager.count.collectAsState()
val isAutoIncrementing by viewModel.counterManager.isAutoIncrementing.collectAsState()
CommonPageLayout(
title = "计数器页面A",
navController = navController
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
"当前页面使用 ViewModel + StateHolder (CounterManager) 架构。\n" +
"跳转到 B 页面后,ViewModel 存活,自动计数器仍在后台运行。\n" +
"返回 A 页面,状态无缝衔接,无需重建。"
)
// 显示当前计数
Text(
text = "当前计数",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "$count",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
// 加减按钮行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = { viewModel.counterManager.decrement() },
modifier = Modifier.width(100.dp)
) {
Text(text = "-1", style = MaterialTheme.typography.titleMedium)
}
Spacer(modifier = Modifier.width(24.dp))
Button(
onClick = { viewModel.counterManager.increment() },
modifier = Modifier.width(100.dp)
) {
Text(text = "+1", style = MaterialTheme.typography.titleMedium)
}
}
Spacer(modifier = Modifier.height(32.dp))
// 跳转到B页面按钮
Button(
onClick = {
navController.navigate(CmpRouter.RouterPageB.router())
},
modifier = Modifier.fillMaxWidth(0.6f)
) {
Text(
text = "跳转到B页面",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(32.dp))
// 自动计数切换按钮
Button(
onClick = { viewModel.counterManager.toggleAutoIncrement() },
modifier = Modifier.fillMaxWidth(0.6f)
) {
Text(
text = if (isAutoIncrementing) "停止自动计数" else "开启自动计数",
style = MaterialTheme.typography.titleMedium
)
}
}
}
}
关键点:
- 使用
viewModel()函数获取 ViewModel 实例 - 通过
collectAsState()观察 StateFlow 状态 - UI 层简洁,只负责展示和事件处理
架构优势
1. 状态自动保留
ViewModel 的生命周期跟随 NavigationBackStackEntry,即使页面跳转,ViewModel 仍然存活,StateHolder 中的状态得以保留。返回页面时,状态无缝衔接。
2. 避免 God ViewModel
很多开发者担心 ViewModel 膨胀成巨型类,我们的设计通过以下方式解决这个问题:
- 单一职责:ViewModel 只负责管理 StateHolder 的生命周期
- 组合而非继承:通过组合多个 StateHolder 来管理不同领域的业务逻辑
- StateHolder 独立:每个 StateHolder 封装特定领域的逻辑,清晰独立
// 示例:一个 ViewModel 可以持有多个 StateHolder
class ComplexPageViewModel : ViewModel() {
val counterManager = CounterManager(viewModelScope)
val userManager = UserManager(viewModelScope)
val networkManager = NetworkManager(viewModelScope)
// 如果有跨 StateHolder 的组合逻辑,可以在这里提供
fun loadData() {
networkManager.fetch()
userManager.updateProfile()
}
}
3. 高度可测试
- StateHolder 是纯 Kotlin 类,可以轻松进行单元测试,无需 Android 环境
- ViewModel 和 UI 解耦,可以独立测试业务逻辑
// CounterManager 的单元测试示例
class CounterManagerTest {
@Test
fun `increment should increase count`() {
val scope = TestScope()
val counterManager = CounterManager(scope)
assertEquals(0, counterManager.count.value)
counterManager.increment()
assertEquals(1, counterManager.count.value)
}
}
4. 清晰的代码结构
| 层级 | 职责 | 示例 |
|---|---|---|
| UI 层 | 展示界面,响应用户操作 | RouterPageA.kt |
| ViewModel 层 | 管理生命周期,持有 StateHolder | RouterPageAViewModel.kt |
| StateHolder 层 | 封装业务逻辑和状态 | CounterManager.kt |
适用场景
这种架构模式特别适合:
✅ 中大型应用 - 有复杂业务逻辑的场景
✅ 需要状态保留 - 页面跳转后需要保持状态的页面
✅ 需要高度可测试 - 对代码质量要求较高的项目
✅ 跨平台项目 - StateHolder 是纯 Kotlin 代码,可复用
最佳实践建议
- StateHolder 命名规范:使用
xxxManager或xxxState后缀 - 状态使用 StateFlow:符合 Compose 响应式编程范式
- ViewModel 保持轻量:只负责持有 StateHolder,避免膨胀
- 合理的粒度:StateHolder 应该是合理的业务边界,不要过小也不要过大
总结
ViewModel + StateHolder 架构模式是解决 Compose 页面跳转状态丢失问题的优雅方案。它不仅解决了状态保留问题,还提供了清晰的代码结构、高度的可测试性,以及避免 God ViewModel 的有效方式。
这种模式符合 Google 官方的架构指导原则,同时在实际项目中具有很高的实用价值。如果你的应用需要处理复杂的页面状态,强烈推荐尝试这种架构!