SuperAPI AI 工具集

compose-state-holder-ui-split

前端与建站

查看项目

更新于 10-16 05:33

技能介绍

用途

该技能用于指导 Jetpack Compose 屏幕级 composable 的拆分:把「状态持有者接线」与「UI 渲染」分离。状态持有者 composable 负责与 ViewModel、组件、Flow、导航和副作用交互;UI composable 只接收纯不可变 UI 状态加回调,并描述布局。这样可让屏幕更易预览、测试,也更容易在 Android、Desktop、TV 和 KMP/CMP 目标间复用。

何时使用

当 Compose 屏幕出现以下情况时使用:

  • 直接接收 ViewModel、component、controller、navigator、repository 或 service。
  • 在同一个负责大部分 UI 布局的函数中收集应用/业务状态或副作用。
  • 把整个状态持有者传给子 composable,而不是传显式状态和回调。
  • 因为需要依赖注入、导航、生命周期或假服务而难以预览。
  • UI 测试必须构造完整应用栈才能验证一个简单布局分支。

模式

使用一个小的公开状态持有者 composable:

@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 放在一个完全不了解状态持有者的普通 composable 中:

@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,
    )
}

私有 content 函数可用于拆分布局:

@Composable
private fun ProfileContent(
    name: String,
    isSaving: Boolean,
    canSave: Boolean,
    onNameChange: (String) -> Unit,
    onSaveClick: () -> Unit,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // Layout only.
}

经验规则

关注点 状态持有者 composable UI composable
收集 ViewModel/组件状态 是 否
收集一次性副作用 是,或一个很小的同级副作用处理器 通常否
持有依赖注入对象 是 否
接收不可变 UI 状态 通常透传 是
接收用户事件 lambda 接线 调用
拥有布局、modifier、语义、测试标签 否/极少 是
拥有 UI 本地状态,如滚动、焦点、文本输入、动画、交互 有时初始化 是
便于预览/截图 不一定 是

「UI composable 中不收集」规则针对的是应用/业务状态和副作用流。普通 UI composable 仍可拥有 UI 本地的框架状态:rememberScrollState、rememberLazyListState、FocusRequester、焦点状态、动画状态、TextFieldState、MutableInteractionSource.collectIsPressedAsState(),以及类似属于被渲染组件的行为。

如果这些 UI 本地状态增长为涉及多个相关字段和操作的协调行为,可参考 compose-expert(状态提升部分)判断是否应改为在组合中 remember 的普通状态持有者类。

传什么

传递最小可用的 UI 契约:

  • 当屏幕有真实状态时,优先使用专门的 UiState/State 对象,而不是许多不相关的原始值。
  • 优先使用显式 lambda(onRetryClick、onItemSelected),而不是传整个 component。
  • 如果领域模型会迫使业务规则进入 UI,就不要把它放进 UI composable;当 UI 需要不同形状时映射为 UI 模型。
  • 导航保持为回调。UI composable 表达「用户点击了返回」,而不是「导航到路由 X」。
  • 帧率或 UI 本地值变化时不应强制整棵树重组:按 compose-state-deferred-reads 优先使用 provider lambda 和延迟读取。

副作用

compose-side-effects 涵盖副作用 API(LaunchedEffect、DisposableEffect、SideEffect)、key、清理和 rememberUpdatedState。

在状态持有者附近处理副作用,因为那里同时具备副作用来源和命令式目标:

@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),而不是把 component 推入 UI composable。

常见错误

错误 危害 修复
fun Screen(viewModel: MyViewModel) 包含所有布局 没有 Android 生命周期和 DI 时难以预览/测试 增加接收状态和回调的普通 UI 重载
子 composable 接收 component 依赖沿树泄漏 只传子组件需要的状态/回调
UI composable 发起导航 UI 与应用路由耦合 暴露 onBackClick、onItemClick 等
UI composable 收集应用/业务流 收集生命周期隐藏在布局中 在状态持有者附近收集并向下传值
无理由把 UI 本地状态提升到状态持有者 状态持有者开始拥有布局机制 当滚动/焦点/动画/文本框交互状态只是 UI 行为时,保留在 UI composable 中
每个微小 composable 都加状态持有者重载 仪式感过重 在屏幕/区块边界拆分,而不是每个 Row

何时不适用

  • 已经接收普通值和回调的微小一次性 composable。
  • 设计系统原语,如 Button、Card 或 ListItem;它们应暴露 slot 和 modifier,而不是状态持有者。
  • 状态持有者 composable 只转发一个原始值且没有增加隔离的情况。

相关

  • compose-expert — Amethyst 的共享 UI 模式,包括 UI 元素状态的状态提升和普通状态持有者类。
  • compose-side-effects — Compose 中的副作用 key 和清理。
  • compose-state-deferred-reads — 跨边界传递的帧率/UI 本地值的延迟读取。
  • kotlin-multiplatform — 共享 UI 遇到平台特定叶子时的平台服务、原生视图和 expect/interface 边界。