无状态组件 vs 有状态组件:职责分离、可复用性、测试性

1. 原理:状态的所有权决定组件的形态

在 Jetpack Compose 中,组件(Composable)根据是否直接持有可观察状态(remember + mutableStateOf)分为两类:

维度有状态组件无状态组件
状态拥有者组件内部通过 remember 创建并管理状态状态由调用方传入,组件仅读取并触发回调
职责边界同时承担 UI 渲染与状态管理只负责 UI 渲染,状态管理由上层处理
可复用性低:状态逻辑固定,难以适应不同场景高:组件行为完全由参数驱动,可灵活替换
测试性差:需要模拟组合环境,状态变化难以隔离强:纯 UI 函数,输入输出明确,可独立验证
重构方向应当逐步将状态提升至调用方目标形态,推荐在可复用组件中采用
核心原则:可复用组件应尽可能写成无状态组件。仅在组件作为屏幕级或叶节点,且状态不对外暴露时才保留有状态写法。

2. 实现流程:从有状态重构为无状态

下面以计数器组件为例,展示重构步骤。

2.1 有状态组件(不推荐复用)

@Composable
fun StatelessCounter(modifier: Modifier = Modifier) {
    var count by remember { mutableIntStateOf(0) }
    Column(modifier = modifier) {
        Text("计数: $count")
        Button(onClick = { count++ }) {
            Text("增加")
        }
    }
}

2.2 提升状态后的无状态组件

@Composable
fun StatelessCounter(
    count: Int,
    onIncrement: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier) {
        Text("计数: $count")
        Button(onClick = onIncrement) {
            Text("增加")
        }
    }
}

2.3 调用方持有状态

@Composable
fun CounterScreen() {
    var count by remember { mutableIntStateOf(0) }
    StatelessCounter(
        count = count,
        onIncrement = { count++ }
    )
}

核心变更:状态向上移动一层,组件签名变为 (Int, () -> Unit),不再依赖内部 remember。

3. 代码对比:购物车应用示例

模式组件签名可复用场景测试方式
有状态CheckedItem(name: String)仅用于当前列表需要在组合树中渲染后再断言
无状态CheckedItem(name: String, isChecked: Boolean, onToggle: () -> Unit)任意列表、设置页、选择器直接传参调用,验证 UI 与回调

4. 架构对比:内部状态 vs 提升状态

下面 SVG 展示两种组件的架构差异:左侧有状态组件将状态锁定在内部,右侧无状态组件将状态托管给调用方,实现职责分离。
有状态组件 vs 无状态组件架构对比 有状态组件 内部持有状态 remember + mutableStateOf UI 渲染 + 状态更新 调用方无法干预状态 问题:不可复用 每次使用都是独立副本 无法从外部控制初始值 无状态组件 状态由参数传入 通过回调通知变更 纯 UI 函数 调用方控制状态 优势:高度可复用 任意调用方共享 可单独单元测试 状态提升 调用方持有状态 remember + mutableStateOf

5. 调试与验证策略

  1. 使用 Layout Inspector:查看组件树中每个 Composable 的状态宿主。有状态组件会在内部显示 remember 创建的 MutableState 实例;无状态组件则不显示私有状态。
  2. 日志追踪:在回调中打印参数变化,确认状态流向是否正确。
  3. 单元测试验证:无状态组件可直接调用并断言 UI 输出:
@Test
fun statelessCounter_displaysCount() {
    composeTestRule.setContent {
        StatelessCounter(count = 5, onIncrement = {})
    }
    composeTestRule.onNodeWithText("计数: 5").assertExists()
}

6. 常见错误与解决方案

错误模式表现原因修正
状态被意外保留组件跳转后重新进入,状态未重置有状态组件内部 remember 未与作用域绑定将状态提升至调用方,或改用 rememberSaveable
内部状态导致不可复用两个调用点需要不同初始值有状态组件硬编码初始值重构为无状态,通过参数注入初始值
回调被多次触发组件内同时持有状态和副作用状态变更与回调耦合分离副作用到 LaunchedEffect,组件只负责渲染

7. 性能与稳定性优化

  • 优先使用稳定类型:无状态组件的参数应标记为 @Stable 或使用基本类型,避免不必要的重组。
  • 避免在无状态组件内使用 remember:若确实需要内部缓存,应将其抽象为单独的状态 holder 并提升。
  • 使用 derivedStateOf 减少计算:当状态依赖多个来源时,在调用方计算后再传入,而非在组件内重复计算。
  • 限制 recomposition 范围:无状态组件接收的 lambda 应尽可能稳定,可使用 remember 包裹回调引用。
注意: 无状态组件并非万能。对于屏幕级容器或叶节点,如果状态不会被外部复用,可以保留有状态写法以简化代码。但若组件将被抽取到公共模块或跨团队使用,则必须采用无状态设计。