本文由 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
) {
// 實現體程式碼
}
重要的是認識到,我們的設計決策都基於下面這句口號:
讓簡單的開發變得簡單,讓困難的開發變得可能。*
*這裡出自著名的技術類書籍: 英文版:《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 的體驗提升有任何 建議和想法,請告訴我們。歡迎廣大開發者參與到我們接下來的 使用者調研活動 中,期待您的註冊報名。
歡迎您 點選這裡 向我們提交反饋,或分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支援!