使用者介面圖層

使用者介面的角色是在螢幕上顯示應用程式資料,同時作為使用者互動的主要點。每當因使用者互動 (例如按下按鈕) 或外部輸入 (例如網路回應) 導致資料變更,使用者介面應該更新以反映這些變更。 實際上,使用者介面是從資料層擷取的應用程式狀態的視覺表示。

不過,從資料層取得的應用程式資料通常與需要顯示資訊的格式不同。例如,您可能只需要將部分資料用於使用者介面,或者可能需要合併兩個不同的資料來源,以顯示與使用者相關的資訊。無論套用的邏輯為何,您都必須完全傳遞使用者介面在完整轉譯所需的完整資訊。如果要將應用程式資料變更轉換為使用者介面可以呈現並且在隨後顯示的型態,使用者介面層便是可以利用的管道。

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

基本個案研究

考慮使用會擷取新聞報導供使用者閱讀的應用程式。應用程式有報導畫面,能夠顯示可供閱讀的文章,也可讓已登入的使用者將真正引人注目的報導加入書籤假使 任何時刻有大量報導,讀者應該能夠依照類別 瀏覽報導。總結來說,應用程式能夠使用者執行下列操作:

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

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

使用者介面圖層架構

使用者介面一詞指的是 UI 元素,例如可顯示資料的活動和片段,無論他們用來執行此動作的 API 為何 (檢視畫面或 Jetpack Compose)。由於資料層的角色是保留、管理及提供應用程式資料的存取權,因此使用者介面層必須執行下列步驟:

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

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

  • 如何定義使用者介面狀態。
  • 單向資料流 (UDF) 是用來產生及管理使用者介面狀態的一種方式。
  • 如何根據 UDF 原則藉由可觀察的資料類型顯示使用者介面狀態。
  • 如何實施消耗可觀察使用者介面狀態的使用者介面。

其中最基本的細節就是使用者介面狀態。

定義使用者介面狀態

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

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

使用者介面是將螢幕上的 UI 元素與使用者介面狀態繫結的結果。
圖 3. 使用者介面是將螢幕上的 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 元素。因此,除非使用者介面本身是其資料的唯一來源,否則請勿直接在使用者介面中修改使用者介面狀態。違反此原則會導致多個可靠來源 產生相同資訊,進而造成資料不一致和細微錯誤。

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

本指南中的命名慣例

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

functionality + UiState

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

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

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

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

本節將探討單向資料流程 (UDF) 這種架構模式,該模式可協助實施健全的責任分離。

狀態持有者

負責產生使用者介面狀態且包含該工作必要邏輯的類別稱為「狀態持有者」。狀態持有者有各種不同的尺寸,具體情況視其管理的相應 UI 元素範圍而定,從單獨小工具 (例如底部應用程式列) 到整個螢幕或是導覽目的地皆為其範圍。

在後面的例子中,一般實作為 ViewModel 的執行個體,儘管應用程式的需求各有不同,但是簡單的類別可能已經足夠。例如,個案研究中的新聞應用程式會使用 NewsViewModel 類別作為狀態持有者,為該版面所顯示的畫面產生使用者介面狀態。

有許多方法可以建立使用者介面及其狀態生產端之間的共同相依性模型。不過,因為 UI 和其 ViewModel 類別之間的互動經常可以被解讀為輸入事件並隨後輸出事件狀態,因此彼此關係可以依下圖表示:

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

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

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

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

圖 5. 個案研究應用程式中的報導項目使用者介面。

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

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

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

邏輯類型

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

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

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

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

使用 UDF 的原因

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

換句話說,UDF 可允許:

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

揭露 UI 狀態

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

觀看次數

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

    val uiState: StateFlow<NewsUiState> = …
}

Compose

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

    val uiState: NewsUiState = …
}

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

在使用者介面中顯示資料相對簡單的情況下,通常值得將資料納入使用者介面狀態類型,因為它可以傳達狀態持有者的排放及其相關畫面或 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 接著可以顯示內部變更狀態的方法,發布更新供使用者介面使用。舉例來說,假設需要執行非同步動作;系統可以使用 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 類別會嘗試擷取特定類別的報導,並反映使用者介面狀態 (使用者介面可以對其做出適當的反應) 中的嘗試結果 (無論成功或失敗)。如需進一步瞭解錯誤處理的資訊,請參閱在螢幕上顯示錯誤一節。

其他注意事項

除了上述的指導原則以外,在顯示使用者介面狀態時,請留意以下事項:

  • 使用者介面狀態物件應處理彼此相關的狀態。如此會導致不一致的情況減少,並且讓程式碼更容易理解。如果您在兩個不同串流中顯示新聞項目清單和書籤數量,最後可能遇到的情況是其中一個串流已經更新,但另一個串流則未更新。當您使用單一串流時,兩個元素都會保持最新狀態。此外,有些商業邏輯可能需要來源組合。舉例來說,只有在使用者已經登入,「同時」該使用者為付費新聞服務的訂閱者時,您才需要顯示書籤按鈕。您可以按照以下方式定義使用者介面狀態類別:

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

    在這個宣告中,書籤按鈕的瀏覽權限是兩個其他屬性的衍生資源。隨著商業邏輯變得較為複雜,提供能夠立即使用所有屬性的單一 UiState 類別就變得日益重要。

  • 使用者介面狀態:單一串流或多個串流? 選擇在單一串流或是多個串流中公開使用者介面狀態的關鍵指導原則是先前的要點:也就是排放項目之間的關係。單一串流顯示的最大優勢就是方便以及資料一致性:狀態消費者隨時都能夠即時取得最新資訊。但是,在某些情況下可能會有來自 ViewModel 的適當獨立狀態串流:

    • 不相關的資料類型: 呈現使用者介面所需的某些狀態可能彼此完全獨立。在這類情況下,將這些不同的狀態組合在一起的成本可能會超過好處,尤其是當其中一個狀態的更新頻率比另一個狀態更為頻繁時。

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

耗用使用者介面狀態

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

在使用者介面中使用可觀察資料持有者時,請務必考量使用者介面的生命週期。這個過程相當重要,因為使用者介面不應該在向使用者顯示檢視畫面時觀察使用者介面狀態。如要進一步瞭解這個主題,請參閱這篇網誌文章。使用 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,
    ...
)

此旗標值代表使用者介面中的進度列是否存在。

觀看次數

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

在螢幕上顯示錯誤

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

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

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

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

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

執行緒和並行

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

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

應用程式導覽方式的變化經常是由類似事件的排放所驅動。舉例來說,在 SignInViewModel 類別執行登入之後,UiStateisSignedIn 欄位可能設為 true。類似這樣的觸發條件應與上述消耗使用者介面狀態一節中所涵蓋觸發條件的消耗方式相同,除非消耗實施方式應依照 Navigation 元件

Paging

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

動畫

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