卷 VI · 交付· CH 22· 变体 vs 参数 深度 22/28

组件不是控件,是一次决定

你建一个组件,不是为了少写几行代码,是为了把一个决定固定下来。这一章讲这个决定该有多大——以及它什么时候开始失控。

2ⁿ 的诅咒样子 vs 身份插槽而不是参数

组件封的是决定,不是代码

先摆正一件事。你写 OrderCard 的时候,复用代码只是副产品。真正发生的是:

你把「订单卡片长什么样」这个问题,从「每次都要回答」变成了「回答一次」

这正是第 1 章那条主线在组件层面的样子。一个组件的价值和它关掉了多少个临场决定成正比。

推论:一个把所有决定都变成参数的组件,等于什么都没关掉。它只是把 110 个随手定的数搬了个家。

2ⁿ 的诅咒

组件失控的过程永远是一样的:一个布尔值一个布尔值地加,每次都觉得「就一个布尔值而已」。

把开关一个个打开,看那两个数:

3 个布尔参数8 种组合,你会测 4 种
4 个16 种,你会测 5 种
6 个64 种,你会测 7 种

红色的格子是永远不会有人打开的组合——但它们能被构造出来。也就是说,它们是 IllegalStateException 和「诶这个界面怎么这样」的产地。

最阴险的一点

这些组合不会在你加参数的那天出问题。它们会在半年后,某个人为了赶需求传了 isLoading = true, hasIcon = true, isCompact = true,然后图标和转圈重叠在一起的时候出问题。

而那时候没人记得这三个参数为什么会互斥。

那条线:样子还是身份

怎么判断该做参数还是该拆成变体?有一条很实用的线:

这个参数改变的是「长什么样」,还是「是什么」?
  • 改的是样子(大小、有没有图标、单行还是两行)→ 参数
  • 改的是身份(这是个危险操作、这是个次要动作)→ 变体

理由:身份不同的东西,将来会朝不同方向演化。

今天「危险按钮」只是颜色不同;明天它要加二次确认;后天它要在长按时才生效。如果它只是 Button(isDanger = true),这些需求会全部挤进那一个组件,然后你就有了第七个布尔参数。

随手定
Button(
  text: String,
  isLoading: Boolean = false,
  isDanger: Boolean = false,
  isCompact: Boolean = false,
  hasIcon: Boolean = false,
  icon: ImageVector? = null,
  onClick: () -> Unit
)

32 种组合。hasIcon = trueicon = null 会怎样?没人知道。

有出处
// 身份 → 三个变体
PrimaryButton(text, onClick)
SecondaryButton(text, onClick)
DangerButton(text, onClick)

// 样子 → 参数,且用可空
// 类型消灭掉不合法的组合
PrimaryButton(
  text: String,
  icon: ImageVector? = null,   // 有就画
  loading: Boolean = false,
  onClick: () -> Unit
)

icon: ImageVector? 一个参数干掉了两个——不可能出现「说有图标但没给图标」

用插槽,不用参数

Compose 里有一个比参数更好的东西:插槽(把 @Composable lambda 当参数)。

// ✗ 参数式:每来一个新需求就加一个参数
fun ListItem(
    title: String,
    subtitle: String? = null,
    iconRes: Int? = null,
    badgeCount: Int? = null,
    showChevron: Boolean = false,
    trailingSwitch: Boolean = false,
    switchChecked: Boolean = false,          // 又一个耦合参数…
    onSwitchChange: ((Boolean) -> Unit)? = null
)

// ✓ 插槽式:结构固定,内容开放
@Composable
fun ListItem(
    headline: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    supporting: (@Composable () -> Unit)? = null,
    leading: (@Composable () -> Unit)? = null,
    trailing: (@Composable () -> Unit)? = null
)

// 用的时候,尾部想放什么放什么 —— 组件不需要知道
ListItem(
    headline = { Text("推送通知") },
    supporting = { Text("接收订单状态变化") },
    leading = { Icon(Icons.Default.Notifications, null) },
    trailing = { Switch(checked, onCheckedChange) }
)

插槽的关键好处:组件固定的是「结构」(哪儿放什么、间距多少、对齐怎么算),开放的是「内容」。

而结构正是你想统一的东西——第 5 到 12 章那些间距、对齐、字号规则,全部封在这一层里。至于尾部到底是个开关还是个箭头,那本来就不该由组件决定。

这也是 Material 3 自己的 API 风格:ScaffoldTopAppBarListItemCard 全是插槽式的。

什么时候该建组件

反方向也要说,因为过早抽象的代价一样大:

  • 只用过一次。「以后会用到」不算理由。等到第二次用到时再抽,那时你才知道哪些该变、哪些不该变。
  • 两处用法看起来像但意图不同。「订单卡片」和「商品卡片」现在长得一样,不代表它们该是同一个组件——它们会各自演化,而合并会让每次演化都变成加参数。
  • 为了避免重复三行代码。三行重复的代价,远小于一个抽象错的组件。
一个判断标准

如果你无法用一句话说清「这个组件是什么」,它就不该存在。

「订单卡片」——清楚。
「通用信息展示卡片」——不清楚,它会长成怪物。

说不清的时候,通常意味着你在合并两个不同的东西。拆开,各自命名,让它们独立演化。等它们真的长得一样了(而且稳定了半年),再考虑合并。

本章关掉的自由度本章 3 条 · 累计 64 / 78
  • 「参数还是变体」——改样子做参数,改身份做变体。身份不同的东西会朝不同方向演化。
  • 「怎么加新需求」——优先加插槽,不加布尔参数。用可空类型消灭不合法组合。
  • 「什么时候抽组件」——第二次用到时。说不清它是什么就不建。