告别 Modifier 地狱,Compose 样式系统要变天了

0 阅读6分钟

告别 Modifier 地狱,Compose 样式系统要变天了

又写了一坨 InteractionSource 模板代码?

一个悬停变色的按钮,要手动管理 collectIsPressedAsStatecollectIsHoveredAsState、再配上 animateColorAsState……代码量比业务逻辑还多。

Google 终于坐不住了——Compose Foundation 里悄悄加了一套全新的 Style API,用声明式的方式干掉这些样板代码。

compose_style_before_after.png

老方法到底有多痛

先看一个最常见的场景:一个支持悬停和按压变色的按钮。

传统写法是这样的:

@Composable
fun InteractiveButton(onClick: () -> Unit) {
    val interactionSource = remember {
        MutableInteractionSource()
    }
    val isPressed by interactionSource
        .collectIsPressedAsState()
    val isHovered by interactionSource
        .collectIsHoveredAsState()

    val bgColor by animateColorAsState(
        targetValue = when {
            isPressed -> Color.Red
            isHovered -> Color.Yellow
            else -> Color.Green
        }
    )

    Box(
        modifier = Modifier
            .clickable(
                interactionSource = interactionSource,
                indication = null
            ) { onClick() }
            .background(bgColor)
            .size(150.dp)
    )
}

数一下,光是为了实现"按下变红、悬停变黄",你需要:

  • 手动创建 InteractionSource
  • 分别收集每种交互状态
  • animateXxxAsState 管理动画
  • 在 Modifier 链里条件拼接

**两个状态就要写这么多,如果再加上 focus、selected、disabled 呢?**代码量指数级膨胀。

Style API:声明式的交互样式

同样的效果,Style API 只需要这样:

@Composable
fun InteractiveButton(onClick: () -> Unit) {
    ClickableStyleableBox(
        onClick = onClick,
        style = {
            background(Color.Green)
            size(150.dp)
            hovered {
                animate { background(Color.Yellow) }
            }
            pressed {
                animate { background(Color.Red) }
            }
        }
    )
}

代码量砍掉一大半,而且意图一目了然——默认绿色,悬停黄色,按下红色,动画自动处理。

不用手动创建 InteractionSource,不用自己管 animateColorAsState,不用在 Modifier 链里做条件判断。

compose_style_architecture.png

三大核心接口

Style API 的设计围绕三个核心接口展开。

Style——样式定义的入口

@ExperimentalFoundationStyleApi
fun interface Style {
    fun StyleScope.applyStyle()
}

Style 是一个函数式接口,可以用 lambda 创建。更强大的是,样式支持通过 then 组合:

val baseStyle = Style {
    background(Color.White)
    contentPadding(16.dp)
}

val borderedStyle = Style {
    borderWidth(1.dp)
    borderColor(Color.Gray)
}

// 组合使用,后者覆盖前者的同名属性
val combinedStyle = baseStyle then borderedStyle

注意这里和 Modifier 的关键区别:Modifier 是叠加的(两个 background 都会绘制),而 Style 是覆盖的(后者替换前者的同名属性)。这更符合 CSS 的直觉。

StyleScope——属性画板

StyleScope 提供了四大类属性设置方法:

类别属性
布局width() height() size() fillWidth() contentPadding() minWidth() maxWidth()
绘制background() borderWidth() borderColor() shape() dropShadow() innerShadow()
变换alpha() scale() rotation() translationX() translationY() clip() zIndex()
文字fontSize() fontWeight() fontFamily() contentColor() textAlign() lineHeight()

覆盖面已经相当全了,日常 UI 开发中最常用的属性基本都有。

StyleState——交互状态感知

sealed interface StyleState {
    val isEnabled: Boolean
    val isFocused: Boolean
    val isHovered: Boolean
    val isPressed: Boolean
    val isSelected: Boolean
    val isChecked: Boolean
}

六种交互状态开箱即用。配合 hovered {}pressed {}focused {} 这些语法糖,你可以在一个 Style 块里把所有状态的样式全部定义清楚。

compose_style_animation.png

动画系统:零成本的状态过渡

Style API 最让人惊喜的设计是动画系统。把属性变化包裹在 animate {} 里,系统就会自动在状态之间做插值动画:

style = {
    background(Color.Blue)
    size(150.dp)

    hovered {
        animate {
            background(Color.Yellow)
            scale(1.1f)
        }
    }

    pressed {
        // 可以自定义动画参数
        animate(tween(100)) {
            background(Color.Red)
            scale(0.95f)
        }
    }
}

内部通过 StyleAnimations 类管理所有动画实例:

internal class StyleAnimations {
    private val entries =
        mutableObjectListOf<Entry>()

    private class Entry(
        val key: Any,
        var style: ResolvedStyle,
        val toSpec: AnimationSpec<Float>,
        val fromSpec: AnimationSpec<Float>,
        val animatable: Animatable<...>,
        var state: State,
    )
}

它自动处理了几个以前需要手动搞定的难题:

  • 并发动画:多个属性同时变化,各自独立插值
  • 中断恢复:动画进行到一半切换状态,从当前值平滑过渡
  • 进入/退出:状态激活和退出可以使用不同的动画参数

以前要写一堆 LaunchedEffect + Animatable 才能实现的效果,现在一个 animate {} 搞定。

主题集成

StyleScope 继承了 CompositionLocalAccessorScope,意味着你可以直接在 Style 里读取主题值:

style = {
    val colors = LocalColors.current
    background(colors.surface)
    contentColor(colors.onSurface)
    shape(RoundedCornerShape(12.dp))

    pressed {
        background(colors.surfaceVariant)
    }
}

当主题切换(比如深色模式)时,Style 会自动感知变化并重新解析。这是通过底层的 ObserverModifierNode 实现的——它追踪 Style 内部读取了哪些 CompositionLocal,并在值变化时触发失效。

compose_style_performance.png

性能设计:三层优化

Style API 不只是语法糖,底层做了相当精细的性能优化。

双节点 Modifier 架构

传统做法中,background + padding + shadow + clickable 会在 Modifier 链上创建多个节点。Style API 只使用两个节点:

  • StyleOuterNode:处理布局约束、背景绘制、变换、阴影
  • StyleInnerNode:处理内容 padding(需要在 outer 之后应用)

StyleOuterNode 实现了多个接口:LayoutModifierNodeDrawModifierNodeCompositionLocalConsumerModifierNodeObserverModifierNode——一个节点干了以前四五个节点的活。

Bitset 标记的变更检测

ResolvedStyle 内部存储约 50 个属性,用位标记区分"未设置"和"设置为默认值":

internal class ResolvedStyle {
    private var layoutFlags: Int = 0
    private var drawFlags: Int = 0
    private var textFlags: Int = 0

    // 文字枚举打包到一个 Int 里
    private var textEnums: Int = 0
    // fontWeight | fontStyle | fontSynthesis
    // | textDecoration | textAlign ...
}

变更检测时只需比较几个 Int 值,而非逐一对比 50 个属性。

选择性失效

internal fun invalidate(previous: ResolvedStyle): Int {
    var result = 0
    if (layoutChanged(previous))
        result = result or LAYOUT_INVALIDATION
    if (drawChanged(previous))
        result = result or DRAW_INVALIDATION
    if (textChanged(previous))
        result = result or TEXT_INVALIDATION
    return result
}

如果只是颜色变了,只触发绘制阶段的失效,跳过布局和组合。这是 Compose 渲染管线的精髓——能少做一步就少做一步

实战:一个完整的卡片组件

把上面的知识点串起来,看一个贴近真实业务的例子:

@Composable
fun StyledCard(
    title: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    val cardStyle = Style {
        background(
            MaterialTheme.colorScheme.surface
        )
        shape(RoundedCornerShape(12.dp))
        contentPadding(16.dp)
        dropShadow(
            4.dp,
            Color.Black.copy(alpha = 0.1f)
        )

        hovered {
            animate(tween(200)) {
                dropShadow(
                    8.dp,
                    Color.Black.copy(alpha = 0.15f)
                )
                translationY((-2).dp)
            }
        }

        pressed {
            animate(tween(100)) {
                dropShadow(
                    2.dp,
                    Color.Black.copy(alpha = 0.05f)
                )
                scale(0.98f)
            }
        }

        focused {
            borderWidth(2.dp)
            borderColor(
                MaterialTheme.colorScheme.primary
            )
        }
    }

    ClickableStyleableBox(
        onClick = onClick,
        modifier = modifier,
        style = cardStyle
    ) {
        Text(title)
    }
}

悬停时阴影变大、微微上浮;按下时阴影缩小、轻微缩放;聚焦时显示主题色边框。这些效果放在传统 Modifier 里写,代码量至少翻三倍。

现在能用了吗

Style API 目前标记为 @ExperimentalFoundationStyleApi,还在积极开发中。从 Gerrit 的代码提交记录来看,API 的核心结构已经比较稳定,但具体的属性方法和行为可能还会调整。

几点使用建议:

  • 个人项目 / Demo 可以尝鲜,提前熟悉声明式样式的思维模式
  • 生产项目暂时观望,等 API 稳定后再大规模使用
  • 关注 compose-foundation 的更新日志,Style API 大概率会在未来几个版本逐步稳定

从设计理念上看,Style API 代表了 Compose 团队对"交互样式应该怎么写"这个问题的最新思考。它不是要取代 Modifier,而是在 Modifier 之上提供了一层更高级的抽象——专门解决状态驱动的样式变化这个高频场景。

写在最后

回顾 Compose 的演进路线,从最初的 Modifier.clickableInteractionSource,再到现在的 Style API,每一步都在让"交互样式"这件事变得更简单、更声明式。

Style API 的核心价值不只是少写代码,而是把交互状态和视觉表现的映射关系从命令式的"怎么做"变成了声明式的"是什么"

你平时写 Compose 交互样式最头疼的是什么?欢迎评论区聊聊。