compose-state-holder-ui-split

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Compose: state holder/UI split

Compose:状态持有者与UI分离

Core principle

核心原则

Separate state-holder wiring from UI rendering. The state-holder composable talks to ViewModels, components, flows, navigation, and side effects. The UI composable takes plain immutable UI state plus callbacks and describes layout.
This keeps screens previewable, testable, and easier to reuse across Android, Desktop, TV, and KMP/CMP targets.
将状态持有者的连接逻辑与UI渲染分离。状态持有者可组合函数负责与ViewModels、组件、数据流、导航及副作用交互;UI可组合函数接收纯不可变UI状态和回调,并描述布局。
这种方式可让屏幕具备可预览性、可测试性,且更易于在Android、桌面端、电视端以及KMP/CMP目标平台间复用。

When to use this skill

适用场景

Use this when a Compose screen:
  • Takes a ViewModel, component, controller, navigator, repository, or service directly.
  • Collects app/business state or side effects in the same function that lays out most UI.
  • Passes a whole state holder into child composables instead of explicit state and callbacks.
  • Is hard to preview because it needs dependency injection, navigation, lifecycle, or fake services.
  • Has UI tests that must construct a full app stack to verify a simple layout branch.
当Compose屏幕满足以下任一条件时,可使用本技巧:
  • 直接接收ViewModel、组件、控制器、导航器、仓库或服务。
  • 在同一个函数中既收集应用/业务状态或副作用,又负责大部分UI布局。
  • 将完整的状态持有者传递给子可组合函数,而非显式传递状态和回调。
  • 因依赖注入、导航、生命周期或模拟服务需求,难以进行预览。
  • UI测试必须构建完整应用栈才能验证简单的布局分支。

The pattern

实现模式

Use a small public state-holder composable:
kotlin
@Composable
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
    val state by component.state.collectAsStateWithLifecycle()

    ProfileScreen(
        state = state,
        onNameChange = component::onNameChange,
        onSaveClick = component::save,
        onBackClick = component::back,
        modifier = modifier,
    )
}
Then put UI in a plain composable that knows nothing about the state holder:
kotlin
@Composable
fun ProfileScreen(
    state: ProfileUiState,
    onNameChange: (String) -> Unit,
    onSaveClick: () -> Unit,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    ProfileContent(
        name = state.name,
        isSaving = state.isSaving,
        canSave = state.canSave,
        onNameChange = onNameChange,
        onSaveClick = onSaveClick,
        onBackClick = onBackClick,
        modifier = modifier,
    )
}
Private content functions can break up layout:
kotlin
@Composable
private fun ProfileContent(
    name: String,
    isSaving: Boolean,
    canSave: Boolean,
    onNameChange: (String) -> Unit,
    onSaveClick: () -> Unit,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // Layout only.
}
使用一个小型的公开状态持有者可组合函数:
kotlin
@Composable
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
    val state by component.state.collectAsStateWithLifecycle()

    ProfileScreen(
        state = state,
        onNameChange = component::onNameChange,
        onSaveClick = component::save,
        onBackClick = component::back,
        modifier = modifier,
    )
}
然后将UI逻辑放在一个不感知状态持有者的纯可组合函数中:
kotlin
@Composable
fun ProfileScreen(
    state: ProfileUiState,
    onNameChange: (String) -> Unit,
    onSaveClick: () -> Unit,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    ProfileContent(
        name = state.name,
        isSaving = state.isSaving,
        canSave = state.canSave,
        onNameChange = onNameChange,
        onSaveClick = onSaveClick,
        onBackClick = onBackClick,
        modifier = modifier,
    )
}
私有内容函数可拆分布局:
kotlin
@Composable
private fun ProfileContent(
    name: String,
    isSaving: Boolean,
    canSave: Boolean,
    onNameChange: (String) -> Unit,
    onSaveClick: () -> Unit,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // 仅包含布局代码
}

Rules of thumb

经验准则

ConcernState-holder composableUI composable
Collect ViewModel/component stateYesNo
Collect one-shot effectsYes, or a tiny sibling effect handlerUsually no
Hold dependency-injected objectsYesNo
Accept immutable UI stateUsually passes it throughYes
Accept lambdas for user eventsWires themCalls them
Own layout, modifiers, semantics, test tagsNo/minimalYes
Own UI-local state like scroll, focus, text input, animation, interactionSometimes seeds itYes
Preview/screenshot friendlyNot necessarilyYes
The "no collection in UI composables" rule is about app/business state and side-effect streams. Plain UI composables can still own UI-local framework state:
rememberScrollState
,
rememberLazyListState
,
FocusRequester
, focus state, animation state,
TextFieldState
,
MutableInteractionSource.collectIsPressedAsState()
, and similar behavior that belongs to the rendered widget.
If that UI-local state grows into coordinated behavior with multiple related fields and operations, use
compose-state-hoisting
to decide whether it should become a plain state holder class remembered in composition.
关注点状态持有者可组合函数UI可组合函数
收集ViewModel/组件状态
收集一次性副作用是,或通过小型关联副作用处理器处理通常否
持有依赖注入对象
接收不可变UI状态通常负责传递
接收用户事件的Lambda负责连接逻辑负责调用
拥有布局、修饰符、语义、测试标签否/极少
拥有UI本地状态(如滚动、焦点、文本输入、动画、交互)有时负责初始化
支持预览/截图不一定
“UI可组合函数不进行状态收集”的规则针对的是应用/业务状态和副作用流。纯UI可组合函数仍可持有UI本地框架状态:
rememberScrollState
rememberLazyListState
FocusRequester
、焦点状态、动画状态、
TextFieldState
MutableInteractionSource.collectIsPressedAsState()
等属于渲染组件的行为状态。
若UI本地状态演变为包含多个关联字段和操作的协同行为,请参考
compose-state-hoisting
来决定是否应将其转换为在组合中被remember的纯状态持有者类。

What to pass

传递内容规范

Pass the smallest useful UI contract:
  • Prefer a dedicated
    UiState
    /
    State
    object over many unrelated primitives when the screen has real state.
  • Prefer explicit lambdas (
    onRetryClick
    ,
    onItemSelected
    ) over passing a whole component.
  • Keep domain models out of the UI composable if they force business rules into UI. Map to UI models when the UI needs a different shape.
  • Keep navigation as callbacks. The UI composable says "user clicked back", not "navigate to route X".
  • Frame-rate or UI-local values that should not force whole-tree recomposition when they change: prefer provider lambdas and deferred reads per
    compose-state-deferred-reads
    .
传递最小且有效的UI契约:
  • 当屏幕存在实际状态时,优先使用专用的
    UiState
    /
    State
    对象,而非多个无关的基本类型。
  • 优先使用显式Lambda(如
    onRetryClick
    onItemSelected
    ),而非传递完整组件。
  • 若领域模型会迫使业务规则进入UI层,请避免在UI可组合函数中使用领域模型;当UI需要不同的数据结构时,映射为UI模型。
  • 将导航逻辑作为回调处理。UI可组合函数只需告知“用户点击了返回”,而非“导航到路由X”。
  • 对于不应触发整棵树重组的帧率级或UI本地值:请参考
    compose-state-deferred-reads
    ,优先使用提供者Lambda和延迟读取。

Side effects

副作用处理

compose-side-effects
covers effect APIs (
LaunchedEffect
,
DisposableEffect
,
SideEffect
), keys, cleanup, and
rememberUpdatedState
.
Handle effects near the state holder, where the effect source and imperative target are both available:
kotlin
@Composable
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
    val state by component.state.collectAsStateWithLifecycle()

    LaunchedEffect(component) {
        component.effects.collect { effect ->
            when (effect) {
                ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
            }
        }
    }

    ProfileScreen(state = state, onSaveClick = component::save)
}
If effect handling grows, extract
ProfileEffects(component, snackbarHostState)
rather than pushing the component into the UI composable.
compose-side-effects
介绍了副作用API(
LaunchedEffect
DisposableEffect
SideEffect
)、键、清理操作以及
rememberUpdatedState
在状态持有者附近处理副作用,确保副作用源和命令式目标均可用:
kotlin
@Composable
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
    val state by component.state.collectAsStateWithLifecycle()

    LaunchedEffect(component) {
        component.effects.collect { effect ->
            when (effect) {
                ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
            }
        }
    }

    ProfileScreen(state = state, onSaveClick = component::save)
}
若副作用处理逻辑变得复杂,请提取为
ProfileEffects(component, snackbarHostState)
,而非将组件传递到UI可组合函数中。

Common mistakes

常见错误

MistakeWhy it hurtsFix
fun Screen(viewModel: MyViewModel)
contains all layout
Hard to preview/test without Android lifecycle and DIAdd a plain UI overload that takes
state
and callbacks
Child composables take
component
Dependencies leak through the treePass only the state/callbacks that child needs
UI composable launches navigationUI becomes coupled to app routingExpose
onBackClick
,
onItemClick
, etc.
UI composable collects app/business flowsCollection lifecycle is hidden in layoutCollect near the state holder and pass values down
UI-local state is hoisted into the state holder for no reasonState holder starts owning layout mechanicsKeep scroll/focus/animation/text-field interaction state in the UI composable when it is only UI behavior
Every tiny composable gets a state-holder overloadToo much ceremonySplit at screen/section boundaries, not every
Row
错误行为危害修复方案
fun Screen(viewModel: MyViewModel)
包含所有布局代码
难以在没有Android生命周期和依赖注入的情况下进行预览/测试添加一个接收
state
和回调的纯UI重载函数
子可组合函数接收
component
依赖关系渗透到整个组件树仅传递子组件所需的状态/回调
UI可组合函数发起导航UI与应用路由产生耦合暴露
onBackClick
onItemClick
等回调
UI可组合函数收集应用/业务数据流收集生命周期隐藏在布局中在状态持有者附近收集数据并向下传递值
无理由地将UI本地状态提升到状态持有者中状态持有者开始管控布局机制当仅涉及UI行为时,将滚动/焦点/动画/文本字段交互状态保留在UI可组合函数中
每个小型可组合函数都添加状态持有者重载冗余代码过多在屏幕/模块边界处拆分,而非每个
Row
都拆分

When NOT to apply

不适用场景

  • Tiny one-off composables that already take plain values and callbacks.
  • Design-system primitives such as
    Button
    ,
    Card
    , or
    ListItem
    ; those should expose slots and modifiers, not state holders.
  • Cases where the state-holder composable would only forward one primitive and add no isolation.
  • 已接收纯值和回调的小型一次性可组合函数。
  • 设计系统原语(如
    Button
    Card
    ListItem
    );这些组件应暴露插槽和修饰符,而非状态持有者。
  • 状态持有者可组合函数仅转发单个基本类型,无法提供隔离价值的场景。

Related

相关链接

  • compose-ui-testing-patterns
    — testing plain state-driven UI composables without the full app graph.
  • compose-state-hoisting
    — deciding where UI element state and UI logic should live, including plain state holder classes.
  • kotlin-multiplatform-expect-actual
    — platform services, native views, and expect/interface boundaries when shared UI meets platform-specific leaves.
  • compose-ui-testing-patterns
    — 在无需完整应用图的情况下测试纯状态驱动的UI可组合函数。
  • compose-state-hoisting
    — 确定UI元素状态和UI逻辑的存放位置,包括纯状态持有者类。
  • kotlin-multiplatform-expect-actual
    — 当共享UI遇到平台特定逻辑时的平台服务、原生视图以及expect/interface边界处理。