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

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

在本系列的兩篇文章中,咱們將闡述 Compose 的優點,並探討它背後的工做原理。做爲開篇,在本文中,我會分享 Compose 所解決的問題、一些設計決策背後的緣由,以及這些決策如何幫助開發者。此外,我還會分享 Compose 的思惟模型,您應如何考慮在 Compose 中編寫代碼,以及如何建立您本身的 API。架構

Compose 所解決的問題

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

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

在編寫可維護的軟件時,咱們的目標是最大程度地減小耦合增長內聚編程語言

當咱們處理緊耦合的模塊時,對一個地方的代碼改動,便意味對其餘的模塊做出許多其餘的改動。更糟的是,耦合經常是隱式的,以致於看起來毫無關聯的修改,卻會形成了意料以外的錯誤發生。函數

關注點分離是儘量的將相關的代碼組織在一塊兒,以便咱們能夠輕鬆地維護它們,並方便咱們隨着應用規模的增加而擴展咱們的代碼。工具

讓咱們在當前 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 開發的知識儲備和所使用的工具將會無縫銜接。

相關文章
相關標籤/搜索