Guide to app architecture 2 - UI layer Overview

proheart發表於2022-05-10

UI Layer

UI的角色:1.在螢幕上顯示資料的地方,2.使用者互動的地方。
使用者互動(如按下按鈕),外部輸入(如網路響應)都會讓資料變化,UI應該更新以反映這些變化。實際上,可以認為UI是從data layer檢索到的應用程式狀態後的視覺化展示。

這裡有一個問題,從data layer檢索到的資料,其格式和要顯示的資料通常是不一樣的。例如,UI上要展示的只是檢索到的資料的一部分,或者要合併兩個不同的資料來源來供UI使用。不管是哪一種,你都要給UI提供其所需的所有資訊。所以UI層需要一個管道,管道一端是從data層拿到的資料,另一端是整理好的合乎UI格式的資料。
image.png

A basic case study

來設計一個新聞App,根據需求列出如下列表:

  1. 有一個新聞列表
  2. 可以按類別瀏覽
  3. 支援使用者登入
  4. 登入使用者可以bookmark
  5. 有premium的功能
    image.png

下面章節使用該案例來介紹單向資料流(UDF)的原理,並說明UDF在UI層架構的上下文中是如何解決問題的。

UI Layer architecture

這裡的UI指的是UI element,例如activity和fragment,指用來展示資料的容器,要和API中各種View或Jetpack Compose區分開。data layer的角色是持有,管理資料,併為其他部分提供訪問資料的介面。UI layer必須完成下面的步驟(這裡用例子來替代原文的翻譯,感覺更直觀):

  1. data layer 中拿到的資料A --> UI可以使用的資料B
  2. 把資料B傳給UI element,比如set data to the adapter of RecyclerView.
  3. 處理使用者和UI element的互動,比如使用者bookmark了一條新聞
  4. 根據需要,重複1-3

剩下的指南描述瞭如何實現UI layer,來完成上面的步驟,涵蓋了如下任務和概念:

  1. 如何定義UI State
  2. 用單向資料流的方式生成和管理UI state
  3. 如何使用單向資料流原則,把UI state和observable data type暴露出去
  4. 如何實現UI來消費observable UI state

下面來看看最基本問題:對UI State的定義。

Define UI State

在新聞App中,UI會顯示文章列表以及每篇文章的一些後設資料,呈現給使用者的這些資訊就是UI狀態。
換句話說:UI狀態決定了呈現給使用者的UI,UI是UI狀態的視覺化表示,任何UI狀態的變化會立即反應在UI上。
image.png
為滿足news app的要求,要在UI上展示所有的資訊,可以封裝一個類NewsUiState,如下:

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

Immutability

上面UI State定義是不可變的。這樣做的好處是,不可變物件保證了無論application狀態在任意時刻變化無法影響UI state。這就使得UI可以專注於自己單一的職責:讀取state並更新UI element。因此,除非UI本身是其資料的唯一來源,否則不應該直接在UI中修改UI狀態。違反這一原則會導致同一資訊的多個真實來源,從而導致資料不一致和微妙的bug。
例如,如果案例中NewsItemUiState物件的bookmarked 在activity類中更新,那麼該標記就會和資料層競爭,也要去爭奪作為書籤狀態的資料來源。而Immutability data class就阻止了這種麻煩。

注:只有資料來源或者資料的所有者才能更新它們公開出去的資料。

本指南中的命名規範

本指南對UI state的命名規範基於介面的功能或者介面上某部分的描述,規範如下:
functionality + UIState
例如,例子中的新聞首頁,狀態就叫NewsUiState,新聞條目的狀態就叫NewsItemUiState.

使用UDF管理狀態

上一節確定了UI狀態是UI的不可變快照。但是資料的動態特性意味著狀態會隨時變化。使用者互動或其他event都會修改用於填充應用的基礎資料。
這些互動可能會由一個mediator來負責處理,mediator對每一個event定義相應的邏輯,轉換資料來源格式並建立UI state。這些互動和邏輯可能包含在UI本身中(雖然UI的名稱暗示了只在這裡做某件事,但是隨著UI變得更復雜,你不知不覺就放進去很多其他程式碼,完美的成為生產者,所有者,轉換器。。。)。很快它就變得很笨拙了,成為了緊密耦合的混合物,沒有可辨識的邊界,影響可測試性。要避免這些,除非UI State很簡單,否則UI唯一的職責就是消費並顯示UI狀態。

State Holder狀態持有者

State Holder稱為狀態持有者,是一個類。該類負責生成UI State,幷包含了該過程所需的邏輯。State Holder有大有小,取決於其管理的UI element的範圍,小到單一的小部件(如bottom app bar),大到整個螢幕或者導航元件。
典型的實現就是ViewModel的一個例項。例如news app中使用NewsViewModel類作為狀態持有者為顯示在螢幕上的部分生成UI state。

注:推薦使用ViewModel來管理螢幕級的UI狀態並訪問資料層。此外,它還會自動處理更改配置的情況。ViewModel類定義邏輯去處理APP中發生的各種events,並生成更新後的狀態。

雖然有很多方式可以對UI與狀態生成器之間的相互依賴關係建模,但是由於UI和ViewModel類之間的互動在很大程度上可以理解為事件輸入以及隨之而來的狀態輸出,因此可以將其關係表示為下圖:
image.png
狀態向下流動而事件向上流動的模式稱為單向資料流 (UDF)。這種模式對應用架構的影響如下:

  • ViewModel儲存並提供UI所需的狀態,UI狀態是ViewModel從data layer拿到的資料經過ViewModel轉換得到的
  • UI 把使用者事件反饋給ViewModel
  • ViewModel處理上面的事件並更新state
  • 更新後的state被反饋給UI進行渲染
  • 對任何導致狀態變化的事件,上述步驟重複執行

ViewModel中會注入repository或者其他use case類,VM在它們的幫助下獲得data並把data轉換成UI state;上面的步驟裡VM也接收來自UI的事件,有些事件會導致state變化,VM也要處理這些變化。前面的例子中,螢幕上有文章列表,每篇文章有標題,描述,來源,坐著,日期,是否新增了書籤等資訊:
image.png
可能導致狀態變化的示例:某使用者要為某篇文章新增書籤。
作為狀態生產者,VM的責任:1.定義所有所需的邏輯,以便填充UI狀態中所有欄位 2.處理來自UI的事件。
下圖顯示了單向資料流中data和event的流動週期
image.png
下面的章節我們來了解一下event引起state變化,並且如何在UDF中處理它們。

Types of logic

給文章加書籤是一個典型的業務邏輯。這裡有幾個重要的邏輯型別需要定義:

  • Business logic: 業務邏輯就是狀態改變後做什麼。例如給某篇文章加書籤。業務邏輯通常放在domain層或者data層,但是一定不要放在UI層。
  • UI behavior logic / UI logic: UI邏輯是如何把狀態改變展示在螢幕上。例如:通過Android資源獲取正確的文字然後顯示在螢幕上;當使用者點選按鈕時開啟特定的頁面;通過toast或snackbar展示訊息。

UI邏輯應該放在UI中(尤其當涉及到Context時),不要放在ViewModel。如果UI變得越來越複雜,就要稍微重構一下,將UI邏輯委託給一個類,這樣便於測試並遵循了SOC原則。可以建立一個簡單的類作為狀態持有者。在 UI中建立的簡單類可以採用 Android SDK 依賴項,因為它們遵循 UI 的生命週期; ViewModel 物件的生命週期更長。
有關狀態持有者以及它們如何融入幫助構建UI的上下文的更多資訊,請參閱 Jetpack Compose State 指南。

為什麼使用單向資料流(UDF)?

UDF對狀態生產週期進行建模。它還對每個部分進行了隔離:狀態變化的源頭、轉換的地方和最終消費的地方。這種分離讓 UI 完全符合其名稱的含義:通過觀察狀態變化來顯示資訊,並通過將這些變化傳遞給 ViewModel 來傳遞使用者意圖。
換句話說,UDF帶來了以下好處:

  • 資料一致性。 UI有一個單一的事實來源。
  • 可測試性。狀態源被隔離開,因此可獨立於 UI 進行測試。
  • 可維護性。狀態的突變遵循一個明確定義的模式,突變是使用者事件和提取的資料來源的結果。

公佈UI 狀態

定義UI狀態並確定瞭如何管理該狀態的生成,下面就要將生成的狀態呈現給UI。因為使用 UDF 來管理狀態的產生,所以可以將產生的狀態視為一個流——換句話說,隨著時間的推移會產生多個版本的狀態。因此,應該在 LiveData 或 StateFlow 等可觀察資料持有者中公開 UI 狀態。這樣做的原因是 UI 可以對狀態中所做的任何更改做出反應,而無需直接從 ViewModel 手動提取資料。這些型別還具有始終快取最新版本的 UI 狀態的好處,這對於在配置更改後快速恢復狀態很有用。

class NewsViewModel(...) : ViewModel() {

    val uiState: StateFlow<NewsUiState> = …
}

有關 LiveData 作為可觀察資料持有者的介紹,請參閱此codhttps://developer.android.com...elab。有關 Kotlin 流的類似介紹,請參閱 Android 上的 Kotlin 流

注:在 Jetpack Compose 應用中,您可以使用 Compose 的 mutableStateOf 或 snapshotFlow 等可觀察狀態 API 來進行 UI 狀態的暴露。您在本指南中看到的任何型別的可觀察資料持有者(例如 StateFlow 或 LiveData)都可以使用適當的擴充套件在 Compose 中輕鬆使用。

如果暴露給 UI 的資料相對簡單,通常可將資料包裝在 UI 狀態型別中,因為它傳達了狀態持有者的發射與其關聯的UI元素間的關係。此外,隨著UI元素變得越來越複雜,可以很easy的新增需要的UI狀態,裡面帶上渲染UI元素所需的額外資訊即可。

建立 UiState流的一種常見方法是將支援的可變流公開為來自 ViewModel 的不可變流——例如,將 MutableStateFlow<UiState> 公開為 StateFlow<UiState>

class NewsViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

然後 ViewModel 可以公開內部改變狀態的方法,釋出更新以供 UI 使用。例如要進行非同步操作,可以使用 viewModelScope 啟動協程,並且可以在完成後更新可變狀態。

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                 }
            }
        }
    }
}

在上面的程式碼中,NewsViewModel類嘗試獲取某個類別的文章,然後把嘗試的結果更新到UI state中(無論嘗試結果成功與否)。請參閱Show errors on the screen部分以瞭解錯誤處理的更多資訊。

注:上面示例中顯示的模式通過ViewModel中的函式改變狀態,這種實現方式是實現單向資料流的較流行的方式。

其他注意事項

除了前面的部分,其他值得注意的還有:

  • UI 狀態物件應該處理彼此相關的狀態。
    這樣可以減少不一致,並使程式碼更易於理解。如果在兩個不同的流中公開新聞專案列表和書籤數量,您最終可能會遇到一個已更新而另一個未更新的情況。當您使用單個流時,兩個元素都會保持最新。此外,某些業務邏輯可能需要源的組合。例如,僅當使用者已登入並且該使用者是高階新聞服務的訂閱者時,您可能才需要顯示書籤按鈕。您可以按如下方式定義 UI 狀態類:

    data class NewsUiState(
      val isSignedIn: Boolean = false,
      val isPremium: Boolean = false,
      val newsItems: List<NewsItemUiState> = listOf()
    )
    
    val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium

    在此宣告中,書籤按鈕的可見性是其他兩個屬性的派生屬性。隨著業務邏輯變得越來越複雜,擁有一個所有屬性都立即可用的單一 UiState 類變得越來越重要。

  • UI 狀態:單流還是多流?
    在單個流或多個流中公開 UI 狀態之間進行選擇的關鍵指導原則是前面的要點:發射的專案之間的關係。單流公開的最大優勢是便利性和資料一致性:狀態的消費者始終擁有隨時可用的最新資訊。但是,在某些情況下,來自 ViewModel 的單獨狀態流可能是合適的:

    • 不相關的資料型別:渲染 UI 所需的某些狀態可能彼此完全獨立。在這種情況下,將這些不同的狀態捆綁在一起的成本可能會超過收益,尤其是如果這些狀態中的一個比另一個更頻繁地更新。
    • UiState 差異:UiState 物件中的欄位越多,流越有可能由於其欄位之一被更新而發出。因為檢視沒有區分機制來理解連續發射是不同還是相同,所以每次發射都會導致檢視更新。這意味著可能需要在 LiveData 上使用 Flow API 或 distinctUntilChanged() 等方法進行緩解。

消費UI state

要在UI中使用UiState物件流,可以使用終端運算子來表示您正在使用的可觀察資料型別。例如,對於 LiveData,使用 observe() 方法,對於 Kotlin 流,使用 collect() 方法或其變體。

在 UI 中使用 observable 資料持有者時,請確保將 UI 的生命週期考慮在內。這很重要,因為當檢視沒有顯示給使用者時,UI不應該觀察UI狀態。要了解有關此主題的更多資訊,請參閱此部落格文章。使用 LiveData 時,LifecycleOwner 隱式處理生命週期問題。使用流時,最好使用適當的協程範圍和 repeatOnLifecycle API 來處理這個問題:

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}
注:此示例中使用的特定 StateFlow 物件在沒有活動收集器時不會停止執行工作,但是當您使用流時,您可能不知道它們是如何實現的。使用生命週期感知流收集可以讓您稍後對 ViewModel 流進行此類更改,而無需重新訪問下游收集器程式碼。

顯示loading

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

這個標記表示UI是否顯示進度條。

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

顯示錯誤資訊

在UI中顯示錯誤類似於顯示loading的操作,它們都可以用布林值表示,這些值表示它們的存在或不存在。但是,錯誤還可能包括要轉發給使用者的關聯訊息,或與它們關聯的重試失敗操作的操作。因此,當正在進行的操作正在載入或未載入時,可能需要使用資料類對錯誤狀態進行建模,這些資料類承載適合於錯誤上下文的後設資料。

例如,考慮上一節中在獲取文章時顯示進度條的示例。如果此操作導致錯誤,您可能希望向使用者顯示一條或多條訊息,詳細說明問題所在。

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

錯誤訊息可能會以UI元素(如snackbars)的形式呈現給使用者。因為這與UI事件的產生和使用方式有關,請參閱 UI事件頁面瞭解更多資訊。

執行緒和併發

在ViewModel中執行的任何工作都應該是主執行緒安全的。這是因為資料層和域層負責將工作轉移到不同的執行緒。

如果 ViewModel 執行長時間執行的操作,那麼它還負責將該邏輯移動到後臺執行緒。 Kotlin 協程是管理併發操作的好方法,Jetpack 架構元件為它們提供了內建支援。要了解有關在 Android 應用程式中使用協程的更多資訊,請參閱 Android 上的 Kotlin 協程

導航

應用導航的變化通常是由類似事件發射驅動的。例如,在 SignInViewModel 類執行登入後,UiState 可能會將 isSignedIn 欄位設定為 true。這些觸發器應該像上面的使用 UI 狀態部分中介紹的那樣被使用,除了消費實現應該遵循導航元件

分頁

Paging 庫在 UI 中使用名為 PagingData 的型別。因為 PagingData 表示幷包含可以隨時間變化的專案——換句話說,它不是immutable type——它不應該以immutable UI state表示。相反,您應該在 ViewModel 自己的流中獨立地公開它。有關這方面的具體示例,請參閱 Android Paging 程式碼

動畫

為了提供流暢和平滑的頂級導航轉換,您可能希望在開始動畫之前等待第二個螢幕載入資料。 Android 檢視框架提供了 hooks 來延遲片段目的地之間的轉換,並使用 preventEnterTransition() 和 startPostponedEnterTransition() API。這些 API 提供了一種方法來確保第二個螢幕上的 UI 元素(通常是從網路獲取的影像)在 UI 動畫過渡到該螢幕之前準備好顯示。有關更多詳細資訊和實現細節,請參閱 Android Motion 示例

相關文章