UI 層

UI 的作用是在螢幕上顯示應用程式資料,並提供使用者與系統互動的主要接觸方式。只要資料因使用者互動 (例如按下按鈕) 或外部輸入 (例如網路回應) 而改變,UI 都應更新以反映變更。實際上,UI 是用視覺方式呈現從資料層擷取的應用程式狀態。

不過,從資料層取得的應用程式資料通常與需要顯示資訊的格式不同。舉例來說,UI 可能只需要使用部分資料,或者可能需要合併兩個不同的資料來源,以顯示與使用者相關的資訊。無論套用何種邏輯,您都必須傳遞所有所需資訊至 UI,才能完整轉譯所需的所有資訊。UI 層是一條管道,可以轉換應用程式的資料變更形式,讓 UI 可以呈現並顯示出來。

在一般架構中,UI 層的 UI 元素是取決於狀態持有者,而後者則取決於資料層或選用網域層的類別。
圖 1. UI 在應用程式架構中的角色。

基本個案研究

假設我們要製作的應用程式,功能是擷取新聞報導供使用者閱讀。這個應用程式會提供一個報導畫面,顯示所有可供閱讀的新聞,已登入帳號的使用者還能將出色的報導加入書籤。由於隨時都可能有許多新聞報導發布,我們應該讓讀者能分類瀏覽這些報導。總結來說,使用者可以用這個應用程式執行下列操作:

  • 查看可供閱讀的報導。
  • 依類別瀏覽報導。
  • 登入並將特定報導加入書籤。
  • 使用部分付費功能 (如果符合資格)。
圖 2. UI 個案研究的新聞應用程式範例。

以下各節將使用這個範例作為個案研究,介紹單向資料流的原則,並解釋在 UI 層的應用程式架構下,這些原則可協助解決的問題。

UI 層架構

「UI」指的是 UI 元素,例如可顯示資料的活動和片段,不管執行此動作的 API 是 Views 或 Jetpack Compose。由於資料層的角色是保留、管理及提供應用程式資料的存取權,因此 UI 層必須執行下列步驟:

  1. 使用應用程式資料,並將其轉換成 UI 可以輕鬆呈現的資料。
  2. 使用 UI 可轉譯的資料並轉換為 UI 元素,以便向使用者呈現。
  3. 使用 UI 元素組合中的使用者輸入事件,然後視需要在 UI 資料中反映。
  4. 視需要重複步驟 1 到 3。

本指南的其餘部分會說明如何實作執行這些步驟使用的 UI 層。本指南尤其著重於下列工作和概念:

  • 如何定義 UI 狀態。
  • 使用單向資料流 (UDF) 做為產生及管理 UI 狀態的一種方式。
  • 如何根據 UDF 原則,藉由可觀測的資料類型顯示 UI 狀態。
  • 如何實作使用可觀測 UI 狀態的 UI。

其中最基本的,就是 UI 狀態。

定義 UI 狀態

請參閱前述的個案研究。簡單來說,UI 可顯示報導清單,以及每篇報導的某些中繼資料。應用程式向使用者顯示的資訊就是 UI 狀態。

換句話說,如果 UI 是使用者看到的內容,則 UI 狀態是應用程式顯示,可供使用者查看的訊息。UI 就像是同一枚硬幣的兩面,是 UI 狀態的視覺呈現。UI 狀態的任何變更,都會立即反映在 UI 上。

UI 是螢幕上的 UI 元素與 UI 狀態繫結的結果。
圖 3. UI 是將螢幕上的 UI 元素與 UI 狀態繫結的結果。

考慮個案研究的因素;為符合新聞應用程式需求,完整顯示 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,
    ...
)

不變性

上例中的 UI 狀態定義不可變更。這種設定的主要好處,是不可變更的物件可為應用程式狀態即時提供保證。如此一來,UI 就可專注於讀取狀態,並據此更新 UI 元素。因此,除非 UI 本身是其資料的唯一來源,否則請勿直接在 UI 中修改 UI 狀態。違反此原則會導致多個可靠資料來源產生相同的資訊,進而造成資料不一致和細微錯誤。

舉例來說,如果個案研究中 UI 狀態的 NewsItemUiState 物件中的 bookmarked 標記在 Activity 類別中更新,則該標記就會與資料層競爭成為報導書籤狀態的來源。不可變更的資料類別對於預防這種反模式而言非常有用。

本指南中的命名慣例

在這份指南中,會根據其所描述的畫面或部分畫面的功能為 UI 狀態類別命名。慣例如下:

「功能」+「UiState」

舉例來說,顯示新聞的畫面狀態可稱為 NewsUiState,而新聞項目清單中的新聞項目狀態可能是 NewsItemUiState

使用單向資料流程管理狀態

上一節已確定 UI 狀態是 UI 呈現時所需的詳細資料中不可變更的數據匯報。但是,應用程式中資料的動態性質表示狀態可能隨時間改變。這可能是因為使用者互動或是修改基礎資料的其他事件是用於填入應用程式資料。

這些互動可能會因中介者介入處理而受益,進而定義要套用至每個事件的邏輯,並執行備份資料來源的必備轉換作業,藉此建立 UI 狀態。這些互動及其邏輯可能存在於 UI 本身當中,但當 UI 開始變得超過其名稱所建議的內容時,可能很快就會變得使用不便:它可能變成資料擁有者、生產者、轉換者等。此外,這會影響到測試能力,因為產生的程式碼是緊耦合的綜合體,並且沒有可辨識的界線。最終,UI 的負擔將可減輕並從中受益。除非 UI 狀態非常簡單,否則 UI 的唯一責任是使用並顯示 UI 狀態。

本節將探討單向資料流程 (UDF) 架構模式,而這種模式有助於強制執行健全的責任分離。

狀態容器

負責產生 UI 狀態且包含該工作必要邏輯的類別稱為「狀態容器」。狀態容器有各種不同大小,視管理的對應 UI 元素範圍而定。從單一小工具 (例如底部應用程式列) 到整個畫面,或導覽目的地都有可能。

就後面所舉範圍較大者,一般是採用 ViewModel 類別,不過還是要看應用程式的需求,有時可能簡單的類別就已足夠。舉例來說,個案研究中的新聞應用程式會使用 NewsViewModel 類別做為狀態容器,為顯示的畫面產生 UI 狀態。

有很多方式可以展示 UI 及其狀態生產端間的共同相依性,不過,因為 UI 和其 ViewModel 類別間的互動,大致可解讀為「輸入」事件及隨之產生的「輸出」狀態,我們可用下圖代表兩者的關係:

應用程式資料從資料層傳送到 ViewModel。UI 狀態會從 ViewModel 傳送至 UI 元素,而事件則從 UI 元素傳回 ViewModel。
圖 4. UDF 在應用程式架構中運作方式的圖表。

狀態向下流動而事件向上流動的模式,稱為單向資料流 (UDF)。這個模式對應用程式架構的影響如下:

  • ViewModel 會保有並顯示 UI 使用的狀態。UI 狀態是由 ViewModel 轉換的應用程式資料。
  • UI 會就使用者事件通知 ViewModel。
  • ViewModel 會處理使用者動作並更新狀態。
  • 系統會將更新狀態傳回 UI 以進行算繪。
  • 任何造成狀態變化的事件都會重複上述步驟。

如為導航目的地或畫面,ViewModel 可與存放區或用途類別搭配使用,藉此取得資料並轉換為 UI 狀態,同時整合可能會造成狀態異動的事件效果。前述的個案研究包含報導清單,每個報導都有一個標題、說明、來源、作者姓名、發布日期,以及是否已經加入書籤。每個報導項目的 UI 外觀看起來會是這樣:

圖 5. 個案研究應用程式中的報導項目 UI。

舉例來說,使用者要求將報導加入書籤,就是會造成狀態異動的事件。做為狀態生產端,ViewModel 有責任定義所有必要邏輯,以便填入 UI 狀態中的所有欄位,並處理讓 UI 完全呈現所需的事件。

當使用者將某篇報導加入書籤時,就會發生 UI 事件。ViewModel 會通知資料層狀態變更。資料層會保留資料變更,並更新應用程式資料。包含已書籤報導的全新應用程式資料會傳遞至 ViewModel,系統會因此產生新的 UI 狀態,並將其傳遞至 UI 元素進行顯示。
圖 6. 說明 UDF 中的事件與資料循環的圖表。

以下各節將進一步介紹造成狀態變更的事件,以及使用 UDF 處理這些事件的方式。

邏輯類型

將報導加入書籤是「商業邏輯」的一個例子,因為它能為應用程式帶來價值。若要瞭解更多詳情,請參閱資料層頁面。不過,有一些不同類型的邏輯務必須定義:

  • 商業邏輯是實作應用程式資料產品的需求條件。如先前所述,其中一個範例就是將個案研究應用程式中的某個報導加入書籤。商業邏輯通常會放置在網域或資料層中,但絕不會放在 UI 層中。
  • UI 行為邏輯或是 UI 邏輯,是指在螢幕上顯示狀態變更的「方式」。相關範例包括使用 Android Resources 取得要在螢幕上顯示的正確文字、當使用者按一下某個按鈕時前往特定畫面,或是使用 toastSnackbar 在螢幕上顯示使用者訊息。

UI 邏輯 (尤其是如果涉及 Context 等之類的 UI 類型時) 應該位於 UI,而非 ViewModel 中。如果 UI 的複雜程度較高,且您希望將 UI 邏輯委派給其他類別,以利可測試性以及關注點分離,您可以建立簡易類別作為狀態持有者。透過 UI 建立的簡易類別,可能會採用 Android SDK 依附元件,因為這類元件取決於 UI 的生命週期;ViewModel 物件的週期較長。

如需進一步瞭解狀態持有者及其如何融入協助建構 UI 的,請參閱 Jetpack Compose 狀態指南

為什麼要使用 UDF?

UDF 可建立狀態生產週期的模型,如圖 4 所示。此外,它也會將狀態變更的來源、狀態變更的轉換地點以及狀態變更最後使用的地點加以分隔。這種分隔可讓 UI 精確地解讀名稱所代表的意義:透過觀察狀態變更以顯示資訊,並透過傳遞這些變更,將使用者意圖轉發至 ViewModel。

換句話說,UDF 可允許:

  • 資料一致性。UI 有單一可靠資料來源。
  • 可測試性。系統會隔離狀態來源,因此可測試性不會受 UI 影響。
  • 可維護性。狀態的變動遵循已明確定義的模式,而異動原因則是使用者事件及其所提取之資料來源的結果。

公開 UI 狀態

在定義 UI 狀態並決定如何管理狀態產生方式之後,下一步就是向 UI 顯示已產生的狀態。由於您使用 UDF 管理狀態的產生,因此您可以將產生狀態視為串流,換句話說,系統會隨著時間產生的多個狀態版本。因此,您應在可觀測資料容器 (例如 LiveDataStateFlow) 中顯示 UI 狀態。如此一來,UI 會回應狀態發生的所有變更,而不必手動直接從 ViewModel 提取資料。上述類型也具備始終快取最新版 UI 狀態的好處,在設定變更後,該設定相當適合用於快速還原狀態。

檢視畫面

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

    val uiState: StateFlow<NewsUiState> = …
}

Compose

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

    val uiState: NewsUiState = …
}

如需 LiveData 做為可觀測資料容器的簡介,請參閱此程式碼研究室。如需 Kotlin 流程的類似簡介,請參閱 Android 中的 Kotlin 流程

如果在 UI 中顯示的資料相對簡單,通常值得將資料納入 UI 狀態類型,如此一來,就能傳達狀態容器排放情形與相關畫面/UI 元素之間的關係。此外,由於 UI 元素日益複雜,我們就必須能以更輕鬆的方式加入 UI 狀態定義,以提供顯示 UI 元素所需的額外資訊。

建立 UiState 串流的其中一種常見方式,就是將支援的可變更串流顯示為 ViewModel 中不可變更的串流,例如將 MutableStateFlow<UiState> 顯示為 StateFlow<UiState>

檢視畫面

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

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

    ...

}

Compose

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

    var uiState by mutableStateOf(NewsUiState())
        private set

    ...
}

然後 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)
                 }
            }
        }
    }
}

Compose

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

   var uiState by mutableStateOf(NewsUiState())
        private set

    private var fetchJob: Job? = null

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

在上述範例中,NewsViewModel 類別會嘗試擷取特定類別的報導,並反映 UI 狀態 (UI 可以對其做出適當的反應) 中的嘗試結果 (無論成功或失敗)。如需進一步瞭解錯誤處理的資訊,請參閱「在螢幕上顯示錯誤」。

其他注意事項

除了上述指引之外,顯示 UI 狀態時,請注意以下事項:

  • 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 物件中擁有的欄位越多,就越有可能因為其中一個欄位進行更新而造成串流排放。由於檢視畫面沒有比較差異機制,無法瞭解連續發出的內容是否相同或有所不同,因此每次發出都會更新檢視畫面。這表示使用 Flow API 或方法 (例如 distinctUntilChanged() 可能有必要的LiveData

取用 UI 狀態

如要在 UI 中耗用 UiState 物件的串流,您應針對可觀察的資料類型使用終端運算子。舉例來說,在 LiveData 時請使用 observe() 方法;如為 Kotlin 流程,則請使用 collect() 方法或其變化版本。

在 UI 中使用可觀察資料持有者時,請務必考量 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
                }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    viewModel: NewsViewModel = viewModel()
) {
    // Show UI elements based on the viewModel.uiState
}

顯示進行中的作業

表示 UiState 類別中載入狀態的簡單方法,是使用布林值欄位:

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 }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    modifier: Modifier = Modifier,
    viewModel: NewsViewModel = viewModel()
) {
    Box(modifier.fillMaxSize()) {

        if (viewModel.uiState.isFetchingArticles) {
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }

        // Add other UI elements. For example, the list.
    }
}

在螢幕中顯示錯誤

在 UI 中顯示錯誤與顯示進行中的作業類似,因為兩者都能輕鬆以代表其是否存在的布林值進行表示。但是錯誤也可能包含將相關訊息轉發給使用者,或是與重試失敗作業有關的操作。因此,在載入或是未載入進行中作業時,可能需要使用資料類別為錯誤狀態建模,而該資料類別會針對錯誤背景下適合的中繼資料進行代管。

舉例來說,想想上一節中擷取報導時會顯示進度列的例子。如果這項作業引發錯誤,您可能需要向使用者顯示一則或多則訊息,以詳述出錯情況。

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

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

這些錯誤訊息隨後可能會以 UI 元素 (例如 Snackbar) 的形式向使用者顯示。原因是這與 UI 事件的產生和使用方式有關,詳情請參閱 UI 事件頁面。

執行緒和並行

在 ViewModel 中執行任何工作都應為「main-safe」,也就是可安全地從主執行緒呼叫。這是因為資料和網域層需負責將工作移至其他執行緒。

如果 ViewModel 執行長時間執行的作業,也必須負責將該邏輯移至背景執行緒。Kotlin 協同程式是管理並行作業的絕佳方式,Jetpack Architecture Components 則為其提供內建支援。如要進一步瞭解如何在 Android 應用程式中使用協同程式,請參閱 Android 上的 Kotlin 協同程式

應用程式導覽方式的變化經常是由類似事件的排放所驅動。舉例來說,在 SignInViewModel 類別執行登入之後,UiStateisSignedIn 欄位可能設為 true。此類觸發條件應與上述「使用 UI 狀態」所涵蓋觸發條件的使用方式相同,但使用實作應依照 Navigation 元件

分頁

分頁程式庫會在名為 PagingData 的 UI 類型中使用。由於 PagingData 代表與包含的是會隨時間變更的項目 (亦即並非不可變更的類型),因此不應以不可變更的 UI 狀態表示。相反地,您應該在其自己的串流中單獨顯示該 ViewModel。如需具體範例,請參閱 Android Paging 程式碼研究室。

動畫

為了提供流暢且順暢的頂層導覽轉換,您可能需要等到第二個畫面載入資料後,再開始執行動畫。Android 檢視架構提供掛鉤,可延遲 postponeEnterTransition()startPostponedEnterTransition() API 片段目的地之間的轉換。這些 API 可讓您確保第二個畫面的 UI 元素 (通常是從網路擷取的圖片) 在 UI 以動畫呈現到該螢幕之前,已經準備好顯示。如需詳細資訊和實作具體說明,請參閱 Android Motion 範例

範例

以下 Google 範例示範如何使用 UI 層。歡迎查看這些範例,瞭解實務做法: