扶我起来,Compose Styles API 进阶

本篇文章是上篇文章的延续。

如果已经看完了上一篇文章,那么对于 Styles 的一些基本用法,你已经完全掌握了,甚至可以说 Styles 几乎所有用法你都会了。

当然,我知道你们肯定不会满足于这些,我相信很多人在学习 Styles 的时候,第一想法就是...Styles 与 Modifier 有什么区别?

那,我们先说下这个事情。

在我写这篇文章的时候,Compose 的 foundation 版本又做了更新,目前的最新版本已经来到了 1.12.0-beta02,各位在学习 Styles 的时候,一定要保证是最最最新的版本。

Styles 与 Modifier 的对比

Styles 在设计上与 Modifier 不同。

我一直反复强调的事情是:Styles 并不打算取代 Modifier(实际上按照目前的公开 API 来看,它也取代不了)。

相反,这两个系统一直共存,只不过它们的目标不同。

在内部,Style 就是一种 Modifier。你可以用 Modifier 实现 Styles 能做的一切,但 Modifier 中的一些功能在 Styles 中是无法实现的。

下面是 Styles 与 Modifier 的对比:

特性 Modifier Styles
主要目标 定义行为、语义和复杂布局。Modifier 针对特定可组合项即时操作单个元素,不会从主题向下传递。 定义视觉外观、单个元素尺寸和可主题化属性。Styles 在主题层级运作,并可在组件层级覆盖。它们向下传递,并将样式应用到不同的可组合项。
逻辑 累加------Modifier 组合在一起形成新结果。 可覆盖------Style 中最后设置的属性生效。Styles 作为单层属性,根据定义的优先级层次相互覆盖。
主题化 难以提升到主题中,通常单独使用。 天生可主题化(可访问 CompositionLocal),可定义一次并在多个组件中使用。
性能 更新通常需要 Compose 的全部三个阶段:组合、布局和绘制。要获得良好的 Modifier 动画性能,通常需要编写基于 lambda 的版本。 跳过组合阶段,仅在布局和绘制阶段活动,减少重组。对象分配更少。
动画 需要使用单独的动画原语如 animate*AsState。 内置 animate { } API,自动处理部分动画。

Modifier 的局限性

Modifier 在当前的 Compose 体系中有很多好处。但 Styles 解决了 Modifier 的一些局限性,以下列表说明了这些局限性:

  • Modifier 通常在组合阶段创建。更新可能强制完整重跑组合、布局和绘制,即使只是颜色这样的小视觉变化,除非你创建基于 lambda 的 Modifier。
  • 条件 Modifier 需要在流式链中插入破坏性的 if-else 逻辑。为它们做动画需要手写状态样板代码,且缺少高性能的"自动动画"机制。
  • Modifier 是堆叠而非替换的。你无法覆盖组件的默认边框;你只能在上面再画一个。
  • Modifier 难以抽象为全局主题。因此,主题通常存储原始值,而不是可复用的 Modifier 配置。

Styles 的局限

虽然 Styles 能填补 Modifier 的一些空白,但它们也有一些局限,它无法完全取代 Modifier:

  • Styles 是特殊的 Modifier。Modifier 能做 Style 能做的任何事,反过来却不行。因此,Styles 是补充,而非取代 Modifier。
  • Styles 仅限于视觉配置(背景、内边距、边框)。它们无法处理点击逻辑、手势检测或无障碍语义等行为。
  • 将 Style 解析为最终状态比应用单个 Modifier 更昂贵。系统必须生成包含所有可能属性值的数据结构,继承属性的查找进一步增加了复杂性。

Styles Or Modifier

虽然是否使用 Styles 很大程度上取决于你的应用和用例,为了让读者可以做一个初步的判断,专门给出以下指南,有助于你判断何时优先使用 Style 而非 Modifier:

  • 实现主题范围的一致性 :Styles 被设计为可以"提升"到全局主题。与其向每个组件传递重复的 Modifier,你可以在主题中定义一个 Style 来创建整个应用统一的外观。
  • 频繁做动画时 :Styles 在布局和绘制阶段求值,允许颜色或缩放等属性在做动画时完全绕过组合阶段。这显著降低了性能开销。做视觉属性动画时,应使用 Style 而非 Modifier。
  • 覆盖 vs 堆叠 :当你需要替换默认属性时使用 Styles。Modifier 是累加的(添加边框会叠上第二个),而 Styles 使用"后写者胜"逻辑,更容易替换背景或内边距而不会产生视觉混乱。
  • 定制 Material 组件:如果 Material 组件提供了 Style 参数,这是推荐的定制方式。这些样式允许你访问和修改可组合项内部结构中原本无法访问的特定属性。

用 Styles 做主题

是否使用 Styles 做主题,取决于你的应用在 Material Design 采用上所处的阶段:

  1. 完全自定义的设计系统,不使用 Material Design:定义消费主题中值的组件样式,并在设计系统组件上暴露 style 参数。
  2. 使用 Material Design:等待 Material 集成 Styles。在你自己的组件上尽可能使用 Styles。

简单来讲,如果你的应用已经完全依赖了 Material Design,那就等 Material Design 集成 Styles,否则你现在就可以完全使用 Styles。

Style 层

在传统的 Compose 模型中,定制通常严重依赖覆盖 MaterialTheme 提供的全局属性(颜色和排版),或在可能的情况下包装和覆盖设计系统可组合项的属性。有时,Material 层中有些属性没有通过子系统或参数暴露出来,而是组件本身的硬编码默认值。

有了 Styles API,就多了一层抽象,它是子系统与组件之间的桥梁。

层 职责 示例
子系统值 命名值 val Primary = Color(0xFF34A85E)
原子样式 只做一个属性变更的样式 val largeSizeAtomic = Style { size(100.dp, 40.dp) }
组件样式 组件特定的配置 带有 Primary 背景和 16dp 内边距的按钮。val buttonStyle = Style { contentPadding(16.dp) shape(RoundedCornerShape(8.dp)) background(Primary) }
组件 消费 Style 的功能 UI 元素。 Button(style = buttonStyle) { ... }

上图展示了一个组件以及它如何从主题中访问样式的示例。

实际上这个过程非常简单,如果你不想继续看这么多的内容,我可以给你一个通俗易懂的适配方案:

  1. 定义基准 :设计稿中的基本颜色,文字大小,圆角尺寸,文字间距等,都是一些子系统值,也就是最基本的样式,你的 App 用到的所有的基本样式,应该都来源这里。有个很好的例子就是 Markdown 文档,Markdown 在渲染的时候,实际上已经定义好了很多基准样式,当我们打开一个 Markdown 文档的时候,都会按照事先定义好的样式进行渲染和展示。那么哪些应该是基准样式呢?有个比较好的判定:基准样式是与技术或者细节无关的,无论使用朴素的 Modifier 还是 Styles,这部分的基准样式定义都不会受到你选型技术的影响。正常来说,基准样式,你的 UI 设计师已经定义好了。
  2. 定义原子样式:举个简单的例子,标题的大小,标题的颜色,圆角的大小,不同按钮的背景颜色,这些都是原子样式。
  3. 组合原子样式:例如 App 的确认按钮,可以是 背景色 + 圆角 + 文本大小 + 文本颜色的组合。
  4. 应用样式 :这一步最简单,使用 Modifier.styleable 放置第 3 步的组合样式即可,当然,后续 Compose 会提供针对单个控件的单个 Style 参数。

原子样式 vs 整体样式

使用 Styles API,你可以将一个 Style 拆分为多个原子样式。与其定义像 baseButtonStyle 这样复杂的、组件特定的样式,你还可以创建小的、单一用途的工具样式。这些就是你的"原子"。

kotlin 复制代码
// 定义单一用途的"原子"样式
val paddingAtomic = Style {
    contentPadding(16.dp)
}
val roundedCornerShapeAtomic = Style {
    shape(RoundedCornerShape(8.dp))
}
val primaryBackgroundAtomic = Style {
    background(Color.Blue)
}
val largeSizeAtomic = Style {
    size(100.dp, 40.dp)
}
val interactiveShadowAtomic = Style {
    hovered {
        animate {
            dropShadow(
                Shadow(
                    offset = DpOffset(
                        0.dp,
                        0.dp
                    ),
                    radius = 2.dp,
                    spread = 0.dp,
                    color = Color.Blue,
                )
            )
        }
    }
}

用 then 组合

新 Styles API 的强大特性之一是 then 操作符,它允许你合并多个 Style 对象。这让你可以用原子工具类来构建组件。

传统(非原子):

kotlin 复制代码
// 一个大的整体样式
val buttonStyle = Style {
    contentPadding(16.dp)
    shape(RoundedCornerShape(8.dp))
    background(Color.Blue)
}

原子重构:

kotlin 复制代码
// 组合原子来创建最终外观
val buttonStyle = paddingAtomic then roundedCornerShapeAtomic then primaryBackgroundAtomic then interactiveShadowAtomic

采用 Styles

根据你的设计系统定义,可以考虑下面这种 Styles 方案。

带 Styles 的自定义设计系统

适用场景:你拿到了一份不基于 Material Design 的详细样式指南,且你不打算使用 Material Design。

策略:实现完全自定义的设计系统,并将样式作为主题的一部分暴露出来。

如果你不使用 Material 作为主要设计系统语言,这就是自定义路径。

你完全绕过 MaterialTheme 来定义视觉,并已经创建了自己的自定义主题。你构建一个 CompanyTheme 作为 Styles 的容器。

  • 工作原理 :创建一个 CompanyTheme 对象,为系统中的每个组件持有 Style 对象。你的组件(无论是 Material 组件的封装,还是自定义 Box 或 Layout 实现)直接消费这些样式,并为设计系统的消费者暴露 Style 参数。
  • Style 层:Styles 是设计系统的主要定义。通过传入样式的命名变量,让 Styles 可以深度定制,例如为状态变化定义独特的动画(例如按下时动画化缩放和颜色)。

如果你在不使用 Material 的情况下构建自己的自定义主题,并希望采用样式,请将你的样式列表添加到主题中。这让你可以从项目中的任何位置访问基础样式。

  1. 创建一个 Styles 类,存储应用中的各种样式并创建默认值。例如,在 Jetsnack 应用中------这个类名为 JetsnackStyles:

    kotlin 复制代码
    object JetsnackStyles{
        val buttonStyle: Style = Style {
            shape(shapes.medium)
            background(colors.brand)
            contentColor(colors.textPrimary)
            contentPaddingVertical(8.dp)
            contentPaddingHorizontal(24.dp)
            textStyle(typography.labelLarge)
            disabled {
                animate {
                    background(colors.brandSecondary)
                }
            }
        }
        val cardStyle: Style = Style {
            shape(shapes.medium)
            background(colors.uiBackground)
            contentColor(colors.textPrimary)
        }
    }
  2. 将 Styles 作为整体主题的一部分提供,并在 StyleScope 上暴露辅助扩展函数以访问子系统:

    kotlin 复制代码
    @Immutable
    class JetsnackTheme(
        val colors: JetsnackColors = LightJetsnackColors,
        val typography: androidx.compose.material3.Typography = androidx.compose.material3.Typography(),
        val shapes: Shapes = Shapes()
    ) {
        companion object {
            val colors: JetsnackColors
                @Composable @ReadOnlyComposable
                get() = LocalJetsnackTheme.current.colors
    
            val typography: androidx.compose.material3.Typography
                @Composable @ReadOnlyComposable
                get() = LocalJetsnackTheme.current.typography
    
            val shapes: Shapes
                @Composable @ReadOnlyComposable
                get() = LocalJetsnackTheme.current.shapes
    
            val styles: JetsnackStyles = JetsnackStyles
    
            val LocalJetsnackTheme: ProvidableCompositionLocal<JetsnackTheme>
                get() = LocalJetsnackThemeInstance
        }
    }
    
    val StyleScope.colors: JetsnackColors
        get() = LocalJetsnackTheme.currentValue.colors
    
    val StyleScope.typography: androidx.compose.material3.Typography
        get() = LocalJetsnackTheme.currentValue.typography
    
    val StyleScope.shapes: Shapes
        get() = LocalJetsnackTheme.currentValue.shapes
    
    internal val LocalJetsnackThemeInstance = staticCompositionLocalOf { JetsnackTheme() }
    
    @Composable
    fun JetsnackTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
        val colors = if (darkTheme) DarkJetsnackColors else LightJetsnackColors
        val theme = JetsnackTheme(colors = colors)
    
        CompositionLocalProvider(
            LocalJetsnackTheme provides theme,
        ) {
            MaterialTheme(
                typography = LocalJetsnackTheme.current.typography,
                shapes = LocalJetsnackTheme.current.shapes,
                content = content,
            )
        }
    }
  3. 在你的可组合项中访问 JetsnackStyles:

    kotlin 复制代码
    @Composable
    fun CustomButton(modifier: Modifier,
                     style: Style = Style,
                     text: String) {
        val interactionSource = remember { MutableInteractionSource() }
        val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    
        // 在顶层容器上应用样式,并结合来自参数的传入样式。
        Box(modifier = modifier
            .clickable(
                interactionSource = interactionSource,
                indication = null,
                enabled = true,
                role = Role.Button,
                onClick = {
    
                },
            )
            .styleable(styleState, JetsnackTheme.styles.buttonStyle, style)) {
            Text(text)
        }
    }

除了全局主题采用之外,还有其他策略可以将 Styles 纳入你的应用。

开发者可以在特定调用处内联使用 Styles,或在不需要完整主题化能力时使用静态定义。除非整个样式有根本性不同,否则不应随意切换 Styles。

应该优先在视觉定义内部访问动态属性,而不是在不同的样式对象之间切换。

性能

一如既往,Compose 性能问题在任何时候都会被提及。

在设计上,Styles 在 Compose 的布局和绘制阶段运作。这避免了创建基于 lambda 的 Modifier 的需要,因为 Styles 总是跳过组合阶段。

图 1. Compose 的阶段以及 Styles 在哪里运行。

相对于 Modifier 的性能改进来自三个主要优化:

  • 阶段转移:Styles 通常针对绘制阶段。当一个值变化时,Compose 只使受影响的阶段(例如重绘)失效,而不是触发完整的重组或重新布局。
  • 延迟分配:Styles 将动画资源分配推迟到动画实际开始时。这减少了初始组合期间所需的工作。
  • 减少对象开销 :链式 Modifier 为每个属性(例如内边距、边框)分配一个对象。Styles 使用单个 lambda 来应用多个属性,显著减少内存分配。如果 Style 在主题中定义,该 lambda 会被使用该主题的所有组件共享。

下表是展示了 Styles 的内部性能基准测试的示意结果,与 Compose 中不使用 Styles 的实现对比。

basic_box_border_change 测试凸显了样式系统在属性更新时避免分配多个 Modifier 对象方面的优势,分配减少了约 77%,时间减少了约 59%。

测试方法 描述 时间变化 分配变化
basic_box_border_change 切换 Box 的边框颜色以测量更新性能。 -59.91% -77.22%
input_state_basic_box 比较基于样式的悬停/聚焦/按下状态与手动交互状态收集。 -5.24% -14.72%
basic_box 测量带有五个链式 Modifier 的 Box 的初始组合和布局。 -4.78% -6.60%
basic_text 渲染五个带有硬编码字符串的 BasicText 组件。 +0.62% +2.41%
basic_text_provided_color 比较通过样式设置文本颜色与使用 CompositionLocalProvider。 +5.86% +9.82%

上述数据来源于官方测试数据。

一点想法

写到这里,文章里的内容基本就讲完了。

Styles 给我最大的感觉并不是"又一个新 API",而是 Compose 团队终于把"主题"和"修饰"这两件事从 Modifier 那一锅粥里拆了出来------视觉归视觉,行为归行为。

当然,这种拆分是有代价的,foundation 也才到 1.12.0-beta02,API 还在快速迭代。所以现阶段生产项目再等一等,至少等一个稳定版本再考虑迁移。

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui