本文由 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 就像下面这样:
△ 最初的 Button API
除了名字外,最初的 Button API 与最终版本的代码相去甚远。它经历了多次迭代,我们将为大家展示这一过程:
△ 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 API
也有开发者尝试创建一个 Text 组件,然后使用圆角矩形围在文本的外围:
△ 在 Text 上添加 Padding 来模拟一个 Button
当时使用样式 API,比如 themeShape
或 themeTextStyle
,需要添加 + 操作符前缀。这是因为当时的 Compose Runtime 的特定限制造成的。开发者调查表明: 开发者发现很难理解此操作符的工作原理。从该现象中我们得到的启示是,不受设计者直接控制的 API 样式会影响开发者对 API 的认知。比如,我们了解到某位开发者对这里的操作符的评论是:
就我目前的理解,它是在复用一个已有的样式,或者基于该样式进行扩展。
大多数开发者认为 Compose API 之间出现了不一致性 —— 比如,对 Button 添加样式的方式与 Text 组件添加样式的方式不同*。
*大多数开发者希望在样式前加上 "加号",使用 +themeButtonStyle 或者 +buttonStyle,类似他们对 Text 组件使用 +themeTextStyle 一样的方式。
此外,我们发现大多数开发者在 Button
上实现圆角边缘时,都经历了痛苦的过程,但是本来的预期是非常简单。通常,他们需要浏览多个层次的实现代码,来理解 API 的结构。
我感觉只是在这里随意堆叠了一些东西,没有信心能够使其发挥作用。
△ 正确自定义 Button 的文字样式、颜色和形状
这就影响了开发者对 Button
设置样式的方式。比如,当为 Android 应用添加 Button时,ContainedButtonStyle
是无法对应到开发者所已知的样式的。点击这里 查看来自开发者研究的早期的感悟视频。
通过举办的这些编程活动,我们体会到需要简化 Button
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 中,实现如下所示:
△ 为登录按钮定义样式
现在可以在 UI 中的各种 Button
上使用 LoginButtonStyle
,而无需在每个 Button
上显式设置这些参数。然而,如果您也希望提取文本,让所有的登录按钮都显示相同的文本: *"LOGIN"*,该怎么办呢?
在 Compose 中,每个组件都是一个函数,所以常规的解决方法是定义一个函数,其中调用 Button
,并且为 Button
提供正确的文本:
△ 创建一个在语义上表达了其含义的 LoginButton 函数
由于组件先天的无状态特性,以这样的方式提炼函数的成本是很低的: 参数可以直接从封装的函数,传递给内部的按钮。由于您并不是继承一个类,所以仅暴露需要的参数;剩下的可以留在 LoginButton
的内部实现体中,从而避免颜色和文本被覆盖。这样的方式适用于很多自定义场景,超过样式所涵盖的范围。
此外,相比在 Button
上设置 LoginButtonStyle
,创建一个 LoginButton
函数,可以具有更多的语义上的含义。我们也在研究过程中发现: 相比样式,独立的函数更具有可发现性。
没有了样式,LoginButton
现在可以重构为直接向其中的 Button
传参,而无需使用样式对象,这样就能与其他自定义操作保持一致:
△ 最终的 LoginButton 实现
最终我们 去掉样式,并且将参数扁平化到组件中 —— 一方面是为了整体 Compose 设计的一致性,另一方面是鼓励开发者创建更具语义特征的 "封装" 函数:
△ 1.0 版本中的 OutlinedButton
我们还在研究中发现,在如何设置按钮形状方面存在一个重大缺陷。要自定义 Button 的形状,开发者可以使用 shape 参数,它可接受一个 Shape 对象。当开发者需要新建一个带有切角的按钮时,通常可通过如下方式实现:
Button
MaterialTheme.kt
源文件中参考关于形状的主题设置相关的内容MaterialButtonShapeTheme
函数RoundedCornerShape
,并且使用类似的方法创建一个带有切角的 shape大多数开发者在这里会感到迷惑,在浏览大量 API 和源代码时,常常会不知所措。我们发现开发者不易发现 CutCornerShape
,这是因为它是从与其他的 shape API 不同的包里所暴露出来的。
可见性用于衡量开发者达到其目标时,定位函数或者参数的难易程度。它和编写代码所需的认知过程所付出的精力直接相关;用于探索发现和使用一个方法的路径越深,API 的可见性越差。最终,这会导致较低的效率和较差的开发者体验。基于这样的认知,我们 将 CutCornerShape 迁移 到与其他 shape API 相同的包中,来支持便捷的可发现性。
接下来是更多的反馈 —— 我们在一系列更进一步的编程活动中,重新评估了 Button
API 的可用性。在这些活动中,我们使用 Material Design 中对于按钮的定义来进行命名: Button
变为 ContainedButton
以符合它在 Material Design 中的特性。然后,我们测试新的命名,以及当时已有的整个 Button API,并且评估了两个主要的开发者目标:
Button
并且处理点击事件Button
添加样式△ material.io 中的 Material Button
我们从开发者活动中得到了一个关键启示 —— 大多数开发者不太熟悉 Material Button 中的命名习惯。比如,很多开发者无法区分 ContainedButton
和 OutlinedButton
:
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"*,现已经广泛应用于各个组件。
△ 带有空白 "slot" 的 Button
△ 带有横向排列的图片和文本的 Button
一个 "slot" 代表一个可组合的 lambda 参数,它代表组件中的任意内容,比如 Text 或者 Icon。Slot API 增加了可组合性,使组件更加简单,减少了组件之间的独立概念数量,使开发者可以快速上手创建一个新的组件,或者在不同的组件之间切换。
△ 移除原始类型重载的 CL
我们对 Button API 所做的修改数量之多,在讨论 Button 的会议中所付出的时间之多,以及收集开发者的反馈所投入的精力之巨大,足以惊人。话虽如此,我们对 API 整体的效果非常满意。事后看来,我们看到在 Compose 中 Button 变得更具可发现性、可定制性,最重要的是它促进了组合式思维。
重要的是认识到,我们的设计决策都基于下面这句口号:
让简单的开发变得简单,让困难的开发变得可能。*
*这里出自著名的技术类书籍: 英文版:《Learning Perl: Making Easy Things Easy and Hard Things Possible》(Randal L. Schwartz、Brian D Foy 和 Tom Phoenix 著),中文版:《Perl 语言入门》(盛春译)
我们尝试通过减少重载,并将 "样式" 扁平化处理,使开发变得更加简单。与此同时,我们改进了 Android Studio 的自动补全功能,来帮助开发者提高效率。
这里我们希望特别提出在整个 API 设计过程中的两个要点:
我们承认虽然我们对现有版本的 Button
API 很满意,但是我们也知道它并不是完美的。开发者的思维方式有很多,加上不同的应用场景,以及层出不穷的需求,要求我们要不断迎接新的挑战。这都不是问题!Button
的整个进化过程,对于我们和开发者社区的意义都很大。所有这些都是为 Compose 设计和塑造了一个可用的 Button
API —— 一个可以在屏幕上点击的简单矩形。
希望这篇文章能够帮助大家清楚了解到您的反馈如何帮助我们改进 Compose 中 Button API。如果您在使用 Compose 时遇到任何问题,或者对新 API 的体验提升有任何 建议和想法,请告诉我们。