在Compose UI中使用Slot API模式设计Composable

1,931 阅读7分钟

Slot API模式 是一种在Compose UI代码库中广泛使用的模式. 谷歌的Compose团队经过无数次迭代, 终于找到了这种模式, 并以一种地道的方式构建了一套标准的可组合组件.

备注: Slot API, 即狭槽API, 指的是在设计Composable函数时, 函数参数倾向于为通用的Lambda, 可以接受别的Composable函数以提供最大的灵活性.

什么是Slot API模式?

一言以蔽之, 它实现了这么一个理念, 即组件应具有单一责任. 随着组件越来越复杂, 根据以往的经验, 人们倾向于创建输入非常简单但隐含行为非常复杂的组件.

让我们来看一个例子, 顶部应用栏(又名Toolbar):

看一下这个组件的时候, 你会发现有一些明确的输入:

  • 导航图标(在左侧)
  • 标题
  • 副标题(未画出)
  • 操作列表, 带溢出

如果我们要在API中对其进行建模, 你可能会创建如下内容:

@Composable
fun TopAppBar(
    navigationIcon: Painter?,
    title: String,
    subtitle: String? = null, // optional
    actions: List<Action> = emptyList(),
)

这个API非常接近视图Toolbar类的API模型(拥有相同的基础数据类型).

我并不是说上述API不好, 但它_ 确实是 _ 不灵活. TopAppBar()Toolbar一样, 负责将所有这些"简单"的输入转换为布局, 而且该函数现在必须提供许多隐式行为:

  • 标题与副标题如何布局?
  • 如果屏幕上有太多的操作, 会发生什么?

然后是缺乏灵活性:

  • 如果我想把动作的变化变成动画怎么办?
  • 如果我想提供一些丰富的文本作为标题怎么办?
  • 如果我想使用一些复杂的内容作为导航图标怎么办?
  • 如果我需要更改标题行的高度怎么办?(或Text上的任何其他参数?)

这些问题说明了Slot API模式试图解决的问题之一(在我看来是成功的). 让我们看看TopAppBar的实际实现:

@Composable
fun TopAppBar(
    navigationIcon: @Composable (() -> Unit)? = null,
    title: @Composable () -> Unit,
    actions: @Composable (RowScope.() -> Unit)? = null,
    // ...
)

总之, 所有这些"简单"的输入都被可组合的内容lambda所取代, 从而可以将每个Slot的内容委托给调用者. 使用方法如下:

TopAppBar(
    navigationIcon = {
        Image(...)
    },
    title = {
        Column {
            Text(...) // title
            Text(...) // subtitle
        }
    },
    actions = {
        IconButton(...)
        IconButton(...)
        IconButton(...)
    }
)

这样, 客户就可以在TopAppBar提供的不同"Slot"中提供他们想要的任何内容. 客户可以轻松地制作动画, 提供自定义布局或其他任何他们想要的内容. 这样, TopAppBar就成了一个简单的可插拔布局.

单一责任原则

框架和可插拔方法是通过Slot API模式实现单一责任原则的方式. 通过将不同的独立代码组合在一起, 客户可以轻松地自行构建复杂的组件.

如果我们回过头来看"如果我想提供一些丰富的文本作为标题怎么办?"这个问题, Slot API允许客户将Text()换成其他内容:

TopAppBar(
    title = {
        RichText(...)
    },
)

@Composable
fun RichText(...) {
    val annotatedString = buildAnnotatedString {
        ...
    }
    Text(annotatedString)
}

这里可合成的RichText()只是Text()的一个封装, 但关键在于它与TopAppBar()没有任何关系. 它只负责绘制一些富文本.

但这并不意味着客户需要自己编写RichText(). 在设计系统组件时, 可以同时提供TopAppBar().

如果你需要提供特定于父可组合函数的"子"可组合函数, 也可以扩展这种模式. 如果我们看一下Compose Material TabRow(), 它为标签页提供了一个Slot, 但同时也提供了Tab()可组合元素, 为单个标签页提供了所有好看的默认样式:

@Composable
fun TabRow(
    tabs: @Composable () -> Unit,
    // ...
)

@Composable
fun Tab(...)

客户可以使用或者不使用Tab()可组合函数, 这取决于他们:

TabRow(
    tabs = {
        // We can even use a mix of tab types
        Tab(...)
        CustomFancyTab(...)
        CustomImageTab(...)
    }
)

单一责任原则还能大大加快测试速度. 你可以单独测试每个部分, 而不必测试隐式的复杂行为.

Style参数 🎨

与Slot API模式半相关的是数据类输入的使用, 它可以作为组件的样式. 我在论坛和其他地方看到这种用法越来越多, 可以帮助对具有固定设计风格的组件进行建模.

我们以Material button为例. 它有4种基本相同的样式:

Text button, Outlined button, contained button and toggle button (in-order).

文本按钮, 轮廓按钮, 包含按钮和切换按钮(按顺序排列).

在视图中, 你可以将其想象为不同的样式. 类似这样

<MaterialButton
    style="Widget.Material.Button.Text" />

<MaterialButton
    style="Widget.Material.Button.Outlined" />

<MaterialButton
    style="Widget.Material.Button.Contained" />

<MaterialButton
    style="Widget.Material.Button.Icon" />

如果直接在Compose中进行映射style, 最终结果可能会如下所示:

@Composable
fun Button(
    style: ButtonStyle,
    // ...
)

sealed interface ButtonStyle {
    data class Text(val text: String)
    data class Outlined(val text: String)
    data class Contained(val text: String)
    data class Icon(icon: Painter)
}

从表面上看, 这似乎是一个不错的API, 而且在编写时也是_ 如此 _. 这种模式的问题与我们在上文谈到的TopAppBar是一样的, 它对未来缺乏灵活性.

它还有一个更大的问题, 我们将通过深入了解所包含的样式来了解. 如果我们扩展该类, 它可能看起来更像这样:

data class Contained(
    val text: String,
    val icon: Painter,
    // These default values won’t work as they need to be called
    // from a composable context (i.e. function)
    val background: Color = MaterialTheme.colors.primary,
    val contentColor: Color = contentColorFor(background),
)

构造函数无法访问可组合函数的上下文, 这意味着你无法对任何默认值使用可组合函数调用.

可以使用可组合函数的lambda来解决这个问题:

data class Contained(
    ...
    val background: @Composable () -> Color = {    
        MaterialTheme.colors.primary
    },
    val contentColor: @Composable () -> Color = {
        contentColorFor(background())
    },
)

但你不再有不可变的"状态"作为输入, 而且你现在要求调用者只能使用可组合函数中的这些类, 这就破坏了可测试性.

那么我们应该怎么做呢? 让我们看看Material Compose库提供了哪些功能:

@Composable
fun Button(...)

@Composable
fun TextButton(...)

@Composable
fun OutlinedButton(...)

@Composable
fun IconButton(painter: Painter?, ...)

Compose库一贯使用的模式是将样式建模为不同的可组合函数. 这样, 每个函数都可以有不同的相关参数. 任何复杂的默认参数值都可以提取并封装到默认类中, 在本例中就是ButtonDefaults.

如果你有兴趣了解有关Button API的演进的更多信息, 可以阅读这里的深度报道:

在Jetpack Compose中按下正确的按钮

你是否应该将其用于内部组件?

读到这里, 你可能会想: "我们可以访问所有使用我们组件的客户端代码, 既然我们可以根据需要重构代码, 为什么还要让组件具有灵活性呢?"

根据我的经验, 偏重灵活性往往会减少未来的问题.

这个问题很有道理, 应该由每个编写自己组件的团队来回答. 如果你非常需要限制对组件外观的更改, 那么Slot API模式可能就不是正确的解决方案. 只有作为一个团队的你们才能做出这样的判断, 但根据我的经验, 倾向于灵活性的一方往往会减少未来的问题.

我的反问是: 如果客户向你提出一些要求, 而当前的组件无法满足, 会发生什么?

你要么让它们处于阻塞状态, 要么放弃正在做的事情来解除阻塞. 不过, 这样匆忙的更改往往会导致错误, 因此进行广泛的用户界面测试是关键, 而根据我的经验, 很多团队都不具备这种能力.

Slot API允许客户在一个很小的特定位置提供他们需要的任何自定义内容. 他们仍然可以继续使用你提供的整体框架, 甚至可以在日后将这些更改反馈回去.

其他优势

除了创建内聚和解耦的组件外, 还有另一个好处. Slot模式允许你简化数据在布局中的流动方式, 几乎是避免可怕的支柱钻孔效应的捷径. Kiran Rao在此进行了解释:

Curious Techizen: 在Jetpack Compose中高效地使用Slot

总结一下 🧱

希望这篇文章向你展示如何构建你的可组合函数的时候是有用的, 以实现标准Compose UI可组合函数所提供的相同优势.