mcpField
C
compose-modifier-and-layout-style

compose-modifier-and-layout-style

github.com/chrisbanes开发1.1k1.0k 次安装1 天前更新
代码仓库

关于 compose-modifier-and-layout-style

面向 Kotlin Compose 开发者的编码技能,指导 modifier 的归属与写法、条件布局处理及测量阶段读取。

主要功能

  1. 01在根布局暴露并应用调用方的 modifier
  2. 02避免在根布局固定父级布局选择
  3. 03以流畅表达式构建 modifier 链
  4. 04将较长的 modifier 链分行书写
  5. 05提取符合条件的单一条件布局
  6. 06在布局阶段读取测量相关状态

适用场景

  1. 01检查 composable 是否支持 modifier
  2. 02重构逐步重新赋值的 modifier
  3. 03简化条件布局代码
  4. 04匹配兄弟项尺寸并避免组合阶段读取

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/chrisbanes/skills --skill compose-modifier-and-layout-style

安装后在 Claude Code 中输入/compose-modifier-and-layout-style确认已加载。

详细介绍

Compose 修饰符和布局样式

核心原则

发出布局的可组合项是由父级放置的叶节点——位置、大小、对齐方式和内边距都由父级决定。可组合项负责结构(内部是什么),而不是放置(放在哪里)。由此得出三条规则:

  • 声明一个 modifier 参数,并将其应用到根节点,这样父级才能真正完成自己的工作。在可组合项的根节点上硬编码 .fillMaxWidth(),会剥夺所有未来调用方做出这一决定的权利。
  • 将修饰符链构造成一个流畅表达式,而不是分步重新赋值。两种写法编译后的结果相同,但修饰符链能在一遍阅读中直接表达意图。
  • 条件渲染应放在条件适用的位置。 如果某个布局调用的内容只有一个 if,那么它的存在只是为了容纳这个条件——应将 if 移到外部。

这些规则相辅相成,因为同一个可组合项通常会触发全部三条:你要声明它的参数(规则 1),调用方要构造修饰符链来放置它(规则 2),而函数体中有一个你可能会想用布局包起来的条件(规则 3)。

何时使用此技能

  • 你正在编写一个会调用布局的 @Composable fun(Box、Column、Row、LazyColumn、Text、Image、Surface、Card、Layout { … },以及 compose.foundation.layout 或 compose.material* 中的任何内容),而它的签名没有 modifier 参数,或者有该参数但未将其应用到根节点,或者在根节点上硬编码了 .fillMaxWidth()/.padding(...)。
  • 你看到 var m = Modifier,后面跟着 m = m.padding(…)、m = m.background(…) 等。
  • 某个 modifier = … 参数在同一行中链式调用了三次或更多次。
  • 某个可组合项的函数体是 Layout { if (cond) Content() }——只有一个条件,没有其他内容。

1. 声明 modifier 参数

对于发出布局的可组合项,建议在必需参数之后、内容/lambda 参数之前添加一个默认值为 Modifier 的 modifier 参数。名称必须是 modifier——不能是 mod、m 或 wrapperModifier。

// ❌ BAD — no modifier param; caller can't position, size, or constrain this
@Composable
fun HomeScreenHeader(title: String, subtitle: String) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        verticalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Text(title, style = MaterialTheme.typography.headlineLarge)
        Text(subtitle, style = MaterialTheme.typography.bodyMedium)
    }
}
// ✅ GOOD — parent decides width and padding; the composable describes structure only
@Composable
fun HomeScreenHeader(
    title: String,
    subtitle: String,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Text(title, style = MaterialTheme.typography.headlineLarge)
        Text(subtitle, style = MaterialTheme.typography.bodyMedium)
    }
}

现在,调用方只需在主页中写一次 HomeScreenHeader(title, subtitle, Modifier.fillMaxWidth().padding(horizontal = 16.dp))——只有主页知道布局实际需要这些修饰符。

2. 将调用方的修饰符应用到根节点,并放在最前面

当根布局还需要其他参数(对齐方式、排列方式、可组合项固有的内边距)时,调用方提供的修饰符仍应放在根布局的 modifier 参数中,然后接上可组合项的本地修饰符链。

// ❌ BAD — modifier accepted but never applied
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Image(painter = rememberAsyncImagePainter(url), contentDescription = null)
}

// ❌ BAD — applied to a child, not the root; caller's size/position changes don't take
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Box {
        Image(
            painter = rememberAsyncImagePainter(url),
            contentDescription = null,
            modifier = modifier,
        )
    }
}

// ❌ BAD — caller's modifier ends up last, so the composable's own size wins
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Image(
        painter = rememberAsyncImagePainter(url),
        contentDescription = null,
        modifier = Modifier
            .clip(CircleShape)
            .size(48.dp)
            .then(modifier),
    )
}
// ✅ GOOD — caller's modifier first, then the composable's intrinsic chain
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Image(
        painter = rememberAsyncImagePainter(url),
        contentDescription = null,
        modifier = modifier
            .clip(CircleShape)
            .size(48.dp),
    )
}

顺序很重要:在修饰符链中,靠前的部分是外层包装。调用方的修饰符应位于最外层,这样调用方提供的 .size(...) 或 .padding(...) 就能覆盖可组合项的默认设置,而不会被默认设置覆盖。

3. 不要在根节点上硬编码布局决策

如果可组合项的根节点带有 .fillMaxWidth()、.padding(horizontal = 16.dp)、.height(56.dp) 等修饰符,调用方就无法不使用这些修饰符。这些布局选择应由父级负责。

// ❌ BAD — every caller now fills max width whether they want to or not
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Button(
        onClick = onClick,
        modifier = modifier.fillMaxWidth(),   // ← hardcoded
    ) { Text(text) }
}

// ✅ GOOD — caller adds .fillMaxWidth() if (and only if) they want it
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Button(onClick = onClick, modifier = modifier) { Text(text) }
}

例外情况是那些属于可组合项身份特征的修饰符——它们定义了 Avatar 作为头像的特征(.clip(CircleShape) 和默认的 .size(48.dp)),而不是决定它在屏幕上的位置。判断方法:你能否想象调用方希望使用一个不带此修饰符的版本?如果可以,就将它移到外部。如果不可以(不带 .clip(CircleShape) 的头像就不再是头像),就保留它——但要将它放在修饰符链中调用方修饰符之后(见 §2)。

4. 将修饰符链构建为一个流畅的表达式

重组会重新运行可组合函数体——每个修饰符表达式都会重新求值。逐步重新赋值 var modifier = 看似合理,却会破坏视觉上的连贯性、引发更多修改,而且毫无必要。

// ❌ BAD — visual flow broken into reassignments; `var` invites more mutation
@Composable
fun Demo() {
    var m = Modifier
    m = m.padding(16.dp)
    m = m.fillMaxSize()
    Box(m) { }
}

// ❌ ALSO BAD — same shape, dressed up with .then()
@Composable
fun Demo() {
    var m = Modifier
    m = m.padding(16.dp)
    m = m.then(Modifier.fillMaxSize())
    Box(m) { }
}
// ✅ GOOD
@Composable
fun Demo() {
    val m = Modifier
        .padding(16.dp)
        .fillMaxSize()
    Box(m) { }
}

用 val,而不是 var:链构建完成后,不应该再重新绑定它。正是重新赋值的写法让 var 显得必要;链式写法则不需要它。

短链在调用处内联即可

只有一两个调用时,直接在调用处内联构建修饰符即可。“提取为 val”这条规则只有在链足够长、值得命名,或同一条链会重复使用时才有意义。

// ✅ GOOD — short chain inline
Box(modifier = Modifier.fillMaxWidth()) { … }
Box(modifier = Modifier.padding(8.dp).background(Color.Red)) { … }
条件片段也留在链上

通常人们会因为“修饰符取决于某个条件”而使用 var。其实不必——将条件直接插入链中:

// ✅ GOOD — conditional inside the chain, still one expression
Box(
    modifier = Modifier
        .fillMaxWidth()
        .then(if (selected) Modifier.background(Color.Red) else Modifier),
)

Modifier(空修饰符)是 .then 的单位元——当某个分支不贡献任何内容时,它能让你保留链式结构。

5. 调用处的多行格式

当 modifier 参数的链包含 三个或更多 调用时,使用多行格式,每行一个调用。缩进链,使带点的调用与值对齐。

// ❌ BAD — three+ calls on one line; hard to scan
Box(
    modifier = modifier.fillMaxSize().padding(16.dp).weight(1f),
)

// ✅ GOOD
Box(
    modifier = modifier
        .fillMaxSize()
        .padding(16.dp)
        .weight(1f),
)

一两个调用保持在单行——判断标准是调用次数,而不是字符数。如果某个调用的参数特别长,那是另一个问题(提取为 val,或缩短参数)。

这条规则 仅 适用于名为 modifier 的参数。这里不涵盖其他流畅式参数。

6. 将单个条件判断移出布局

当布局中 唯一 的内容是一个 if 时,该布局的存在只是为了“容纳”这个条件判断。将 if 移到外面——只有在有内容可显示时,布局才会存在。

// ❌ BAD — Column always emitted; only its inner content is conditional
@Composable
fun A() {
    Column {
        if (showHeader) {
            Text("Title")
            Text("Subtitle")
        }
    }
}

// ✅ GOOD — Column only exists when it has content
@Composable
fun A() {
    if (showHeader) {
        Column {
            Text("Title")
            Text("Subtitle")
        }
    }
}

这样做并非为了提升性能——运行时处理这两种写法都没问题——而是因为第二种写法 读起来 是“标题区域,有条件地显示”。第一种则读起来像“始终存在的列,可能有内容,也可能没有”。

例外情况(及原因)
  • 布局承载了不受条件控制的视觉语义。 如果布局调用传入了 modifier、contentAlignment、horizontalArrangement 或 verticalAlignment,这些参数描述的是 容器,而不是内容。将条件判断移出,要么会丢失这些参数(容器会随内容一起消失),要么会导致它们在两个分支中重复。保留原写法。

    // ✅ KEEP AS-IS — modifier on the container is doing visible work
    @Composable
    fun A(modifier: Modifier = Modifier) {
        Box(modifier = modifier) {
            if (something) {
                Text("Bleh1")
                Text("Bleh2")
            }
        }
    }
    
  • if 还有同级内容。 布局中还有其他内容;if 只是其中一部分。将其移出要么会把同级内容也移出去(从而改变布局),要么会留下不同的结构。保留原写法。

  • if … else … 的两个分支都包含可组合函数。 两个分支都会执行;没有可移出的内容;布局 就是 共享的容器。

    // ✅ KEEP AS-IS — both branches contribute to the layout
    Box {
        if (something) Text("Hint") else innerTextField()
    }
    

7. 在测量阶段添加约束修饰

当可组合函数 A 捕获某个尺寸,而可组合函数 B 必须与其匹配时,不要在 B 的可组合函数体中读取捕获的尺寸(Modifier.height(state.dp))。这样一来,每当测量状态变化时,B 都会触发重组。

在 A 上的布局回调中捕获尺寸;在 B 上使用 Modifier.layout 应用尺寸,这样只会使布局失效:

fun Modifier.decorateMeasureConstraints(
    decorate: (Constraints) -> Constraints,
): Modifier = layout { measurable, incoming ->
    val constraints = decorate(incoming).constrain(incoming)
    val placeable = measurable.measure(constraints)
    layout(placeable.width, placeable.height) {
        placeable.placeRelative(0, 0)
    }
}
// Hoisted at the common parent of both rows:
//   var anchorHeightPx by remember { mutableIntStateOf(0) }

// Measured row — write state only from onSizeChanged
RowAnchor(Modifier.onSizeChanged { size -> if (size.height != anchorHeightPx) anchorHeightPx = size.height })

// Sibling rows — read anchorHeightPx only inside layout
RowSibling(
    Modifier.decorateMeasureConstraints { incoming ->
        if (anchorHeightPx > 0) {
            // Clamp to incoming bounds so the constraint never exceeds the parent's max.
            incoming.copy(minHeight = anchorHeightPx, maxHeight = anchorHeightPx)
        } else {
            incoming
        }
    },
)

只有当 anchorHeightPx 为 0 时,才使用组合时的回退值(固定高度)。完整的跨行模式请参阅 compose-state-deferred-reads。

快速参考

症状诊断修复
函数体中有 Column/Box/Text 的 @Composable fun Foo(text: String)没有 modifier 参数(§1)添加 modifier: Modifier = Modifier;传给根布局
声明了 modifier: Modifier = Modifier,但从未引用忽略了参数(§2)将其应用到根布局的 modifier 参数
modifier 传给了子项,而不是根布局目标错误(§2)将其移到最外层布局的 modifier 参数
modifier = Modifier.x().y().then(modifier)调用方的 modifier 放在最后(§2)调整顺序:modifier = modifier.x().y()
通用组件上使用了 modifier = modifier.fillMaxWidth().padding(...)布局被硬编码(§3)移除硬编码调用;让调用方自行添加
文件中的同级可组合项也没有 modifier扩散式反模式修复这个;有机会时再修复同级项
mod: Modifier = Modifier 或 wrapperModifier: Modifier = Modifier名称错误(§1)将名称准确改为 modifier
var m = Modifier 后跟 m = m.xxx() 重新赋值分步构造 modifier(§4)在 val 上使用一条流畅调用链,或直接内联构建
var m = Modifier; m = m.then(Modifier.xxx())通过 .then 实现的相同模式(§4)在调用链中将 .then(Modifier.x()) 简化为 .x()
modifier 分支需要条件判断想用 var(§4)在调用链中使用 .then(if (c) Modifier.x() else Modifier)
modifier = modifier.a().b().c() 写在一行长调用链未格式化(§5)每行一个调用,在值下方缩进
没有其他内容且没有布局调整参数的 Layout { if (cond) X() }提升条件判断(§6)将 if 移到布局外部
Box(modifier = …) { if (cond) X() }布局承载了语义——保留(§6 例外)保持原样
Box { if (cond) X() else Y() }两个分支都有贡献——保留(§6 例外)保持原样
同级惰性行从另一行的测量结果读取 height(state)组合时的尺寸耦合(§7)在已测量的行上捕获;在同级项上通过 decorateMeasureConstraints 应用

不适用的情况

  • 不发出布局的可组合项。 @Composable fun computeColor(): Color 或 @Composable @ReadOnlyComposable 访问器不会发出布局节点。无需 modifier 参数(而且 @ReadOnlyComposable 也无法接受该参数——请参阅 compose-state-authoring)。
  • @Preview 函数。 预览是一次性入口;框架调用它们时没有调用方。modifier 参数只会成为未使用的累赘。
  • 仅用于测试的可组合项,位于 *Test 源码中,且唯一的调用方是 composeTestRule.setContent { … }。理由与预览相同。
  • 将 modifier 作为首个必需参数的内部布局基元(极少见;通常是框架级代码)。规则是“首个可选参数”;有些私有工具确实会将 modifier 作为首个必需参数。
  • 根据动画状态命令式组装的 modifier。 通过追加来自 Animatable 或其他过程式来源的值构建的 modifier,可能确实需要中间变量。调用链本身并非目标,可读性才是。如果调用链反而让表达式更难读,就写成命令式形式。
  • 存储 modifier 的插槽 API,例如数据类或构建器(很少见;通常是框架级代码)。流畅调用链的思路针对的是用户侧构造。
  • 固定特定重组形态的测试可组合项——两种写法通常都可以;不要仅仅为了风格重构测试可组合项。

不要仅仅因为“这个可组合项是内部的”“只在一个地方使用”“我不想让签名多出这个参数”或“我们已经知道所有调用方”而跳过声明端规则(§1–§3)。这些恰恰是各种自我开脱的理由,最终会导致有人想调用某个可组合项两次时,它却只能单次使用。

评审中的警示信号

想法实际情况
“这个可组合项仅供内部使用——添加 modifier 是过度设计”这个参数只有 8 个字符,还带有默认值。这不是过度设计,而是惯例。跳过它才是过度设计——这是一个违背所有 Compose API 惯例的特例决定。
“它只在一个地方使用,所以我知道布局要求”“只在一个地方使用”说的是现在。添加参数的成本只付一次;第二个使用位置出现后重构调用方的成本,则要每个调用方都付一次。
“这个文件里的同级可组合项也没有 modifier,所以我是在保持风格一致”沿用反模式不叫保持风格一致。修正这一处,并顺手修正同级项。
“父级在这里总是需要 .fillMaxWidth()”那就由父级传入 .fillMaxWidth()。这个可组合项不应该替它尚未遇到的调用方做决定。
“等有人需要时我再添加”你就是那个需要的人。你现在就需要它(为了遵循惯例)。下一个调用方也不会添加它——他们会绕过它的缺失。
“这是个很小的可组合项,modifier 参数只会添乱”声明处的参数只有 8 个字符;不需要它的调用点则一个字符也不用写。所谓“添乱”只是想象出来的。
“我添加了 modifier,但根节点上仍保留 .fillMaxWidth(),这样主屏就不必处理它了”那么非主屏调用方就无法取消它。把 .fillMaxWidth() 移到调用方。
“我需要用 var 保存 modifier,因为修饰链取决于条件”条件片段可以写成 .then(if (c) Modifier.x() else Modifier),仍然属于同一条修饰链。不需要 var。
“只有三行,还不够拆成多行”三个修饰调用就是拆成多行的界限。少于三个,写在一行;达到三个或更多,拆成多行。
“Column 什么也没做,不过为了对称我还是保留它”那就把条件上移,并将 Column 放在条件成立的分支里——既保留对称性,也不会始终创建容器。
“我把 if 放在里面,因为布局已经存在了”“已经存在”正是问题所在。条件为假时,就不应该创建该布局。

相关内容

  • compose-slot-api-pattern — 声明可复用可组合项公共 API 的另一半:使用 @Composable () -> Unit 插槽承载可变内容。可复用组件既要接收 modifier 参数,也要接收插槽——调用方既负责放置位置,也负责放置的内容。
  • compose-state-deferred-reads — 跨阶段回写以及延迟读取测量结果。

评论

支持 Markdown