本文由 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,比方 themeShape
或 themeTextStyle
,须要增加 + 操作符前缀。这是因为过后的 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 对象。当开发者须要新建一个带有切角的按钮时,通常可通过如下形式实现:
- 应用默认值创立一个简略的
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
并且解决点击事件 - 应用预约义的 Material 主题为
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”,现曾经广泛应用于各个组件。
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 设计过程中的两个要点:
- API 的设计是一个迭代的过程。 在 API 最后的迭代中就达到完满的状态是简直不可能的。有一些需要容易被忽视。作为一个 API 的作者,您须要做出一些假如。这其中包含开发者背景的不同,所带来的不同思维形式 ¹,最终影响了开发者摸索和应用 API 的形式。适配调整是无奈防止的,这是坏事,一直迭代能够失去可用性更高并且更加直观的 API。
- 在迭代一个 API 设计时,您最有价值的工具之一是开发者应用 API 体验的反馈循环。 对咱们的团队来说,最要害的是去了解开发者所说的 “ 这个 API 太简单了 ” 意味着什么。当谬误调用 API 时,通常会升高开发者的成功率和效率,从中所取得感悟,会帮忙咱们更深刻了解 “ 简单 API” 的意思。咱们一直迭代的要害驱动力是咱们要设计易用且杰出的 API。为此,创立开发者反馈循环,咱们应用了多种钻研门路 —— 现场编程流动 ²,和须要开发者提供体验日记 ³ 的近程路径。咱们曾经能够了解开发者是如何解决 API,以及他们为打算实现的性能,找到正确办法所采取的门路。诸如工程师思维形式 (Programmer Thinking Styles) 和认知纬度 (Cognitive Dimensions) 这类框架中的支柱,有助于咱们跨职能团队放弃语言思维上的统一,不仅体现在审核、沟通开发者反馈中,也波及到 API 设计探讨。尤其是,当评估用户体验和功能性之间的关系时,这个框架帮忙咱们塑造了为抉择和衡量所做的探讨。
- 来自 Android Developer UX 团队的 Meital Tagor Sbero 受到 角色模型和思维形式 (personas & Thinking Styles) 的设计和 认知维度框架 (Cognitive Dimensions Framework) 的启发,开发了工程师思维形式框架 (Programmer Thinking Styles Framework)。该框架应用开发者在限定工夫内所需 “ 解决方案的类型 ” 的动机和态度,帮忙开发者确定 API 可用性的设计思路。它兼顾了一般工程师的工作形式,并且针对高强度开发工作优化了可用性。
- 咱们通常应用这种形式评估 API 特定方面的可用性。比方,每个流动会邀请一组开发者应用 Button API 来实现一系列开发工作,这些工作会特意裸露一些 API 的特色,而这些特色是咱们心愿收集反馈的指标。咱们通过放声思考法,来取得更多对于开发者所谋求的和开发者所构想的信息。这些流动中还蕴含研究者通过一些随访的问题,来进一步理解开发者的需要。咱们会回顾这些流动,从而确定开发者在编程工作中促成胜利或者导致失败的行为模式。
- 咱们通常应用这种形式来评估 API 在一段时间内的可用性和易学习性。这种形式能够通过聆听开发者在惯例工作中的反馈,来捕获遇到困难的霎时和受到启发的霎时。在这个过程中,咱们会有一组开发者开发由他们自选的特定我的项目,同时也确保他们会应用咱们心愿评估的 API。咱们会联合开发者通过自行提交的日记,和由钻研人员基于认知维度框架 (Cognitive Dimensions Framework) ( 示例 ) 所组织的深度考察,以及专访流动来帮忙咱们确定 API 的可用性。
咱们抵赖尽管咱们对现有版本的 Button
API 很称心,然而咱们也晓得它并不是完满的。开发者的思维形式有很多,加上不同的利用场景,以及层出不穷的需要,要求咱们要一直迎接新的挑战。这都不是问题!Button
的整个进化过程,对于咱们和开发者社区的意义都很大。所有这些都是为 Compose 设计和塑造了一个可用的 Button
API —— 一个能够在屏幕上点击的简略矩形。
心愿这篇文章可能帮忙大家分明理解到您的反馈如何帮忙咱们改良 Compose 中 Button API。如果您在应用 Compose 时遇到任何问题,或者对新 API 的体验晋升有任何 倡议和想法,请通知咱们。欢送宽广开发者参加到咱们接下来的 用户调研流动 中,期待您的注册报名。
欢迎您 点击这里 向咱们提交反馈,或分享您喜爱的内容、发现的问题。您的反馈对咱们十分重要,感谢您的反对!