小火苗变声器
65.51MB · 2026-04-16
在传统 Android View 体系里,一个 View 的"装饰"散落在很多地方:XML 属性(android:padding、android:background)、LayoutParams(marginStart、layout_weight)、代码设置(setOnClickListener),还有主题继承。这套机制最大的问题是隐式——你很难一眼看出 padding 究竟作用在 background 里面还是外面,margin 的作用方式也要死记硬背"box model"。
Compose 选择了另一条路:用一个统一的、显式的、可组合的装饰器对象来表达所有这些概念。这个对象就是 Modifier。官方文档对它的定义是:
简单说,Modifier 统一了 布局(size/padding/offset)、绘制(background/border/clip)、交互(clickable/draggable/focusable)、语义(semantics/testTag)、图形(graphicsLayer/alpha/rotate) 这五大类能力。它把过去分散在 View 体系各个角落的东西,全部收拢到一个表达式里。
@Composable
private fun Greeting(name: String) {
Column(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth()
.clickable { /* ... */ }
.background(Color.LightGray)
) {
Text(text = "Hello,")
Text(text = name)
}
}
这几行代码里发生的事情,在 View 体系里至少需要一个自定义 ViewGroup + 多个属性设置 + 一次 OnClickListener 绑定。更重要的是,上面这段代码的行为完全由 Modifier 的顺序决定,而不是由某个不可见的 box model 规则决定。
官方 API 指南里有一条硬性规则:
也就是说,一个 Composable 应该这样写:
@Composable
fun MyCard(
title: String,
modifier: Modifier = Modifier, // 必须有,默认值必须是 Modifier
) {
Column(modifier = modifier.padding(8.dp)) { // 传递给第一个发射 UI 的子节点
Text(title)
}
}
这个约定让调用方可以随意扩展组件的布局和行为,而不需要组件作者预先考虑所有场景。这是 Compose 组件具备强大可复用性的基础。
打开 androidx.compose.ui.Modifier 的源码,你会看到这样一段定义:
@Stable
interface Modifier {
fun <R> foldIn(initial: R, operation: (R, Element) -> R): R
fun <R> foldOut(initial: R, operation: (Element, R) -> R): R
fun any(predicate: (Element) -> Boolean): Boolean
fun all(predicate: (Element) -> Boolean): Boolean
infix fun then(other: Modifier): Modifier =
if (other === Modifier) this else CombinedModifier(this, other)
interface Element : Modifier { /* ... */ }
companion object : Modifier { /* 空 Modifier,链的起点 */ }
}
几个关键点:
Modifier 是一个接口,它的伴生对象 Modifier.Companion 同时也是空 Modifier(链表的空头)。这就是为什么你可以直接写 Modifier.padding(8.dp)——这里的 Modifier 实际上是 Modifier.Companion 这个对象实例。
每一个具体的 Modifier 功能都是一个 Modifier.Element。padding、background、clickable,背后都是一个 Element 类的实例。
then 是链接操作。它把两个 Modifier 拼成一个 CombinedModifier(内部是左右两个 Modifier 字段的树形结构)。但从使用者角度看,结果是一个有序、不可变的 Element 序列。
foldIn 和 foldOut 是遍历 API,Compose 运行时会沿着这个链做折叠遍历,把每个 Element "应用"到当前的 LayoutNode 上。
官方文档给出的权威定义是:
一条 Modifier 链 A.then(B).then(C) 可以想象成从外到内的三层盒子:A 在最外层,C 在最内层,被装饰的 Composable 内容在 C 的里面。
┌────────── A ──────────┐
│ ┌───────── B ─────┐ │
│ │ ┌── C ──┐ │ │
│ │ │Content│ │ │
│ │ └───────┘ │ │
│ └─────────────────┘ │
└───────────────────────┘
这个心智模型是理解后面所有"顺序问题"的基础。测量阶段约束从 A 向内流向 C 再流向 Content;放置阶段坐标从 Content 向外聚合到 A;绘制阶段则是 A 先画,然后 B,然后 C,最后 Content(这样 Content 会画在最上面)。
官方文档用了一个非常经典的例子:
// 版本 A:padding 在 clickable 之后
Column(
Modifier
.clickable(onClick = onClick)
.padding(16.dp)
.fillMaxWidth()
) { /* ... */ }
// 版本 B:padding 在 clickable 之前
Column(
Modifier
.padding(16.dp)
.clickable(onClick = onClick)
.fillMaxWidth()
) { /* ... */ }
版本 A:整个区域(包括 padding 部分)都响应点击。 版本 B:padding 区域不响应点击,只有内容部分响应。
为什么?回到我们的"从外到内"心智模型:
clickable 在外层盒子,它占据的空间是包含 padding 之后的大盒子——padding 是它的子盒子,所以点击 padding 区域也算落在 clickable 里。padding 在外层盒子,它内部才是 clickable 的小盒子。padding 贡献的那 16.dp 空白在 padding 盒子里、在 clickable 盒子外,自然不响应点击。官方文档对这种设计的解释非常到位:
没有 margin,只有 padding——因为 margin 和 padding 的差别本质上只是"在 background 前面还是后面"。在 Compose 里你只需要调整顺序:
// 等价于 "padding"
Modifier.background(Color.Red).padding(16.dp)
// 背景撑满外围大盒子,padding 把内容往里挤
// 等价于 "margin"
Modifier.padding(16.dp).background(Color.Red)
// padding 先把外围留空,背景只在留空之后的内盒子里
一个简单的顺序交换,就覆盖了 View 体系里 margin 和 padding 两套概念。这就是显式顺序的威力。
clip + shadow:
// shadow 外层,clip 内层:阴影按原矩形投,内容被裁剪成圆角,阴影仍是方的
Modifier.shadow(4.dp).clip(RoundedCornerShape(8.dp))
// clip 外层,shadow 内层:先裁剪,阴影按裁剪后的形状投射
Modifier.clip(RoundedCornerShape(8.dp)).shadow(4.dp)
size + padding:
// 总大小 100.dp,内容区 84.dp
Modifier.size(100.dp).padding(8.dp)
// 内容区 100.dp,总大小 116.dp
Modifier.padding(8.dp).size(100.dp)
offset + clickable:offset 只偏移绘制和点击区域的位置,不影响父布局给的约束。如果 offset 在 clickable 之前(外层),偏移后的区域可被点击;反过来则 offset 对可点击区域没影响(因为 clickable 已经"固定"了可点击的边界)。
写 Modifier 链的时候,每加一个 Modifier 都可以问自己一句:"我是想让它在当前盒子的外面再套一层,还是在当前盒子的里面做变化?" 这个问题一旦问出来,顺序就清晰了。
要真正理解 Modifier,必须理解 Compose 的布局协议。Compose 的布局系统是**单次测量(single-pass measurement)**的,整个过程可以概括为一句话:
Constraints 是一个描述"允许的最小/最大宽高"的值类:
class Constraints(
val minWidth: Int,
val maxWidth: Int,
val minHeight: Int,
val maxHeight: Int,
)
Modifier 链中的每一个 layout 类 Modifier(比如 padding、size、fillMaxWidth、wrapContentSize)本质上都是在做一件事:接收来自外层的 Constraints,变换后传给内层。然后把内层返回的尺寸变换后传给更外层。
Box(Modifier.size(200.dp)) {
Text(
"Hello",
modifier = Modifier
.padding(16.dp) // (外) 在内容外加 16.dp padding
.size(100.dp) // 要求 100x100
.background(Color.Red) // 在 100x100 区域画红色背景
)
}
假设屏幕足够大,Box 给 Text 的约束是 Constraints(0, 200, 0, 200)(Box 最大 200.dp)。Modifier 链从外到内依次处理:
padding(16.dp) 收到: (0..200, 0..200)
(0..168, 0..168)w×h 后,它返回 (w+32)×(h+32) 给外层size(100.dp) 收到: (0..168, 0..168)
size 会用 100.dp 去"填写"min/max:(100..100, 100..100),但会和入参约束取交集(100..100, 100..100) 给内层background 收到: (100..100, 100..100)
最终:padding 报出 132×132,外层的 Box 就把 Text 放在它的 200×200 里、居中占 132×132。
size 不是万能的:约束可能强制覆盖官方文档特别强调了这一点:
也就是说,当 size(300.dp) 遇到父约束的 maxWidth = 200.dp 时,size 会让步、让子节点变成 200.dp。如果你真的想"无视父约束,就是要 300.dp",用 requiredSize:
// Row 限制子节点最大 100.dp 高
Row(Modifier.size(width = 400.dp, height = 100.dp)) {
Image(
modifier = Modifier.requiredSize(150.dp) // 真的就是 150.dp,即使超出父约束
)
}
当子节点不尊重父约束时,布局系统会对父节点隐藏这一事实:父节点看到的尺寸仍然是被约束压缩后的值,然后把多出来的部分"居中"显示。如果你不想要这种默认居中行为,可以用 wrapContentSize 手动控制对齐。
offset 和 padding 的本质区别Text("A", Modifier.padding(start = 16.dp)) // 占据的宽度包含 16.dp
Text("B", Modifier.offset(x = 16.dp)) // 占据的宽度不变,只是视觉上右移了 16.dp
padding 是布局变化(影响测量尺寸),offset 是摆放位置变化(影响 placement,不影响测量尺寸)。这就是为什么 offset 不会挤压兄弟节点的空间,但 padding 会。
offset 的两个重载:一个关于性能的细节offset 有两个重载:
fun Modifier.offset(x: Dp = 0.dp, y: Dp = 0.dp): Modifier
fun Modifier.offset(offset: Density.() -> IntOffset): Modifier // lambda 版本
官方文档建议:频繁变化的 offset 值,用 lambda 版本。原因涉及 Compose 的三阶段——lambda 版本把状态读取推迟到了布局的 placement 步骤,从而避免了 composition 阶段的重新执行。我们在下一节细说。
在 Compose 里,有些 Modifier 只能用在特定的父布局下。比如:
Column {
Text("A", Modifier.weight(1f)) // OK,weight 是 ColumnScope 的扩展
}
Box {
Text("A", Modifier.weight(1f)) // 编译错误,Box 里没有 weight
}
这是因为 weight 是定义在 ColumnScope/RowScope 上的扩展函数,只有在这两个作用域里调用 lambda 时,this 才是相应的 Scope。同理:
| Modifier | 作用域 | 含义 |
|---|---|---|
matchParentSize() | BoxScope | 尺寸和父 Box 相同,但不影响 Box 本身的尺寸 |
align(Alignment) | BoxScope/ColumnScope/RowScope | 在父布局内对齐 |
weight(Float) | RowScope/ColumnScope | 按比例分配剩余空间 |
alignBy(...) | RowScope/ColumnScope | 按 alignment line 对齐 |
matchParentSize vs fillMaxSize:一个容易混淆的区别官方文档用了一个典型例子来区分它们:
@Composable
fun MatchParentSizeComposable() {
Box {
Spacer(
Modifier
.matchParentSize()
.background(Color.LightGray)
)
ArtistCard()
}
}
matchParentSize:Spacer 告诉 Box"我要和你一样大,但我的大小不算在你决定自己尺寸的依据里"。于是 Box 先根据 ArtistCard 决定自己的尺寸,然后 Spacer 撑满这个尺寸。结果:灰色背景恰好覆盖 ArtistCard 区域。fillMaxSize:Spacer 告诉 Box"我要填满你允许的最大空间"。Box 没有其他约束的话,就会撑到屏幕大小,然后 Spacer 也是屏幕大小——ArtistCard 瞬间被巨大的灰色背景淹没。这两种行为的差别,本质来自一个叫 ParentDataModifier 的东西。
官方文档里有一段很关键的话:
普通 Modifier 是装饰自己所在的节点;而 ParentDataModifier 是往节点上挂载一段数据,让父布局在测量时能读取这段数据、并根据它做出不同的布局决策。
比如 weight(1f) 的实现,会给这个 LayoutNode 挂上一个 RowColumnParentData(weight = 1f)。Row/Column 的测量策略会在第一轮测量后读取所有子节点的这个数据,计算出剩余空间并按 weight 比例分配。
这就是为什么作用域 Modifier 不能从一个 Scope "逃逸"到另一个 Scope 使用——因为只有对应的父布局的测量策略才会读那种 ParentData。你把 weight 塞到 Box 里,Box 根本不认识这个数据。
官方文档给了一个典型的坑:
Column(modifier = Modifier.fillMaxWidth()) {
val reusableItemModifier = Modifier.weight(1f)
Text1(modifier = reusableItemModifier) // 直接子节点,weight 生效
Box {
Text2(modifier = reusableItemModifier) // 不是 Column 的直接子节点,weight 无效!
}
}
weight 只对父布局的直接子节点有效。Text2 的直接父是 Box,所以挂在它身上的 weight 没人读。这个代码甚至不会报错——因为把一个 ColumnScope 内的变量传给另一个函数并不违反 Kotlin 类型系统。要避免这种错,原则是:只把作用域 Modifier 传给它的直接同作用域子节点。
要理解 Modifier 的性能特性,必须先理解 Compose 的三个阶段:
| 阶段 | 做什么 | 典型 API |
|---|---|---|
| Composition | 决定"显示什么 UI" | Composable 函数执行,构建/更新 LayoutNode 树 |
| Layout | 决定"UI 在哪里" | 包含 Measure(测量)+ Placement(放置)两个子阶段 |
| Drawing | 决定"怎么画到屏幕上" | Canvas 绘制 |
关键事实:当某个阶段依赖的状态变化时,Compose 只会重跑该阶段及其之后的阶段,不会无谓地重跑前面的阶段。举个例子:
Text 的文本内容 → 触发 Composition → Layout → DrawingModifier.offset { ... } 里读到的位置 → 只触发 Layout → Drawing(跳过 Composition)Modifier.drawBehind { ... } 里读到的颜色 → 只触发 Drawing这就解释了为什么很多 Modifier 有两个重载:一个直接接收值,一个接收 lambda。
// 值版本:状态读发生在 Composition 阶段
var offsetX by remember { mutableStateOf(0.dp) }
Text("Hello", Modifier.offset(x = offsetX))
// offsetX 变化 → 重跑 Composition → Layout → Drawing
// Lambda 版本:状态读发生在 Layout 的 Placement 步骤
var offsetX by remember { mutableStateOf(0f) }
Text("Hello", Modifier.offset { IntOffset(offsetX.roundToPx(), 0) })
// offsetX 变化 → 只重跑 Layout 的 placement 步骤 → Drawing
对一个每帧都变的动画值,第二种写法能省掉大量 Composition 开销。官方 performance 文档里明确提到:
类似的重载还有 padding(lambda)、graphicsLayer { ... }、drawBehind { ... }、drawWithContent { ... } 等。记住一条规律:凡是频繁变化的状态驱动的 Modifier 参数,优先用 lambda 版本。
这个模式叫 "defer reads as long as possible"(尽可能推迟读取)。它的一般形式是:把对 State 的 .value 读取从顶层的 Composable 函数里推进到更深、更晚的 lambda 里。lambda 的内容只在需要时才执行,所以读取也只发生在那时候。
这也解释了为什么官方建议你优先把动画状态作为 lambda 捕获值,而不是展开到 Modifier 的参数位置上。
Modifier 链看起来很轻,但其实每次 .padding()、.background() 调用都会分配一个新的 Element 对象。对一条 10 级长度的链,每次 Composable 函数执行就是 10 次小对象分配。通常情况这没问题,但在两种场景下会成为瓶颈:
场景一:频繁重组的 Composable
@Composable
fun LoadingWheelAnimation() {
val animatedState = animateFloatAsState(/*...*/)
LoadingWheel(
// 每帧动画都会重新分配这条链!
modifier = Modifier
.padding(12.dp)
.background(Color.Gray),
animatedState = animatedState
)
}
动画每帧都会导致 LoadingWheelAnimation 重组,于是这条 Modifier 链每帧都新建。改法是把它提取到 Composable 外:
private val LoadingWheelModifier = Modifier
.padding(12.dp)
.background(Color.Gray)
@Composable
fun LoadingWheelAnimation() {
val animatedState = animateFloatAsState(/*...*/)
LoadingWheel(
modifier = LoadingWheelModifier, // 零分配
animatedState = animatedState
)
}
这不仅省了分配,还能让 Compose 运行时的相等性比较更快——同一个对象引用直接命中,不需要逐个 Element 比较。
场景二:LazyList 里的大量同款 item
val reusableItemModifier = Modifier
.padding(bottom = 12.dp)
.size(216.dp)
.clip(CircleShape)
@Composable
private fun AuthorList(authors: List<Author>) {
LazyColumn {
items(authors) {
AsyncImage(
// ...
modifier = reusableItemModifier,
)
}
}
}
1000 个 item 如果每个都新建同一条链,就是 1000 × N 次分配。提取成常量后变成 1 次。
无作用域的 Modifier 可以提取到任意顶层,作用域的就只能提取到对应作用域里:
Column(/*...*/) {
// 这里 this 是 ColumnScope,可以写 align/weight
val reusableItemModifier = Modifier
.padding(bottom = 12.dp)
.align(Alignment.CenterHorizontally) // 只有 ColumnScope 有
.weight(1f)
Text1(modifier = reusableItemModifier)
Text2(modifier = reusableItemModifier)
}
thenval base = Modifier.fillMaxWidth().background(Color.Red)
// 加 clickable
base.clickable { /*...*/ }
// 或者把 base 追加到另一条链
otherModifier.then(base)
记住:a.then(b) 中 a 在外、b 在内,顺序依然敏感。
官方文档总结了三种创建自定义 Modifier 的方式,按复杂度递增:
这是官方最推崇的做法。连 Modifier.clip 自己都是这样实现的:
fun Modifier.clip(shape: Shape) = graphicsLayer(shape = shape, clip = true)
如果你经常重复某一段 Modifier 链,直接封装成一个扩展函数:
fun Modifier.myBackground(color: Color) =
padding(16.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
优点:简单、直接、和现有生态完美协作、零学习成本。能用这种方式解决的问题,就不要用更复杂的方式。
当你需要使用 Composable 生态(比如 animate*AsState 或 CompositionLocal)时,可以写一个带 @Composable 注解的 Modifier 扩展:
@Composable
fun Modifier.fade(enable: Boolean): Modifier {
val alpha by animateFloatAsState(if (enable) 0.5f else 1.0f)
return this then Modifier.graphicsLayer { this.alpha = alpha }
}
关键警告:不要断链。一定要返回 this then ...,不要返回 Modifier.something(),否则调用者之前加的 Modifier 会被丢掉。
这种方式有几个坑,官方文档强调得很明确:
@Composable
fun Modifier.myBackground(): Modifier {
val color = LocalContentColor.current
return this then Modifier.background(color.copy(alpha = 0.5f))
}
@Composable
fun MyScreen() {
CompositionLocalProvider(LocalContentColor provides Color.Green) {
// Modifier 在这里创建,此时 LocalContentColor 是 Green
val backgroundModifier = Modifier.myBackground()
CompositionLocalProvider(LocalContentColor provides Color.Red) {
// 你以为 Box 的背景是红色?不,是绿色!
Box(modifier = backgroundModifier)
}
}
}
原因是 myBackground 是个 Composable 函数,它在被调用时读取 LocalContentColor。一旦 Modifier 对象构造完毕,它就带着那时候的 Green 了——后续在哪儿使用都是 Green。
这往往不符合直觉。如果你希望 CompositionLocal 总是从使用位置解析,就需要方式三 Modifier.Node。
Compose 编译器会对稳定参数的 Composable 做 skipping 优化——如果参数没变就直接跳过函数体。但带返回值的 Composable 函数不能被跳过,因为谁也不知道它是不是依赖了别的状态、会返回不同的值。
这意味着你的 Modifier.fade(enable)(有返回值)每次重组都会被调用,哪怕 enable 没变。如果上层父 Composable 频繁重组,这个 Modifier 工厂每帧都在跑。
Composable 函数必须在 composition 里调用,所以 composable 工厂函数没法被提取到 Composable 外面:
val extractedModifier = Modifier.background(Color.Red) // 可以提升
@Composable
fun Modifier.composableModifier(): Modifier {
val color = LocalContentColor.current.copy(alpha = 0.5f)
return this then Modifier.background(color)
}
@Composable
fun MyComposable() {
val composedModifier = Modifier.composableModifier() // 只能在 Composable 里
}
Modifier.Node 是 Compose 1.4+ 推出的全新 Modifier 实现底座。它解决了 composed {}(老 API)的性能问题,并成为 Compose 自身所有内置 Modifier 的实现方式。官方文档直接点明:
一个基于 Modifier.Node 的自定义 Modifier 由三部分组成:
ModifierNodeElement:一个不可变的轻量数据类,承载参数、负责 create/update NodeModifier.Node:可变的长寿命对象,持有状态、实现行为我们用一个"画圆"的例子走一遍:
// 1. Modifier 工厂函数
fun Modifier.circle(color: Color) = this then CircleElement(color)
// 2. ModifierNodeElement(data class 自动生成 equals/hashCode)
private data class CircleElement(val color: Color)
: ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) {
node.color = color
}
}
// 3. Modifier.Node
private class CircleNode(var color: Color)
: DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() {
drawCircle(color)
}
}
理解这三层的边界非常重要:
| 层次 | 生命周期 | 可变性 | 职责 |
|---|---|---|---|
ModifierNodeElement | 每次重组都可能创建新实例 | 不可变(用 data class) | 描述"要创建什么样的 Node"和"如何更新已有 Node" |
Modifier.Node | 跨多次重组存活,甚至可复用 | 可变(var 字段) | 承载实际行为、持有可变状态 |
| 工厂函数 | 每次调用生成新 Element | — | 面向使用者的公共 API |
工作流程:
create() 创建一个 Node 实例,把 Node 挂到 LayoutNode 上。equals() 发现和旧的相等 → 什么都不做。equals() 返回 false → 调用旧 Element 的 update(node),让它修改已有的 Node(不是创建新的!)。onDetach() 被调用,Node 生命周期结束。onReset() 被调用,然后 onAttach(),Node 回到干净状态复用。这就是 Modifier.Node 高性能的秘密:重组时只产生一个廉价的 data class 实例,真正有状态的 Node 只创建一次并反复更新。相比之下,老的 composed {} 每次重组都重建整条链的一切。
equals 和 hashCode 这么关键因为 Compose 就靠 equals 比较决定"要不要更新":
// Element 必须正确实现 equals
private data class CircleElement(val color: Color)
: ModifierNodeElement<CircleNode>() { ... }
用 data class 是最省事的方式。但如果你有些字段不应该参与比较(比如 lambda 会让 equals 总是 false),或者为了二进制兼容性不能用 data class(库作者),就要手动实现:
class PaddingElement(
val start: Dp,
val top: Dp,
val end: Dp,
val bottom: Dp,
val rtlAware: Boolean,
) : ModifierNodeElement<PaddingNode>() {
override fun create() = PaddingNode(start, top, end, bottom, rtlAware)
override fun update(node: PaddingNode) {
node.start = start
node.top = top
node.end = end
node.bottom = bottom
node.rtlAware = rtlAware
}
override fun hashCode(): Int {
var result = start.hashCode()
result = 31 * result + top.hashCode()
result = 31 * result + end.hashCode()
result = 31 * result + bottom.hashCode()
result = 31 * result + rtlAware.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
val otherElement = other as? PaddingElement ?: return false
return start == otherElement.start &&
top == otherElement.top &&
end == otherElement.end &&
bottom == otherElement.bottom &&
rtlAware == otherElement.rtlAware
}
}
官方的 PaddingElement 就是这么写的(看起来啰嗦,但和 data class 效果一致)。
Modifier.Node 本身是个空抽象类,它通过组合多个能力接口来获得不同能力。你的 Node 实现哪些接口,就获得哪些回调:
| 接口 | 用途 | 关键方法 |
|---|---|---|
LayoutModifierNode | 自定义测量和放置 | MeasureScope.measure(measurable, constraints) |
DrawModifierNode | 自定义绘制 | ContentDrawScope.draw() |
SemanticsModifierNode | 提供语义信息(测试、无障碍) | SemanticsPropertyReceiver.applySemantics() |
PointerInputModifierNode | 接收触摸事件 | onPointerEvent(...) |
FocusTargetModifierNode | 参与焦点系统 | 各种焦点回调 |
ParentDataModifierNode | 给父布局挂载数据(weight 等) | modifyParentData(...) |
LayoutAwareModifierNode | 自己的测量/放置事件 | onRemeasured、onPlaced |
GlobalPositionAwareModifierNode | 自己的全局位置变化 | onGloballyPositioned(...) |
CompositionLocalConsumerModifierNode | 读取 CompositionLocal | currentValueOf(local) |
ObserverModifierNode | 观察 snapshot 状态变化 | onObservedReadsChanged() |
DelegatingNode | 把工作委托给其他 Node | delegate(node) |
TraversableNode | 在 Node 树中向上/下遍历 | traverseAncestors 等 |
一个 Node 可以同时实现多个,这让一个 Modifier 能同时影响布局、绘制、交互和语义——这就是为什么 clickable 能同时处理点击、添加 ripple、设置语义,所有这些功能组合在一个 Node 里。
如果 Modifier 没有任何参数,就不需要 data class(没东西可比较),Element 本身可以是 data object:
fun Modifier.fixedPadding() = this then FixedPaddingElement
data object FixedPaddingElement : ModifierNodeElement<FixedPaddingNode>() {
override fun create() = FixedPaddingNode()
override fun update(node: FixedPaddingNode) {} // 没东西可更新
}
class FixedPaddingNode : LayoutModifierNode, Modifier.Node() {
private val PADDING = 16.dp
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val paddingPx = PADDING.roundToPx()
val horizontal = paddingPx * 2
val vertical = paddingPx * 2
// 注意:先把约束缩小后传给子节点
val placeable = measurable.measure(
constraints.offset(-horizontal, -vertical)
)
// 报出的尺寸 = 子尺寸 + padding
val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
return layout(width, height) {
placeable.place(paddingPx, paddingPx)
}
}
}
这个例子很重要,因为它展示了一个标准的 LayoutModifierNode 模板:
measurable.measure(updatedConstraints) 测量子节点layout(width, height) { ... } 报出自己的尺寸,并在 lambda 里放置子节点和 Composable 工厂方式不同,Modifier.Node 读 CompositionLocal 是在使用位置解析的,这才是大多数人期望的行为:
class BackgroundColorConsumerNode :
Modifier.Node(),
DrawModifierNode,
CompositionLocalConsumerModifierNode { // ← 必须实现这个接口
override fun ContentDrawScope.draw() {
val currentColor = currentValueOf(LocalContentColor) // ← 从这里读
drawRect(color = currentColor)
drawContent()
}
}
关键点:Node 不会自动订阅 CompositionLocal 的变化。但像 ContentDrawScope、MeasureScope、SemanticsPropertyReceiver 这些 scope 内部本身就是 snapshot-observing 的——在它们里面读 state 会自动触发重绘、重测或重读语义。所以上面这个例子只要 LocalContentColor 变了,draw() 就会被重新调用。
如果你需要在这些 scope 之外响应状态变化(比如在 onAttach() 里或者其他回调里),就要用 ObserverModifierNode:
class ScrollableNode :
Modifier.Node(),
ObserverModifierNode,
CompositionLocalConsumerModifierNode {
val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity))
override fun onAttach() {
updateDefaultFlingBehavior()
// 建立观察:第一次运行 lambda,记录访问的 snapshot,变化时回调
observeReads { currentValueOf(LocalDensity) }
}
override fun onObservedReadsChanged() {
// 观察到变化时被调用
updateDefaultFlingBehavior()
}
private fun updateDefaultFlingBehavior() {
val density = currentValueOf(LocalDensity)
defaultFlingBehavior.flingDecay = splineBasedDecay(density)
}
}
observeReads { ... } 是 Compose 运行时提供的机制:它记录 lambda 里读了哪些 snapshot 对象,之后当任何一个变化,就调用 onObservedReadsChanged。
Modifier.Node 内置了 coroutineScope,它的生命周期和 Node 自身绑定(attach 时启动,detach 时取消)。这意味着你可以直接在 Node 里启动协程和跑动画:
class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
private lateinit var alpha: Animatable<Float, AnimationVector1D>
override fun ContentDrawScope.draw() {
drawCircle(color = color, alpha = alpha.value)
drawContent()
}
override fun onAttach() {
alpha = Animatable(1f)
coroutineScope.launch {
alpha.animateTo(
0f,
infiniteRepeatable(tween(1000), RepeatMode.Reverse)
)
}
}
}
注意这里的初始化放在 onAttach 而不是构造函数里——原因是 Node 可能被 Compose 运行时复用(比如在 LazyColumn 里滑动时)。复用时会调用 onReset 然后 onAttach,所以依赖 Node 生命周期的东西应该在 attach 时初始化。
DelegatingNode 让一个 Node 可以把工作委托给其他 Node:
class ClickableNode : DelegatingNode() {
val interactionData = InteractionData()
// delegate 返回的子 Node 会跟随父 Node 的生命周期
val focusableNode = delegate(FocusableNode(interactionData))
val indicationNode = delegate(IndicationNode(interactionData))
}
这种模式在 Compose 自身的实现里极其常见。比如 clickable 实际上是由多个子 Node 组合而成:一个处理指针事件,一个处理焦点,一个处理 ripple,一个挂语义——都通过 delegate() 组合到一个顶层 Node 里。这比把所有逻辑塞到一个巨大的 Node 里清晰得多,也方便在多个 Modifier 之间共享实现(比如 clickable 和 selectable 都能复用同一个焦点 Node)。
而且共享状态也变得很自然——多个被委托的 Node 通过构造函数参数共享同一个 interactionData。
默认情况下,每次 update() 被调用,Node 都会自动触发所有相关阶段的失效:测量、放置、绘制、语义……对于同时做多件事的复杂 Modifier,这会导致浪费。
比如一个 Modifier 同时控制颜色、尺寸和点击回调。如果只是颜色变了,没必要重测量;如果只是点击回调变了,什么都不用失效。这时可以关闭自动失效、手动控制:
class SampleInvalidatingNode(
var color: Color,
var size: IntSize,
var onClick: () -> Unit
) : DelegatingNode(), LayoutModifierNode, DrawModifierNode {
override val shouldAutoInvalidate: Boolean
get() = false // 关闭自动失效
private val clickableNode = delegate(ClickablePointerInputNode(onClick))
fun update(color: Color, size: IntSize, onClick: () -> Unit) {
if (this.color != color) {
this.color = color
invalidateDraw() // 颜色变 → 只重绘
}
if (this.size != size) {
this.size = size
invalidateMeasurement() // 尺寸变 → 重新测量
}
// onClick 变化不需要任何失效,直接更新即可
clickableNode.update(onClick)
}
override fun ContentDrawScope.draw() {
drawRect(color)
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val size = constraints.constrain(size)
val placeable = measurable.measure(constraints)
return layout(size.width, size.height) {
placeable.place(0, 0)
}
}
}
可用的失效 API:invalidateMeasurement()、invalidatePlacement()、invalidateDraw()、invalidateSemantics()。这对库作者非常重要,能显著降低复杂 Modifier 在高频场景下的开销。
在 Modifier.Node 出现之前(Compose 1.3 及以前),Composable 工厂 Modifier 的"正统"写法是 composed {}:
// 老做法,现在不推荐
fun Modifier.myFade(enable: Boolean): Modifier = composed {
val alpha by animateFloatAsState(if (enable) 0.5f else 1f)
Modifier.graphicsLayer { this.alpha = alpha }
}
composed {} 的设计意图是让 Modifier 内部也能使用 Composable 能力(remember、state、CompositionLocal),但它有几个严重问题:
composed {} 在应用时会为每个使用位置开一个新的 composition scope。大量使用时这个开销非常可观。composed 创建的 Modifier 哪怕参数完全相同也被视为不相等(因为内部有一个唯一的 key),导致它们无法被 Compose 运行时复用比较优化。equals 那样的早停机制。结果就是:用了 composed {} 的 Modifier,即使参数没变,Compose 也会每次重组都重跑它的 lambda,产生大量无意义的工作。在 Google IO / Android Dev Summit 的演讲 "Compose Modifiers Deep Dive" 里,团队展示了用 composed 实现 clickable 相比用 Modifier.Node 实现,性能差了一个数量级。
官方文档给出了明确结论:
CompositionLocalConsumerModifierNode(方式三)Modifier.Node + coroutineScope + AnimatableModifier.Nodemodifier: Modifier = Modifier 参数,并传给第一个发射 UI 的子节点offset、padding、graphicsLayer 等)this then Modifier.xxx(),不要直接返回 Modifier.xxx()composed {} 创建新 Modifier——它已经不推荐了优先级从高到低:
fun Modifier.xxx() = this.padding().background()...)Modifier.Nodedata class(或 data object 无参时),自动拿到正确的 equals/hashCodeupdate() 里只更新 Node 的字段,不要创建新 NodeonAttach(),不要放构造函数(为了支持 Node 复用)CompositionLocalConsumerModifierNode + currentValueOfObserverModifierNode + observeReadsDelegatingNode + delegate()shouldAutoInvalidate,手动调 invalidateXxxModifier 表面看是一套装饰器 DSL,但它在底层其实承担了 Compose UI 组件能力扩展的全部职责。从最上层的工厂函数,到中间的不可变 Element,再到底层可变的 Node,整个体系既让调用者能写出简洁的链式表达,又让 Compose 运行时能做出最激进的优化——这是一次相当漂亮的 API 设计。
当你遇到一个不熟悉的 Modifier,记得沿着这条路思考:
读官方源码最好的切入点是 androidx.compose.ui.Modifier.kt 和 androidx.compose.ui.node.ModifierNodeElement.kt,然后挑一个简单的内置 Modifier(推荐 padding 或 background)顺着读一遍实现——你会发现所有概念突然就串起来了。