Button 的 "进化之旅" | 我们是如何设计 Compose API 的

1,018 阅读21分钟

本文由 Jetpack Compose 团队的 Louis Pullen-Freilich (软件工程师)、Matvei Malkov (软件工程师) 和 Preethi Srinivas (UX 研究员) 共同撰写。

近期 Jetpack Compose 发布了 1.0 版本,带来了一系列用于构建 UI 的稳定 API。今年早些时候,我们发布了 API 指南,介绍了编写 Jetpack Compose API 的最佳实践和 API 设计模式。经过多次迭代公共 API 接口 (API surface) 之后形成的指南,其实没有展示出这些设计模式的形成过程和我们在迭代过程中决策背后的故事。

本文将带您了解一个 "简单" 的 Button 的 "进化之旅",来深入了解我们是如何迭代设计 API,使其简单易用又不失灵活性。这个过程需要基于开发者的反馈,对 API 的可用性进行多次的适配和改进。

绘制可点击的矩形

Google 的 Android Toolkit 团队中有一个调侃: 我们所做的就是在屏幕上画一个带着颜色的矩形,并且让它可以被点击。事实证明,这是 UI toolkit 中最难实现的事情之一。

也许有人会认为,按钮是一个简单的组件: 只是一个有颜色的矩形,带有一个点击监听器。造成 Button API 设计复杂的原因有很多方面: 可发现性、参数的顺序和命名等等。另一个约束是灵活性: Button 提供了很多参数,可供开发者随意自定义各个元素。其中一些参数默认使用主题的配置,而一些参数可以基于其他参数的值。这样的搭配使得 Button API 的设计成为了一个很有意思的挑战。

我们针对 Button API 的第一个迭代版本,由两年前的一个 public commit 开始。当时的 API 就像下面这样:

@Composable
fun Button(
    text: String,
    onClick: (() -> Unit)? = null,
    enabled: Boolean = true,
    shape: ShapeBorder? = null,
    color: Color? = null,
    elevation: Dp = 0.dp
) {
     // 下面是具体实现
}

△ 最初的 Button API

除了名字外,最初的 Button API 与最终版本的代码相去甚远。它经历了多次迭代,我们将为大家展示这一过程:

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) {
     // 下面是具体实现
}

△ 1.0 版本的 Button API

获得开发者反馈

在 Compose 的研究和实验阶段的早期,我们的 Button 组件可以接收一个 ButtonStyle 类型的参数。ButtonStyle 为 Button 定义了视觉相关的配置,比如颜色和形状。这使得我们可以展现三种不同的 Material Button 类型: 内含型 (Contained)、轮廓型 (Outlined) 和纯文本型 (Text);我们直接暴露顶层的构建函数,它会返回一个 ButtonStyle 实例,该实例对应 Material 规范中对应的按钮类型。开发者可以复制这些内置的按钮样式并微调,或者从头开始创建新的 ButtonStyle,从而完全重新设计自定义 Button。我们对于最初的 Button API 是比较满意的,这个 API 是可复用的,而且包含了易用的样式。

为了验证我们的假设和设计方法,我们邀请开发者参与编程活动,并使用 Button API 完成简单的编程练习。编程练习中包括实现下图的界面:

△ 开发者所需开发的 Rally Material Study 的界面

对这些代码开发的观察结果使用了 认知维度框架 (Cognitive Dimensions Framework) 进行复盘,以评估 Button API 的 可用性

很快,我们观察到一个有趣的现象: 一些开发者一开始这样使用 Button API:

Button(text = "Refresh"){
}

△ 使用 Button API

也有开发者尝试创建一个 Text 组件,然后使用圆角矩形围在文本的外围:

// 这里我们有 Padding 可组合函数,但是没有修饰符
Padding(padding = 12.dp) {
    Column {
        Text(text = "Refresh", style = +themeTextStyle { body1 })
    }
}

△ 在 Text 上添加 Padding 来模拟一个 Button

当时使用样式 API,比如 themeShapethemeTextStyle,需要添加 + 操作符前缀。这是因为当时的 Compose Runtime 的特定限制造成的。开发者调查表明: 开发者发现很难理解此操作符的工作原理。从该现象中我们得到的启示是,不受设计者直接控制的 API 样式会影响开发者对 API 的认知。比如,我们了解到某位开发者对这里的操作符的评论是:

就我目前的理解,它是在复用一个已有的样式,或者基于该样式进行扩展。

大多数开发者认为 Compose API 之间出现了不一致性 —— 比如,对 Button 添加样式的方式与 Text 组件添加样式的方式不同*。

*大多数开发者希望在样式前加上 "加号",使用 +themeButtonStyle 或者 +buttonStyle,类似他们对 Text 组件使用 +themeTextStyle 一样的方式。

此外,我们发现大多数开发者在 Button 上实现圆角边缘时,都经历了痛苦的过程,但是本来的预期是非常简单。通常,他们需要浏览多个层次的实现代码,来理解 API 的结构。

我感觉只是在这里随意堆叠了一些东西,没有信心能够使其发挥作用。

Button{
   text = "Refresh",
   textStyle = +themeStyle {caption},
   color = rallyGreen,
   shape = RoundedRectangleBorder(borderRadius = BorderRadius.circular(5.dp.value))
}

△ 正确自定义 Button 的文字样式、颜色和形状

这就影响了开发者对 Button 设置样式的方式。比如,当为 Android 应用添加 Button时,ContainedButtonStyle 是无法对应到开发者所已知的样式的。点击这里 查看来自开发者研究的早期的感悟视频。

通过举办的这些编程活动,我们体会到需要简化 Button API,来使其能够实现简单的自定义操作,同时支持复杂的应用场景。我们开始在可发现性和个性化上下功夫,而这两点为我们带来了接下来的一系列挑战: 样式和命名

保持 API 的一致性

在我们的编程活动中,样式给开发人员带来了很多问题。要洞悉其中的原因,我们先回溯一下为什么样式的概念存在于 Android 框架和其他工具包中。

"样式" 本质上是与 UI 相关的属性的集合,可被应用于组件 (如 Button)。样式包含两大主要优点:

1. 将 UI 配置与业务逻辑相剥离

在命令式工具包中,独立定义样式有助于分离关注点并且使代码更易于阅读: UI 可以在一个地方定义,比如 XML 文件中;而回调和业务逻辑可以在另外的地方定义和关联。

在类似 Compose 的声明式工具包中,会通过设计减少业务逻辑和 UI 的耦合。像 Button 这样的组件,大多是无状态的,它仅仅显示您所传递的数据。当数据更新时,您无需更新它的内部状态。由于组件也都是函数,可以通过向 Button 函数传参实现自定义,如其他函数的操作一样。但是这会增加将 UI 配置从功能配置中剥离的难度。比如,设置 Button 的 enabled = false ,不仅控制 Button 的功能,还会控制 Button 是否显示。

这就引出一个问题: enabled 应该是一个顶层的参数呢,还是应该在样式中作为一个属性进行传递?而对于可用于 Button 的其他样式呢,比如 elevation,或者当 Button 被点按时,它的颜色变化呢?设计可用 API 的一个核心原则是保持一致性。我们发现在不同的 UI 组件中,保证 API 的一致性是非常重要的。

2. 自定义一个组件的多个实例

在典型的 Android View 系统中,样式非常有优势,因为创建一个新的组件的成本很高: 您需要创建一个子类,实现构造方法,并且启用自定义属性。样式允许以一种更加简洁的方式,来表达一系列共享的属性。比如,创建一个 LoginButtonStyle,来定义应用中全部用于登录按钮的外观。在 Compose 中,实现如下所示:

val LoginButtonStyle = ButtonStyle(
    backgroundColor = Color.Blue,
    contentColor = Color.White,
    elevation = 5.dp,
    shape = RectangleShape
)

Button(style = LoginButtonStyle) {
    Text(text = "LOGIN")
}

△ 为登录按钮定义样式

现在可以在 UI 中的各种 Button 上使用 LoginButtonStyle,而无需在每个 Button 上显式设置这些参数。然而,如果您也希望提取文本,让所有的登录按钮都显示相同的文本: "LOGIN",该怎么办呢?

在 Compose 中,每个组件都是一个函数,所以常规的解决方法是定义一个函数,其中调用 Button,并且为 Button 提供正确的文本:

@Composable
fun LoginButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Button(onClick = onClick, modifier = modifier, style = LoginButtonStyle) {
        Text(text = "LOGIN")
    }
}

△ 创建一个在语义上表达了其含义的 LoginButton 函数

由于组件先天的无状态特性,以这样的方式提炼函数的成本是很低的: 参数可以直接从封装的函数,传递给内部的按钮。由于您并不是继承一个类,所以仅暴露需要的参数;剩下的可以留在 LoginButton 的内部实现体中,从而避免颜色和文本被覆盖。这样的方式适用于很多自定义场景,超过样式所涵盖的范围。

此外,相比在 Button 上设置 LoginButtonStyle,创建一个 LoginButton 函数,可以具有更多的语义上的含义。我们也在研究过程中发现: 相比样式,独立的函数更具有可发现性。

没有了样式,LoginButton 现在可以重构为直接向其中的 Button 传参,而无需使用样式对象,这样就能与其他自定义操作保持一致:

@Composable
fun LoginButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Button(
        onClick = onClick, 
        modifier = modifier, 
        shape = RectangleShape, 
        elevation = ButtonDefaults.elevation(defaultElevation = 5.dp),
        colors = ButtonDefaults.buttonColors(backgroundColor = Color.Blue, contentColor = Color.White)
    ) {
        Text(text = "LOGIN")
    }
}

△ 最终的 LoginButton 实现

最终我们 去掉样式,并且将参数扁平化到组件中 —— 一方面是为了整体 Compose 设计的一致性,另一方面是鼓励开发者创建更具语义特征的 "封装" 函数:

@Composable
inline fun OutlinedButton(
    modifier: Modifier = Modifier.None,
    noinline onClick: (() -> Unit)? = null,
    backgroundColor: Color = MaterialTheme.colors().surface,
    contentColor: Color = MaterialTheme.colors().primary,
    shape: Shape = MaterialTheme.shapes().button,
    border: Border? =
        Border(1.dp, MaterialTheme.colors().onSurface.copy(alpha = OutlinedStrokeOpacity)),
    elevation: Dp = 0.dp,
    paddings: EdgeInsets = ButtonPaddings,
    noinline children: @Composable() () -> Unit
) = Button(
    modifier = modifier,
    onClick = onClick,
    backgroundColor = backgroundColor,
    contentColor = contentColor,
    shape = shape,
    border = border,
    elevation = elevation,
    paddings = paddings,
    children = children
)

△ 1.0 版本中的 OutlinedButton

提高 API 的可发现性或可见性

我们还在研究中发现,在如何设置按钮形状方面存在一个重大缺陷。要自定义 Button 的形状,开发者可以使用 shape 参数,它可接受一个 Shape 对象。当开发者需要新建一个带有切角的按钮时,通常可通过如下方式实现:

  1. 使用默认值创建一个简单的 Button
  2. MaterialTheme.kt 源文件中参考关于形状的主题设置相关的内容
  3. 再回看 MaterialButtonShapeTheme 函数
  4. 找到 RoundedCornerShape,并且使用类似的方法创建一个带有切角的 shape

大多数开发者在这里会感到迷惑,在浏览大量 API 和源代码时,常常会不知所措。我们发现开发者不易发现 CutCornerShape,这是因为它是从与其他的 shape API 不同的包里所暴露出来的。

可见性用于衡量开发者达到其目标时,定位函数或者参数的难易程度。它和编写代码所需的认知过程所付出的精力直接相关;用于探索发现和使用一个方法的路径越深,API 的可见性越差。最终,这会导致较低的效率和较差的开发者体验。基于这样的认知,我们 将 CutCornerShape 迁移 到与其他 shape API 相同的包中,来支持便捷的可发现性。

映射开发者的工作框架

接下来是更多的反馈 —— 我们在一系列更进一步的编程活动中,重新评估了 Button API 的可用性。在这些活动中,我们使用 Material Design 中对于按钮的定义来进行命名: Button 变为 ContainedButton 以符合它在 Material Design 中的特性。然后,我们测试新的命名,以及当时已有的整个 Button API,并且评估了两个主要的开发者目标:

  • 创建 Button 并且处理点击事件
  • 使用预定义的 Material 主题为 Button 添加样式

△ material.io 中的 Material Button

△ material.io 中的 Material Button

我们从开发者活动中得到了一个关键启示 —— 大多数开发者不太熟悉 Material Button 中的命名习惯。比如,很多开发者无法区分 ContainedButtonOutlinedButton:

ContainedButton 是什么意思呢?

我们发现当输入 Button,并且看到自动补全建议的三个 Button 组件时,开发者花费了相当的精力来猜测哪个才是自己需要的。大多数开发者希望默认的按钮就是 ContainedButton,因为这是最常用的一个,并且也是最像 "按钮" 的一个。所以就明确了我们需要一个默认设置,使开发者可以直接使用而无需阅读 Material Design 的指南。此外,基于视图的 MDC-Android Button 默认就是填充式按钮,这也是将其作为默认按钮的先例。

更清楚地描述角色

研究发现,另外一个令人困惑的点是两个已存在的 Button 的版本: 一个 Button 可接受一个 String类型的参数作为文本,而一个 Button 可接受一个可修改的 lambda 参数,表示通用内容。这么设计的本意是从两个不同的层次来提供 API:

  • 带有文本的 Button 更简单一些,更加易于实现
  • 更高级的 Button,它其中的内容更具开放性

我们发现开发者在两者之间进行选择时,会有一定困难: 但是当从 String 重载转移到 lambda 重载时,自定义 "悬崖" 的存在,使得增量自定义 Button 变得具有挑战性。我们常常听到开发者要求在 String 重载中为 Button 增加 TextStyle 参数。

它允许自定义内部的 TextStyle 而无需使用 lambda 重载的版本。

我们提供 String 的本意是希望能够简化那些最简单用例的实现,但是这样却阻碍了开发者使用带有可组合的 lambda 的重载,转而要求 String 重载增加额外功能。这两个单独 API 的存在,不仅造成了开发者的困惑,也表明了带有原始类型的重载的确存在一些根本的问题: 他们接受了原始类型,比如 String,而不是可组合的 lambda 类型。

单步代码

原始类型的 Button 重载直接将文本作为参数,减少了开发者在创建文本式 Button 时所需要写的代码。我们最初使用简单的 String 类型作为文本参数,但是后来发现 String 类型很难对其中的部分文本添加样式。

对于这样的需求,Compose 提供了 AnnotatedString API,来对文本的不同部分添加自定义样式。然而,它对于简单的应用场景增加了一定成本,因为开发者首先需要将 String 转换为 AnnotatedString。这也使我们在考虑是否应该提供新的 Button 重载,既可以接受 String 作为参数,也可以接受 AnnotatedString 作为参数,来支持简单和更加进阶的需求。

我们的 API 设计讨论在图片和图标方面更加的复杂,比如当 FloatingActionButton 需要用到图片或者图标的时候。icon 参数的类型应该是 Vector 还是 Bitmap?如何支持带有动画的图标?即使我们竭尽了全力,最终发现我们也只能支持 Compose 中可用的类型 —— 任何第三方图片类型都需要开发者实现他们自己的重载以提供支持。

紧耦合的副作用

Compose 最大的优势之一是可组合性。创建可组合的函数以较小成本分离关注点,构建可复用的和相对独立的组件。通过可组合的 lambda 重载,可以直观地看到这样的思路: Button 是可点击内容的容器,但是它无需关心其中的内容是什么。

但是对于原始类型的重载,情况就变复杂了: 直接接受文本参数的 Button,现在既需要负责作为可点击的容器,又需要将 Text 组件传递到内部。这意味着它现在需要管理两者的公共 API 接口,这也引发了另一个重要的问题: Button 该对外暴露什么样的文本相关参数呢?这也将 Button 和 Text的公共 API 接口绑定到了一起: 如果未来 Text 增加了新的参数和功能,那是不是意味着 Button 也需要增加对这些新增内容的支持?紧耦合是 Compose 试图避免的问题之一,而且很难以统一的方式在所有组件上回答该问题,这也导致了公共 API 接口的不一致性。

支持工作框架

原始类型的重载使开发者可以避免使用可组合的 lambda 重载,而以较少的自定义空间作为代价。但是当开发者需要在原始类型的重载上,实现原本无法实现的自定义呢?唯一的选择,就是使用可组合的 lambda 重载,然后,将内部的实现代码从原始类型重载中复制过来,并做相应的修改。我们在研究中发现,自定义操作的 "悬崖" 阻碍了开发者使用更加灵活、可组合的 API,因为在层级之间的操作显得比之前更具挑战。

使用 "slot API" 解决问题

列举上述问题后,我们决定去掉 Button 的原始类型重载,为每种 Button 仅留下包含针对内容的可组合 lambda 参数的 API。我们开始将这个通用的 API 形式叫做 "slot API",现已经广泛应用于各个组件。

Button(backgroundColor = Color.Purple) {
    // 任何可组合内容都可以写在这里
}

△ 带有空白 "slot" 的 Button

Button(backgroundColor = Color.Purple) {
    Row {
        MyImage()
        Spacer(4.dp)
        Text("Button")
    }
}

△ 带有横向排列的图片和文本的 Button

一个 "slot" 代表一个可组合的 lambda 参数,它代表组件中的任意内容,比如 Text 或者 Icon。Slot API 增加了可组合性,使组件更加简单,减少了组件之间的独立概念数量,使开发者可以快速上手创建一个新的组件,或者在不同的组件之间切换。

△ 移除原始类型重载的 CL

展望未来

我们对 Button API 所做的修改数量之多,在讨论 Button 的会议中所付出的时间之多,以及收集开发者的反馈所投入的精力之巨大,足以惊人。话虽如此,我们对 API 整体的效果非常满意。事后看来,我们看到在 Compose 中 Button 变得更具可发现性、可定制性,最重要的是它促进了组合式思维。

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    elevation: ButtonElevation? = ButtonDefaults.elevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) {
    // 实现体代码
}

1.0 Button AP

重要的是认识到,我们的设计决策都基于下面这句口号:

让简单的开发变得简单,让困难的开发变得可能。*

*这里出自著名的技术类书籍: 英文版:《Learning Perl: Making Easy Things Easy and Hard Things Possible》(Randal L. Schwartz、Brian D Foy 和 Tom Phoenix 著),中文版:《Perl 语言入门》(盛春译)

我们尝试通过减少重载,并将 "样式" 扁平化处理,使开发变得更加简单。与此同时,我们改进了 Android Studio 的自动补全功能,来帮助开发者提高效率。

这里我们希望特别提出在整个 API 设计过程中的两个要点:

  1. API 的设计是一个迭代的过程。在 API 最初的迭代中就达到完美的状态是几乎不可能的。有一些需求容易被忽视。作为一个 API 的作者,您需要做出一些假设。这其中包括开发者背景的不同,所带来的不同思维方式¹ ,最终影响了开发者探索和使用 API 的方式。适配调整是无法避免的,这是好事,不断迭代可以得到可用性更高并且更加直观的 API。
  2. 在迭代一个 API 设计时,您最有价值的工具之一是开发者使用 API 体验的反馈循环。对我们的团队来说,最关键的是去理解开发者所说的 "这个 API 太复杂了" 意味着什么。当错误调用 API 时,通常会降低开发者的成功率和效率,从中所获得感悟,会帮助我们更深入理解 "复杂 API" 的意思。我们不断迭代的关键驱动力是我们要设计易用且出色的 API。为此,创建开发者反馈循环,我们使用了多种研究路径 —— 现场编程活动² ,和需要开发者提供体验日记³ 的远程途径。我们已经可以理解开发者是如何处理 API,以及他们为打算实现的功能,找到正确方法所采取的路径。诸如工程师思维方式 (Programmer Thinking Styles) 和认知纬度 (Cognitive Dimensions) 这类框架中的支柱,有助于我们跨职能团队保持语言思维上的一致,不仅表现在审核、沟通开发者反馈中,也涉及到 API 设计讨论。尤其是,当评估用户体验和功能性之间的关系时,这个框架帮助我们塑造了为选择和权衡所做的讨论。
  1. 来自 Android Developer UX 团队的 Meital Tagor Sbero 受到 角色模型和思维方式 (personas & Thinking Styles) 的设计和 认知维度框架 (Cognitive Dimensions Framework) 的启发,开发了工程师思维方式框架 (Programmer Thinking Styles Framework)。该框架使用开发者在限定时间内所需 "解决方案的类型"的动机和态度,帮助开发者确定 API 可用性的设计思路。它兼顾了普通工程师的工作方式,并且针对高强度开发任务优化了可用性。

  2. 我们通常使用这种方式评估 API 特定方面的可用性。比如,每个活动会邀请一组开发者使用 Button API 来完成一系列开发任务,这些任务会特意暴露一些 API 的特征,而这些特征是我们希望收集反馈的目标。我们通过放声思考法,来获得更多关于开发者所追求的和开发者所设想的信息。这些活动中还包含研究者通过一些随访的问题,来进一步了解开发者的需求。我们会回顾这些活动,从而确定开发者在编程任务中促成成功或者导致失败的行为模式。

  3. 我们通常使用这种方式来评估 API 在一段时间内的可用性和易学习性。这种方式可以通过倾听开发者在常规工作中的反馈,来捕捉遇到困难的瞬间和受到启发的瞬间。在这个过程中,我们会有一组开发者开发由他们自选的特定项目,同时也确保他们会使用我们希望评估的 API。我们会结合开发者通过自行提交的日记,和由研究人员基于认知维度框架 (Cognitive Dimensions Framework) (示例) 所组织的深度调查,以及专访活动来帮助我们确定 API 的可用性。

我们承认虽然我们对现有版本的 Button API 很满意,但是我们也知道它并不是完美的。开发者的思维方式有很多,加上不同的应用场景,以及层出不穷的需求,要求我们要不断迎接新的挑战。这都不是问题!Button 的整个进化过程,对于我们和开发者社区的意义都很大。所有这些都是为 Compose 设计和塑造了一个可用的 Button API —— 一个可以在屏幕上点击的简单矩形。

希望这篇文章能够帮助大家清楚了解到您的反馈如何帮助我们改进 Compose 中 Button API。如果您在使用 Compose 时遇到任何问题,或者对新 API 的体验提升有任何 建议和想法,请告诉我们。欢迎广大开发者参与到我们接下来的 用户调研活动 中,期待您的注册报名。

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!