Compose 页面跳转中的状态保留:ViewModel + StateHolder 架构模式

4 阅读1分钟

Compose 页面跳转中的状态保留:ViewModel + StateHolder 架构模式

问题背景

在使用 Jetpack Compose 进行页面导航开发时,我们经常遇到一个经典问题:页面跳转后,返回时 UI 控件的内部状态会丢失

例如,一个计数器页面,用户手动增加了计数,然后跳转到其他页面,再返回时,计数器回到了初始值。这是因为 Composable 的重组机制导致的——页面重建时,所有状态都被重新初始化。

传统解决方案

在 Android 传统开发中,我们习惯使用 savedInstanceState 来保存状态。但在 Compose 中,这仍然面临挑战:

  • 需要手动序列化和反序列化状态
  • 对于复杂的业务逻辑对象,处理起来很繁琐
  • 代码侵入性较强

我们的解决方案:ViewModel + StateHolder 架构

核心思想

状态管理业务逻辑从 UI 层分离出来,使用:

  1. ViewModel:管理生命周期,持有 StateHolder
  2. 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 层管理生命周期,持有 StateHolderRouterPageAViewModel.kt
StateHolder 层封装业务逻辑和状态CounterManager.kt

适用场景

这种架构模式特别适合:

中大型应用 - 有复杂业务逻辑的场景
需要状态保留 - 页面跳转后需要保持状态的页面
需要高度可测试 - 对代码质量要求较高的项目
跨平台项目 - StateHolder 是纯 Kotlin 代码,可复用

最佳实践建议

  1. StateHolder 命名规范:使用 xxxManagerxxxState 后缀
  2. 状态使用 StateFlow:符合 Compose 响应式编程范式
  3. ViewModel 保持轻量:只负责持有 StateHolder,避免膨胀
  4. 合理的粒度:StateHolder 应该是合理的业务边界,不要过小也不要过大

总结

ViewModel + StateHolder 架构模式是解决 Compose 页面跳转状态丢失问题的优雅方案。它不仅解决了状态保留问题,还提供了清晰的代码结构、高度的可测试性,以及避免 God ViewModel 的有效方式。

这种模式符合 Google 官方的架构指导原则,同时在实际项目中具有很高的实用价值。如果你的应用需要处理复杂的页面状态,强烈推荐尝试这种架构!