將架構藍圖專案遷移至 Jetpack Compose

語言: CN / TW / HK

作者 / Manuel Vivo, Android DevRel @ Google

在我們努力實現 應用架構指南 現代化的過程中,我們希望嘗試各種使用者介面模式,瞭解哪個模式最有效,找出替代方案之間的相似性和差異,並最終將這些內容整合為最佳實踐。

  • 應用架構指南

    https://developer.android.google.cn/topic/architecture

為了讓我們的結果儘可能易於理解,我們需要一個不太複雜的樣本,並基於大家熟悉的商業案例。於是,我們選擇了熱門的 TODO 類應用。並在 架構藍圖 (Architecture Blueprints) 專案中來製作示例!架構藍圖以前本就是用於挑選架構的實驗性專案,這正好完美契合了我們的需求!

  • Android 架構藍圖

    https://github.com/android/architecture-samples

△ 架構藍圖應用演示

我們想要嘗試的模式顯然受到了現今可用的多種 API 的影響。而我們這次要使用的是新推出的 Jetpack Compose State API !由於 Compose 可與任何 單向資料流模式 無縫銜接使用,因此我們將用 Compose 來渲染介面,讓比較更加公平。

  • Jetpack Compose State API

    https://developer.android.google.cn/jetpack/compose/state

  • 單向資料流模式

    https://developer.android.google.cn/jetpack/guide/ui-layer#udf

這篇文章介紹了我們的團隊如何將架構藍圖遷移到 Jetpack Compose 。由於 LiveData 也被視為我們實驗中的備選方案,因此在遷移時,我們將樣本保留原樣。 在這次重構中,ViewModel 類和資料層都未經改動

  • LiveData

    https://developer.android.google.cn/topic/libraries/architecture/livedata

:warning: 請注意: 在基於 LiveData 的程式碼庫中使用的架構,並未完全遵循 最新的架構最佳實踐 。特別是,LiveData 不應該用於 資料層 網域層 ,而應該採用 Flow 和協程。

  • 應用架構指南

    https://developer.android.google.cn/jetpack/guide

  • 資料層

    https://developer.android.google.cn/jetpack/guide/data-layer

  • 網域層

    https://developer.android.google.cn/jetpack/guide/domain-layer

現在專案背景已經明確,讓我們來深入探究如何使用 Jetpack Compose 重構藍圖專案。您可以在 dev-compose 上檢視完整程式碼:

https://github.com/android/architecture-samples/tree/dev-compose

✍️ 規劃逐步遷移

在進行任何實際編碼工作前,團隊首先制定了一個遷移計劃,以確保每個人都接受提出的更改意見。最終目標是讓藍圖成為單一 Activity 應用,其各個螢幕為可組合函式,並使用推薦的 Compose Navigation 庫在螢幕之間移動:

https://developer.android.google.cn/jetpack/compose/navigation

幸運的是,藍圖已經是單一 Activity 應用,且使用 Jetpack Navigation 在通過 Fragment 實現的不同螢幕之間移動。為了遷移到 Compose,我們遵循 Navigation 互操作性指南 ,該指南建議混合型應用使用基於 Fragment 的 Navigation 元件,並使用 Fragment 來容納基於檢視的螢幕、Compose 螢幕,以及同時使用二者的螢幕。遺憾的是,您無法在同一 Navigation 圖中混用 Fragment 和 Compose 目的地。

  • 導航

    https://developer.android.google.cn/guide/navigation

  • 互操作性

    https://developer.android.google.cn/jetpack/compose/navigation#interoperability

逐步遷移的目的是減少程式碼審查工作量,並在整個遷移過程中保持產品可交付。遷移計劃涉及三個步驟:

  • 將每個螢幕的 內容 遷移至 Compose。每個螢幕均可單獨遷移至 Compose,包括其介面測試。然後 Fragment 將成為每個已遷移螢幕的容器。

  • 將應用遷移至 Navigation Compose (此操作會移除專案中的所有 Fragment) 並將 Activity 介面邏輯遷移至基於 Composable。端到端測試也會在此時遷移。

  • 移除 View 系統依賴項。

我們也是這樣操作的!時間快進到兩週後,我們遷移了 統計資訊 (Statistics) 螢幕 新增/編輯任務 (Add/Edit task) 螢幕 任務詳細資訊 (Task detail) 螢幕 ,以及 任務 (Tasks) 螢幕 ;同時我們合併了 最終 PR ,此操作將 Navigation 和 Activity 邏輯遷移至 Compose,包括 移除未使用的 View 系統依賴項

  • 將 Statistics 遷移至 Compose

    https://github.com/android/architecture-samples/pull/821

  • 將 AddEditTask 螢幕遷移至 Compose

    https://github.com/android/architecture-samples/pull/823

  • 將 TaskDetail 遷移至 Compose

    https://github.com/android/architecture-samples/pull/822

  • 將 Tasks 遷移至 Compose

    https://github.com/android/architecture-samples/pull/826

  • 將 Activity 和 NavGraph 遷移至 Compose

    https://github.com/android/architecture-samples/pull/827

  • 移除未使用的 View 依賴

    https://github.com/android/architecture-samples/pull/827/commits/2810a37c479ef4b23b4cabf095c55df7b342235e

△ 我們如何將藍圖逐步遷移至 Compose

:bulb: 遷移重點

遷移過程中,我們遇到了一些針對 Compose 的問題,值得重點講述:

介面測試

將 Compose 新增到應用後,斷言 Compose 介面的測試需要使用 Compose 測試 API :

https://developer.android.google.cn/jetpack/compose/testing

對於螢幕級別的介面測試 ,我們不使用 launchFragmentInContainer<FragmentType> API ,而是使用 createAndroidComposeRule<ComponentActivity> API ,這樣我們可以在測試中捕獲字串資源。 這些測試可在 Espresso 和 Robolectric 中執行 。因為 Compose 已經可為所有這一切提供支援,所以無需任何額外改動。例如,您可以比較 AddEditTaskFragmentTest 中已遷移至 AddEditTaskScreenTest 的程式碼。請注意,如果您使用 ComponentActivity ,那麼需要依賴 androidx.compose.ui:ui-test-manifest 元件。

  • launchFragmentInContainer<FragmentType>

    https://developer.android.google.cn/guide/fragments/test#create

  • createAndroidComposeRule<ComponentActivity>

    https://developer.android.google.cn/jetpack/compose/testing

  • AddEditTaskFragmentTest

    https://github.com/android/architecture-samples/blob/653a563e9fe0874b4ae3fba539ce4b6518a2f796/app/src/sharedTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskFragmentTest.kt

  • AddEditTaskScreenTest

    https://github.com/manuelvicnt/architecture-samples/blob/8a203594541b25e5eec2daac63415c05884242ad/app/src/sharedTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreenTest.kt

  • androidx.compose.ui:ui-test-manifest

    https://developer.android.google.cn/jetpack/compose/testing#setup

對於端到端到整合測試 ,我們也未發現任何問題!得益於 Espresso 和 Compose 的互操作性 ,我們可以使用 Espresso 斷言來檢視 View,使用 Compose API 來檢視 Compose 介面。您可以實際檢視遷移至 Compose 期間某一時刻的 AppNavigationTest :

https://github.com/manuelvicnt/architecture-samples/blob/249a636ea9a3f16aab5c284e3245069ef56a557f/app/src/androidTestMock/java/com/example/android/architecture/blueprints/todoapp/tasks/AppNavigationTest.kt

ViewModel 事件

對於在藍圖中 處理 ViewModel 事件 的方式,我們確實遇到過問題。藍圖採用了 事件封裝容器 解決方案,將 命令 從 ViewModel 傳送到介面。但是,這在 Compose 中並不好用。最新的指南建議將這些 "事件" 建模為狀態,我們在遷移中也是這麼做的。

  • 處理 ViewModel 事件

    https://developer.android.google.cn/jetpack/guide/ui-layer/events#handle-viewmodel-events

  • 事件封裝容器

    https://github.com/android/architecture-samples/blob/8e1e0527a0d043b41da58925a39fb8e03d62829a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/Event.kt

讓我們看看 在螢幕上顯示訊息 的事件用例,我們將 LiveData 的 Event<Int> 型別替換為 Int? 。這同樣對沒有要向用戶顯示任何訊息的場景進行了建模。在這一特定用例中,當訊息被顯示時,ViewModel 還需要獲得來自介面的確認。在下面的程式碼中可以看出兩種實現之間的程式碼差異 (diff)。

/* Copyright 2022 Google LLC.  
SPDX-License-Identifier: Apache-2.0 */


class AddEditTaskViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() {


- private val _snackbarText = MutableLiveData<Event<Int>>()
- val snackbarText: LiveData<Event<Int>> = _snackbarText


+ private val _snackbarText = MutableLiveData<Int?>()
+ val snackbarText: LiveData<Int?> = _snackbarText


+ fun snackbarMessageShown() {
+ _snackbarText.value = null
+ }
}

儘管乍一看似乎工作量變大了,但它能 保證 訊息會在螢幕上顯示!

在介面程式碼中,確保事件只處理一次的方法是呼叫 event.getContentIfNotHandled() 。這種方法在 Fragment 中還算行得通,但 在 Compose 中就完全失效了 (如果您編寫的是完全原生的 Compose 程式碼的話)!因為在 Compose 中隨時可能發生重新組合,事件封裝容器並非有效的解決方案。如果在事件處理後,函式被重新組合 (在測試中經常發生這種現象),那麼資訊提示控制元件 (snackbar) 將被取消,使用者可能會錯過訊息。這是一個無法接受的使用者體驗問題。 事件封裝容器解決方案不應在 Compose 應用中使用

請注意,您可以寫出在某些情況下避免重新組合部分函式的 Compose 程式碼,然而,事件包裝器解決方案限制了使用者介面的實現方式。 我們不鼓勵大家在 Compose 中使用事件封裝器解決方案

請檢視以下帶有 "之前" (事件封裝容器) 和 "之後" (事件作為狀態) 對照的程式碼片段。因為在螢幕上顯示訊息是 介面邏輯 ,而我們的螢幕可組合項變得越來越複雜,因此使用 純狀態容器類 來管理此複雜性 (比如 AddEditTaskState )。

/* Copyright 2022 Google LLC.  
SPDX-License-Identifier: Apache-2.0 */


// FRAGMENTS CODE CONSUMING THE EVENT WRAPPER SOLUTION


- class AddEditTaskFragment : Fragment() {
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- ...
- viewModel.snackbarText.observe(
- lifecycleOwner,
- Observer { event ->
- event.getContentIfNotHandled()?.let {
- showSnackbar(context.getString(it), Snackbar.LENGTH_SHORT)
- }
- }
- )
- }
- }




// COMPOSE CODE CONSUMING USER MESSAGES AS STATE


// State holder for the AddEditTask composable.
// This class handles AddEditTask's UI elements' state and UI logic.
+ class AddEditTaskState(...) {
+ init {
+ // Listen for snackbar messages
+ viewModel.snackbarText.observe(viewLifecycleOwner) { snackbarMessage ->
+ if (snackbarMessage != null) {
+ // If there's a previous message showing on the screen
+ // stop showing it in favor of the new one to be displayed
+ currentSnackbarJob?.cancel()
+ val snackbarText = context.getString(snackbarMessage)
+ currentSnackbarJob = coroutineScope.launch {
+ scaffoldState.snackbarHostState.showSnackbar(snackbarText)
+ viewModel.snackbarMessageShown()
+ }
+ }
+ }
+ }
  • 邏輯型別

    https://developer.android.google.cn/jetpack/guide/ui-layer#logic-types

  • 狀態和邏輯的型別

    https://developer.android.google.cn/jetpack/compose/state#types-of-state-and-logic

  • AddEditTaskState

    https://github.com/manuelvicnt/architecture-samples/blob/88cf650fd1759486cce198878b5cf08e823012dc/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskState.kt

:ok_hand: 請優先確保應用正確性

重構期間,您可能很想把手上的 所有內容 遷移到 Compose。雖然這麼做完全沒問題,但您不應犧牲應用的使用者體驗或正確性。逐步遷移的全部意義在於,讓應用始終處於可交付狀態。

在將一些螢幕遷移到 Compose 時,我們也遇到了這種情況。我們不想同時進行過多遷移,所以在從事件封裝容器遷移 "之前",先將一些螢幕遷移到了 Compose。與其在 Compose 中處理事件封裝容器,獲得不夠理想的體驗,不如繼續在 Fragment 中處理這些訊息,而螢幕的其他程式碼則使用 Compose 實現。例如,您可以參考 遷移過程中 TasksFragment 的狀態 :

https://github.com/manuelvicnt/architecture-samples/blob/249a636ea9a3f16aab5c284e3245069ef56a557f/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksFragment.kt

挑戰

不是所有步驟都像看上去那麼順利。儘管將 Fragment 內容轉換為 Compose 很簡單,但從 Navigation Fragment 遷移到 Navigation Compose 需要花費更多的時間和心思。

我們有必要從各方面擴充套件和改進指南,讓遷移到 Compose 的過程更加輕鬆。這項工作引起了廣泛討論,我們希望很快制定出這方面的全新指南!:confetti_ball:

我在初次使用 Navigation :hand: 並處理向 Navigation Compose 遷移的問題時,面臨了以下挑戰:

  • 文件中沒有任何程式碼顯示如何 使用可選引數進行導航 !多虧有 Tivi 的導航圖 ,我才找到辦法解決這個問題。您可以 關注此問題並改進文件 :

    https://issuetracker.google.com/226103829

  • Tivi 的導航圖

    https://github.com/chrisbanes/tivi/blob/main/app/src/main/java/app/tivi/AppNavigation.kt

  • 從基於 XML 的導航圖和 SafeArgs 遷移到 Kotlin DSL 應該是一項簡單的機械式任務。但對我來說這項任務並不輕鬆,因為我並沒有參與初始實現。一些有關如何正確操作的指南本應對我有所幫助。您可以 關注此問題並改進文件 :

    https://issuetracker.google.com/226315955

  • 第三點與其說是挑戰,不如說這就是一個問題。說到導航, NavigationUI 已經為您做了一些工作 。由於 Compose 中不存在該介面,您需要注意這一點,並手動實現。例如,在 Drawer 螢幕之間導航時,保持後退堆疊的清潔需要特殊的 NavigationOptions (請參考 示例 )。 文件 中已經講到了這一點,但您需要意識到自己需要這麼做!

  • 使用 NavigationUI 更新介面元件

    https://developer.android.google.cn/guide/navigation/navigation-ui

  • 示例: TodoNavigation

    https://github.com/android/architecture-samples/blob/dev-compose/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavigation.kt#L79

  • 文件: 與底部導航欄整合

    https://developer.android.google.cn/jetpack/compose/navigation#bottom-nav

‍:school: 小結

總的來說,從 Navigation Fragment 遷移到 Navigation Compose 是一項有趣的工作!有意思的是,我們花在等待同行審查上的時間,比遷移專案本身的時間還要多!制定遷移計劃並讓每個人都切實理解它,無疑有助於儘早確定期望結果,並提醒同事注意即將到來的漫長審查。

希望這篇文章對您有所幫助,讓您瞭解了我們遷移到 Compose 的方法,同時我們期待分享更多我們在架構藍圖中進行的實驗和改進。

如果您有興趣瞭解 Compose 版的藍圖程式碼,請檢視 dev-compose :

https://github.com/android/architecture-samples/tree/dev-compose

如果您想瀏覽逐步遷移的 PR,請檢視以下列表:

  • 統計資訊 (Statistics) 螢幕:

    https://github.com/android/architecture-samples/pull/821

  • 新增/編輯任務 (Add/Edit task) 螢幕:

    https://github.com/android/architecture-samples/pull/823

  • 任務詳細資訊 (Task detail) 螢幕:

    https://github.com/android/architecture-samples/pull/822

  • 任務 (Tasks) 螢幕:

    https://github.com/android/architecture-samples/pull/826

  • 以及 最終 PR ,此操作將 Navigation 和 Activity 邏輯遷移至 Compose,包括 移除未使用的 View 系統依賴項 :

  • 最終 PR

    https://github.com/android/architecture-samples/pull/827
  • 移除未使用的 View 系統依賴項

    https://github.com/android/architecture-samples/pull/827/commits/2810a37c479ef4b23b4cabf095c55df7b342235e

您可以通過下方二維碼或在文章底部私信,向我們提交反饋,分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支援!

推薦閱讀

如頁面未載入,請重新整理重試

點選屏末   閱讀原文  |  即刻 瞭解更多應用架構指南