今天大概會聊到的範圍
- slot api
- modifier scope
- inline classes
在研究如何使用 ConstraintLayout 的時候,有一件事情讓我覺得很神奇。
在 ConstraintLayout 中的 Composable 的會多一個
constraintAs
的 Modifier 用來描述 constraint。
(來自上一篇)
為什麼我用 ConstraintLayout 時,ConstraintLayout 內的 component 就會多這個 modifier?
同樣是之前有聊到的 align,為什麼 Row 裡面的 component 的 Modifier.align()
吃 Alignment.Vertical
,但 Column 中的 Modifier.align()
吃的參數型別卻是 Alignment.Horizontal
呢?
其實這個問題很簡單,但了解之後卻覺得這是非常聰明的設計。
首先,可以聊聊 Slot API 的設計。
先看看 Button,最簡單的 Button 只需要一個文字,所以 Button 這個 composable 可以這樣設計。
@Composable
fun Button(
text: String
) {
// ...
}
但有時,我們需要改變 Button 的顏色,有時我們需要在文字前放一個 icon,放了 icon 又需要改變 icon 的顏色。最後,Button 的設計就會越來越複雜
@Composable
fun Button(
text: String,
icon: Icon,
iconColor: Color,
backgroundColor: Color,
textFontStyle: ...
// ...
) {
// ...
}
當然,可以透過很多很多參數去開放這個 Button 的能力,但永遠猜不到最終開發者會期待怎樣的自由度。於是,Compose 團隊的設計是在 Button 這個元件中留一個固定的區塊,在這個區塊中,讓開發者自由發展。
@Composable
fun Button(
// ... other params
content: @Composable () -> Unit
) {
// ... content 會用在這裡
}
這樣的設計被稱為 Slot API。這種設計在 TopAppBar 以及 Scaffold 這樣的 Composable 中尤其明顯。可以在這裡看到更多說明。
Row、Column 和 Button 一樣有類似 Slot API 的設計,在 function 的最後接收了一個名為 content 的 composable function。但仔細看看,發現這些 composable function 不只是普通的 function,而是一個 lambda with receiver。
@Composable
inline fun Row(
modifier: Modifier = Modifier,
// ... other params
content: @Composable RowScope.() -> Unit
) {
// ...
}
再來看看 RowScope,裡面實作了多個 Modifier 的 extension function。其中,就有 align。
interface RowScope {
@Stable
fun Modifier.align(alignment: Alignment.Vertical): Modifier
}
如此,問題就解開了。平常我們寫在 Row 底下這個 trailing lambda 的 code 是會因為 lambda with receiver 的機制 run 在 RowScope 底下,在 RowScope 底下,又有特化的 Modifier 並且只接受特別的參數。因此,Row 中的 component 使用 align modifier 時不會也沒辦法寫橫向的 Alignment,反之,也不會在 Column 中誤寫 Vertical 的 Alignment。
這其實大幅減少了 XML 中,無法輕易理解現在可以用什麼參數的問題。同樣名稱的 attribute 會出現在不同的 view 中,但是不同的 view 的同一個 attribute 卻不一定能接受一樣的參數。透過 Scope 可以簡化整個流程,在防呆的同時也更輕易的引導開發者探索 API。
同樣在 XML 中沒有明確限制容易混淆的,是單位。dp, sp , em , px, pt ... 等等,不管是文字還是 View ,有太多單位需要了解。同時在 java 中,在設定數字時往往需要透過很多手段將簡單的數字轉成 dp 或 sp。
Compose 團隊也用了很簡單的方法解決了這個問題。
以最簡單的文字為例:
@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
fontSize: TextUnit = TextUnit.Unspecified,
// ....
) {
// ....
}
Text 在接收 fontSize 的時候,接受的不是 sp 或 em,而是 TextUnit。TextUnit 是一個全新的東西,但是不用擔心還要為了這個新的 type 特別轉換,TextUnit 其實是 Compose 團隊在 sp, em 這些可以用在文字的單位之上,抽象化的一個層級。
inline class TextUnitType(internal val type: Long) {
companion object {
val Unspecified = TextUnitType(UNIT_TYPE_UNSPECIFIED)
val Sp = TextUnitType(UNIT_TYPE_SP)
val Em = TextUnitType(UNIT_TYPE_EM)
}
}
另外,在基本的數字型別上,也增加了將數字轉成 TextUnit 的 extension function
val Float.sp: TextUnit get() = // ...
val Double.sp: TextUnit get() = // ...
val Int.sp: TextUnit get() = // ...
因此,在實際使用時就會變成這樣。除了標記明確之外,也不會用錯單位(例如在 Text 中用 dp )
Text(
text = name,
fontSize = 10.sp
)
在研究過程中,發現 Compose team 還使用了另一個技巧:inline class。在上面的 TextUnitType 可以看得到,但以 Dp 舉例更清楚。
inline class Dp(val value: Float)
inline class ( 或是 value class, inline 關鍵字未來會被 value 取代),是 kotlin 中乘載資料的一個方式。 inline class 可以接受一個(而且只能有一個 ) 參數,這個 inline class 就是這個參數的包裝。舉個例,當我今天要做一個 timer :
inline 關鍵字將會被 value 取代,詳細原因可以參考KEEP 的文件。文章後續,依然會用
inline class
稱呼,但是 code 會用value
這個關鍵字。
value class Minutes(val minutes: Int)
value class Seconds(val seconds: Int)
class Timer {
fun time(min: Int, sec: Int) // fun 1
fun time(min: Minutes, sec: Seconds) // fun 2
fun time(min: Minutes) // fun 3
fun time(sec: Seconds) // fun 4
}
如果我們 Timer 的設計,如 fun 1
,那我們在提供參數時有可能會誤會 min 和 sec。透過 inline class 我們可以將 function 改寫成 fun 2
。這時我們要給兩個的參數,即便兩個的本質都是 Int,但在提供時可以透過 type 去限制也不會給錯。同時,也因為兩個參數的型別不同,也可以做到 fun 3
、fun 4
的 overload。
在 compile 後,inline class 會被轉回所乘載資料的型別,也就是 Minutes 和 Seconds 在 compile 後都是以 Int 的方式在運作。
inline class 可以讓某個值受到 type 的限制與保護,同時可以享受到 class 的能力 (ex.覆寫 operator 等等),但同時又不會在 compile 後產生過多的負擔。
今天沒有特別講到如何使用 compose 的其他 API,而是學習 Compose 團隊如何設計 API。就如同他們在這個 talk裡面提到的,使用 Compose 時每個人都是 library developer。學習如何優雅的設計 API,利用各種 Kotlin 提供優良且方便的語法與功能,在後續開發時可以寫出更好的介面、更乾淨的程式。
Reference: