深入詳解 Jetpack Compose | 優化 UI 構建

Android開發者發表於2020-10-21

人們對於 UI 開發的預期已經不同往昔。現如今,為了滿足使用者的需求,我們構建的應用必須包含完善的使用者介面,其中必然包括動畫 (animation) 和動效 (motion),這些訴求在 UI 工具包建立之初時並不存在。為了解決如何快速而高效地建立完善的 UI 這一技術難題,我們引入了 Jetpack Compose —— 這是一個現代的 UI 工具包,能夠幫助開發者們在新的趨勢下取得成功。

在本系列的兩篇文章中,我們將闡述 Compose 的優勢,並探討它背後的工作原理。作為開篇,在本文中,我會分享 Compose 所解決的問題、一些設計決策背後的原因,以及這些決策如何幫助開發者。此外,我還會分享 Compose 的思維模型,您應如何考慮在 Compose 中編寫程式碼,以及如何建立您自己的 API。

Compose 所解決的問題

關注點分離 (Separation of concerns, SOC) 是一個眾所周知的軟體設計原則,這是我們作為開發者所要學習的基礎知識之一。然而,儘管其廣為人知,但在實踐中卻常常難以把握是否應當遵循該原則。面對這樣的問題,從 "耦合" 和 "內聚" 的角度去考慮這一原則可能會有所幫助。

編寫程式碼時,我們會建立包含多個單元的模組。"耦合" 便是不同模組中單元之間的依賴關係,它反映了一個模組中的各部分是如何影響另一個模組的各個部分的。"內聚" 則表示的是一個模組中各個單元之間的關係,它指示了模組中各個單元相互組合的合理程度。

在編寫可維護的軟體時,我們的目標是最大程度地減少耦合增加內聚

當我們處理緊耦合的模組時,對一個地方的程式碼改動,便意味對其他的模組作出許多其他的改動。更糟的是,耦合常常是隱式的,以至於看起來毫無關聯的修改,卻會造成了意料之外的錯誤發生。

關注點分離是儘可能的將相關的程式碼組織在一起,以便我們可以輕鬆地維護它們,並方便我們隨著應用規模的增長而擴充套件我們的程式碼。

讓我們在當前 Android 開發的上下文中進行更為實際的操作,並以檢視模型 (view model) 和 XML 佈局為例:

檢視模型會向佈局提供資料。事實證明,這裡隱藏了很多依賴關係: 檢視模型與佈局間存在許多耦合。一個更為熟悉的可以讓您檢視這一清單的方式是通過一些 API,例如 findViewByID。使用這些 API 需要對 XML 佈局的形式和內容有一定了解。

使用這些 API 需要了解 XML 佈局是如何定義並與檢視模型產生耦合的。由於應用規模會隨著時間增長,我們還必須保證這些依賴不會過時。

大多數現代應用會動態展示 UI,並且會在執行過程中不斷演變。結果導致應用不僅要驗證佈局 XML 是否靜態地滿足了這些依賴關係,而且還需要保證在應用的生命週期內滿足這些依賴。如果一個元素在執行時離開了檢視層級,一些依賴關係可能會被破壞,並導致諸如 NullReferenceExceptions 一類的問題。

通常,檢視模型會使用像 Kotlin 這樣的程式語言進行定義,而佈局則使用 XML。由於這兩種語言的差異,使得它們之間存在一條強制的分隔線。然而即使存在這種情況,檢視模型與佈局 XML 還是可以關聯得十分緊密。換句話說,它們二者緊密耦合。

這就引出了一個問題: 如果我們開始用相同的語言定義佈局與 UI 結構會怎樣?如果我們選用 Kotlin 來做這件事會怎樣?

由於我們可以使用相同的語言,一些以往隱式的依賴關係可能會變得更加明顯。我們也可以重構程式碼並將其移動至那些可以使它們減少耦合和增加內聚的位置。

現在,您可能會以為這是建議您將邏輯與 UI 混合起來。不過現實的情況是,無論您如何組織架構,您的應用中都將出現與 UI 相關聯的邏輯。框架本身並不會改變這一點。

不過框架可以為您提供一些工具,從而幫您更加簡單地實現關注點分離: 這一工具便是 Composable 函式,長久以來您在程式碼的其他地方實現關注點分離所使用的方法,您在進行這類重構以及編寫簡潔、可靠、可維護的程式碼時所獲得的技巧,都可以應用在 Composable 函式上。

Composable 函式剖析

這是一個 Composable 函式的示例:

@Composable
fun App(appData: AppData) {
  val derivedData = compute(appData)
  Header()
  if (appData.isOwner) {
    EditButton()
  }
  Body {
    for (item in derivedData.items) {
      Item(item)
    }
  }
}

在示例中,函式從 AppData 類接收資料作為引數。理想情況下,這一資料是不可變資料,而且 Composable 函式也不會改變: Composable 函式應當成為這一資料的轉換函式。這樣一來,我們便可以使用任何 Kotlin 程式碼來獲取這一資料,並利用它來描述的我們的層級結構,例如 Header() 與 Body() 呼叫。

這意味著我們呼叫了其他 Composable 函式,並且這些呼叫代表了我們層次結構中的 UI。我們可以使用 Kotlin 中語言級別的原語來動態執行各種操作。我們也可以使用 if 語句與 for 迴圈來實現控制流,來處理更為複雜的 UI 邏輯。

Composable 函式通常利用 Kotlin 的尾隨 lambda 語法,所以 Body() 是一個含有 Composable lambda 引數的 Composable 函式。這種關係意味著層級或結構,所以這裡 Body() 可以包含多個元素組成的多個元素組成的集合。

宣告式 UI

"宣告式" 是一個流行詞,但也是一個很重要的字眼。當我們談論宣告式程式設計時,我們談論的是與命令式相反的程式設計方式。讓我們來看一個例子:

假設有一個帶有未讀訊息圖示的電子郵件應用。如果沒有訊息,應用會繪製一個空信封;如果有一些訊息,我們會在信封中繪製一些紙張;而如果有 100 條訊息,我們就把圖示繪製成好像在著火的樣子......

使用命令式介面,我們可能會寫出一個下面這樣的更新數量的函式:

fun updateCount(count: Int) {
  if (count > 0 && !hasBadge()) {
    addBadge()
  } else if (count == 0 && hasBadge()) {
    removeBadge()
  }
  if (count > 99 && !hasFire()) {
    addFire()
    setBadgeText("99+")
  } else if (count <= 99 && hasFire()) {
    removeFire()
  }
  if (count > 0 && !hasPaper()) {
   addPaper()
  } else if (count == 0 && hasPaper()) {
   removePaper()
  }
  if (count <= 99) {
    setBadgeText("$count")
  }
}

在這段程式碼中,我們接收新的數量並且必須搞清楚如何更新當前的 UI 來反映對應的狀態。儘管是一個相對簡單的示例,這裡仍然出現了許多極端情況,而且這裡的邏輯也不簡單。

作為替代,使用宣告式介面編寫這一邏輯則會看起來像下面這樣:

@Composable
fun BadgedEnvelope(count: Int) {
  Envelope(fire=count > 99, paper=count > 0) {
    if (count > 0) {
      Badge(text="$count")
    }
  }
}

這裡我們定義:

  • 當數量大於 99 時,顯示火焰;
  • 當數量大於 0 時,顯示紙張;
  • 當數量大於 0 時,繪製數量氣泡。

這便是宣告式 API 的含義。我們編寫程式碼來按我們的想法描述 UI,而不是如何轉換到對應的狀態。這裡的關鍵是,編寫像這樣的宣告式程式碼時,您不需要關注您的 UI 在先前是什麼狀態,而只需要指定當前應當處於的狀態。框架控制著如何從一個狀態轉到其他狀態,所以我們不再需要考慮它。

組合 vs 繼承

在軟體開發領域,Composition (組合) 指的是多個簡單的程式碼單元如何結合到一起,從而構成更為複雜的程式碼單元。在物件導向程式設計模型中,最常見的組合形式之一便是基於類的繼承。在 Jetpack Compose 的世界中,由於我們使用函式替代了型別,因此實現組合的方法頗為不同,但相比於繼承也擁有許多優點,讓我們來看一個例子:

假設我們有一個檢視,並且我們想要新增一個輸入。在繼承模型中,我們的程式碼可能會像下面這樣:

class Input : View() { /* ... */ }
class ValidatedInput : Input() { /* ... */ }
class DateInput : ValidatedInput() { /* ... */ }
class DateRangeInput : ??? { /* ... */ }

View 是基類,ValidatedInput 使用了 Input 的子類。為了驗證日期,DateInput 使用了 ValidatedInput 的子類。但是接下來挑戰來了: 我們要建立一個日期範圍的輸入,這意味著需要驗證兩個日期——開始和結束日期。您可以繼承 DateInput,但是您無法執行兩次,這便是繼承的限制: 我們只能繼承自一個父類。 

在 Compose 中,這個問題變得很簡單。假設我們從一個基礎的 Input Composable 函式開始:

@Composable
fun <T> Input(value: T, onChange: (T) -> Unit) { 
  /* ... */
}

當我們建立 ValidatedInput 時,只需要在方法體中呼叫 Input 即可。我們隨後可以對其進行裝飾以實現驗證邏輯:

@Composable
fun ValidatedInput(value: T, onChange: (T) -> Unit, isValid: Boolean) { 
  InputDecoration(color=if(isValid) blue else red) {
    Input(value, onChange)
  }
}

接下來,對於 DataInput,我們可以直接呼叫 ValidatedInput:

@Composable
fun DateInput(value: DateTime, onChange: (DateTime) -> Unit) { 
  ValidatedInput(
    value,
    onChange = { ... onChange(...) },
    isValid = isValidDate(value)
  )
}

現在,當我們實現日期範圍輸入時,這裡不再會有任何挑戰:只需要呼叫兩次即可。示例如下:

@Composable
fun DateRangeInput(value: DateRange, onChange: (DateRange) -> Unit) { 
  DateInput(value=value.start, ...)
  DateInput(value=value.end, ...)
}

在 Compose 的組合模型中,我們不再有單個父類的限制,這樣一來便解決了我們在繼承模型中所遭遇的問題。

另一種型別的組合問題是對裝飾型別的抽象。為了能夠說明這一情況,請您考慮接下來的繼承示例:

class FancyBox : View() { /* ... */ }
class Story : View() { /* ... */ }
class EditForm : FormView() { /* ... */ }
class FancyStory : ??? { /* ... */ }
class FancyEditForm : ??? { /* ... */ }

FancyBox 是一個用於裝飾其他檢視的檢視,本例中將用來裝飾 Story 和 EditForm。我們想要編寫 FancyStory 與 FancyEditForm,但是如何做到呢?我們要繼承自 FancyBox 還是 Story?又因為繼承鏈中單個父類的限制,使這裡變得十分含糊。

 

相反,Compose 可以很好地處理這一問題:

@Composable
fun FancyBox(children: @Composable () -> Unit) {
  Box(fancy) { children() }
}
@Composable fun Story(…) { /* ... */ }
@Composable fun EditForm(...) { /* ... */ }
@Composable fun FancyStory(...) {
  FancyBox { Story(…) }
}
@Composable fun FancyEditForm(...) {
  FancyBox { EditForm(...) }
}

我們將 Composable lambda 作為子級,使得我們可以定義一些可以包裹其他函式的函式。這樣一來,當我們要建立 FancyStory 時,可以在 FancyBox 的子級中呼叫 Story,並且可以使用 FancyEditForm 進行同樣的操作。這便是 Compose 的組合模型。

封裝

Compose 做的很好的另一個方面是 "封裝"。這是您在建立公共 Composable 函式 API 時需要考慮的問題: 公共的 Composable API 只是一組其接收的引數而已,所以 Compose 無法控制它們。另一方面,Composable 函式可以管理和建立狀態,然後將該狀態及它接收到的任何資料作為引數傳遞給其他的 Composable 函式。

現在,由於它正管理該狀態,如果您想要改變狀態,您可以啟用您的子級 Composable 函式通過回撥告知當前改變已備份。

重組

"重組" 指的是任何 Composable 函式在任何時候都可以被重新呼叫。如果您有一個龐大的 Composable 層級結構,當您的層級中的某一部分發生改變時,您不會希望重新計算整個層級結構。所以 Composable 函式是可重啟動 (restartable) 的,您可以利用這一特性來實現一些強大的功能。

舉個例子,這裡有一個 Bind 函式,裡面是一些 Android 開發的常見程式碼:

fun bind(liveMsgs: LiveData<MessageData>) {
  liveMsgs.observe(this) { msgs ->
    updateBody(msgs)
  }
}

我們有一個 LiveData,並且希望檢視可以訂閱它。為此,我們呼叫 observe 方法並傳入一個 LifecycleOwner,並在接下來傳入 lambda。lambda 會在每次 LiveData 更新被呼叫,並且發生這種情況時,我們會想要更新檢視。

使用 Compose,我們可以反轉這種關係。

@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
 val msgs by liveMsgs.observeAsState()
 for (msg in msgs) {
   Message(msg)
 }
}

這裡有一個相似的 Composable 函式—— Messages。它接收了 LiveData 作為引數並呼叫了 Compose 的 observeAsState 方法。observeAsState 方法會把 LiveData<T> 對映為 State<T>,這意味著您可以在函式體的範圍使用其值。State 例項訂閱了 LiveData 例項,這意味著 State 會在 LiveData 發生改變的任何地方更新,也意味著,無論在何處讀取 State 例項,包裹它的、已被讀取的 Composable 函式將會自動訂閱這些改變。結果就是,這裡不再需要指定 LifecycleOwner 或者更新回撥,Composable 可以隱式地實現這兩者的功能。

總結

Compose 提供了一種現代的方法來定義您的 UI,這使您可以有效地實現關注點分離。由於 Composable 函式與普通 Kotlin 函式很相似,因此您使用 Compose 編寫和重構 UI 所使用的工具與您進行 Android 開發的知識儲備和所使用的工具將會無縫銜接。

相關文章