iT邦幫忙

2021 iThome 鐵人賽

DAY 19
0
Mobile Development

Jetpack Compose X Android Architecture X Functional Reactive Programming系列 第 19

Jetpack Compose navigation + Koin

現在我們有了編輯便利貼頁面還有編輯文字頁面,該是時候好好的來思考要怎麼切換頁面了!流程如下:使用者選擇了某一個便利貼→看到選單出現→點擊編輯文字→跳轉到編輯文字頁面→編輯完文字後點擊確認→回到便利貼頁面並且看到更新。其中我們有幾個問題可以來好好思考:

  1. Composable function 之間是如何轉換頁面的?
  2. 轉換頁面時要傳送什麼資料給編輯文字頁面?編輯的文字內容?還是便利貼 ID?
  3. 要怎麼更新資料到 Firebase?

所以我們有以下兩個選擇:第一個作法如下圖左所示,編輯文字頁面只負責更改文字,然後將更改的結果傳回去,最後再交給便利貼頁面來去做更新。第二個作法如下圖右所示,編輯文字頁面有編輯便利貼文字的權限,一開始接收到便利貼的 id ,之後用這 id 去查詢到相對應的文字,最後在使用者確定要進行更新時,直接去更改 Firestore 上面的資料,當回到便利貼頁面時,因為資料綁定,所以馬上就能看到剛剛已經更新的資料。

Screen Shot 2021-09-17 at 8.48.05 PM.png

如果是第一個做法的話,編輯文字頁面就會非常簡單,職責非常少,但是便利貼頁面就會相對的負擔比較多的責任,在接收到上個頁面回傳的結果時,因為 Single source of truth ,還不能直接在 View 層更新資料,必須要再經過 ViewModel 、Repository 這兩個元件來傳遞最新的資料,最後才能看到 Firebase 來的更新。

至於第二個做法的話,就是將便利貼這個 Domain 的知識“染指”了編輯文字頁面,因此編輯文字頁面未來的可重用性幾乎降為 0 ,這是個只為了編輯“便利貼”的文字而生的頁面,但是另一方面來說,由於他們共享了“便利貼”這個 Domain ,所以更新文字的任務可以交給這個頁面。

那這兩個做法哪個比較好呢?很可惜的這沒有正確的答案,如果編輯文字頁面是一個功能非常豐富的文字編輯器,想要在其他 App 或是不同的應用場景中使用的話,那就會是第一個做法會比較好。反之,如果這個編輯文字頁面,跟便利貼 Domain 息息相關,甚至還需要獲取或更改更多便利貼的資料時,那就會是第二個做法比較好,因此技術解決方案是與需求有著很高的連結關係的。但是以目前來說,採用第二個做法的技術挑戰會少一點,所以這邊我選擇第二個做法,但是同時開放第一個做法的選項,隨時意識到有這種選項的存在。

好了,思考完各種可能性後,現在再回過頭來回答第一個問題,Jackpack Compose 的頁面轉換要怎麼做呢?

Navigation Compose

Google 有為了 Jetpack Compose 做了一個 Navigation library ,其中的概念與 Navigation Component 是差不多的,先來看看基本的用法吧!

build.gradle

dependencies {
    implementation("androidx.navigation:navigation-compose:2.4.0-alpha09")
}

目前是 alpha 版本,未來 API 很有可能會改動喔!

NavController

navController 可以用來追蹤所有 Compose 頁面的狀態,包含頁面的堆疊、返回以及傳遞資料。

val navController = rememberNavController()

NavHost

NavHost 幫我們管理了所有的 Navigation graph ,與 navController 是一對一的對應關係,在 NavHost 底下,可以透過 navigation DSL,讓我們得以輕鬆的描述出頁面與頁面之間的交互關係,其中每一個頁面的路徑都是以 String 來表示,這種定義方式也讓我們可以用比較自由的方式來描述頁面與頁之間的關係(舉例來說,可以用 Url 來當作頁面的路徑)。最後,在 NavHost 中,如果要定義一個獨立的頁面,就要使用 composable 這個關鍵字:

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

在上面的範例中,設定了 startDestination ,也就是第一個頁面為 "profile",因此在 NavHost 被執行的時候,會先去執行 Profile() 這邊的 composable function 。

轉換頁面以及傳遞資料

Navigation library 中有很多種不同的傳遞資料方式,這邊只介紹最簡單的一種(也因為我只需要用到這樣就好):

NavHost(startDestination = "profile/{userId}") {
    ...
    composable("profile/{userId}") { backStackEntry ->
        Profile(navController, backStackEntry.arguments?.getString("userId"))
    }
}

在路徑中可以加入 /{xx} 來當作這個頁面的參數,而獲取這個參數的方式是利用 composable 這個函示中的 content 獲得。這個 content 其實就跟其他的 Composable function 是一樣的用途,利用了 Kotlin 的特性,最後一個參數是函式時,就可以像 Row 一樣是用來包裝其他的 Composable function 在裡面的,只不過這個 content 預設帶了一個 NavBackStackEntry 來讓裡面的程式可以拿到更多關於 navigation 的資訊,下面是 composable 的原始碼:

Screen Shot 2021-09-17 at 10.22.22 PM.png

最後則是在 NavHost 使用 navController 的方式:

navController.navigate("profile/user1234") // 導到 profile 頁面並帶有 user1234 這個參數
navController.popBackStack() // 跳回上一個頁面

實作

在這個 App 中我只有兩個頁面,如下面定義:

sealed class Screen(val route: String) {
    object Board : Screen("board")
    object EditText : Screen("editText") {
        const val KEY_NOTE_ID = "noteId"
    }
}

我們應該要盡量避免使用字串,透過定義好的類別來切換頁面可以避免不小心的打字錯誤,然後下面則是這專案的 Navigation graph 設定:

// 第一次出現的 MainActivity XD
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController() // [0]

            ReactiveStickyNoteTheme {
                NavHost(navController, startDestination = Screen.Board.route) { // [1]
                    composable(Screen.Board.route) { // [2]
                        val viewModel by viewModel<EditorViewModel>() // [3]
                        EditorScreen(
                            viewModel = viewModel,
                            openEditTextScreen = { id ->
                                navController.navigate(Screen.EditText.route + "/" + id) // [4]
                            }
                        )
                    }

                    composable(
                        Screen.EditText.route + "/" + "{${Screen.EditText.KEY_NOTE_ID}}" // [5]
                    ) { backStackEntry ->
                        val viewModel by backStackEntry.viewModel<EditTextViewModel> { // [6]
                            parametersOf(backStackEntry.arguments?.getString(Screen.EditText.KEY_NOTE_ID)) // [7]
                        }
                        EditTextScreen(viewModel, onLeaveScreen = { navController.popBackStack() }) // [8]
                    }
                }

            }
        }
    }
}
  • [0]:在最上層獲取 navController ,用來控制頁面之間的切換
  • [1]:指定 startDestination 為 Screen.Board.route ,所以第一個開啟的頁面將會是 Screen.Board
  • [2]:這一整個 composable 的區塊都會是 Screen.Board 的範圍,基本上就是放這頁面要用來顯示的 Composable function 。
  • [3]:Koin 的 delegate 語法,有用過 Koin 的讀者應該會很熟悉這個語法。
  • [4]:將這個 openEditTextScreen lambda 帶進去 EditorScreen 當作第二個參數,當 openEditTextScreen 被呼叫的時候就可以觸發 navController.navigate 因而開啟下一個頁面。
  • [5]:上面介紹過,如果要傳遞參數的話可以使用這個方式,將這段程式碼轉成字串的話將會是 editText/{noteId}
  • [6]:backStackEntry.viewModel 是這段程式碼中最關鍵的部分,稍後會再詳細解釋。
  • [7]:藉由 backStackEntry 獲取傳遞進來的參數
  • [8]:與 [4] 一樣,在 lambda 中透過 navController.popBackStack 回到上一個頁面

注:這邊使用 Koin 的版本是 “io.insert-koin:koin-android:3.0.2”,跟 2.0 的用法是有稍微的不同的喔。

ViewModel 的生命週期

以往大家所認識的 ViewModel 都是跟 Activity 或是 Fragment 綁在一起的,如果 ViewModel 是在 Fragment 中宣告,當 Fragment 被回收時,該 ViewModel 也會一起被回收,Activity 也是同理。那如果有一個 ViewModel 不想要跟 Fragment 的生命週期綁在一起而是要改成 Activity 的時候怎麼辦呢?很幸運的 Koin 也有提供 sharedViewModel() 這個函式來幫我們輕鬆做到這件事。

但是實際上 ViewModel 生命週期的運作機制是怎麼運行的呢?誰會有辦法去建立實例又在適合的時候進行回收呢?請看以下類別圖:

ViewModel.png

Activity 以及 Fragment 都實作了 ViewModelStoreOwner ,這是一個很簡單的介面,只有一個 getViewModelStore() 的函式,順帶一提,這樣子的介面就是是工廠模式(Factory pattern),也就是繼承這介面的實作負責生成 ViewModelStore 的實體。

接下來看看 ViewModelStore 這個類別,這個類別是一個儲存 ViewModel 的容器,put() 可以新增一個 ViewModel ,get() 則是從這個容器中拿出相對應的 ViewModel,clear() 用來結束這容器內所有 ViewModel 的生命週期。因此,ViewModelStoreOwner 擁有著所有 ViewModel 生命週期的控制權,如果是 Activity ,就會在 onDestroy 的時候呼叫 clear() , Fragment 則是比較複雜一點就沒有深入往下追了,以下是 ViewModelStore 的實作:

public class ViewModelStore {

    private final HashMap<String, ViewModel> mMap = new HashMap<>();

    final void put(String key, ViewModel viewModel) {
        ViewModel oldViewModel = mMap.put(key, viewModel);
        if (oldViewModel != null) {
            oldViewModel.onCleared();
        }
    }

    final ViewModel get(String key) {
        return mMap.get(key);
    }

    Set<String> keys() {
        return new HashSet<>(mMap.keySet());
    }

    /**
     *  Clears internal storage and notifies ViewModels that they are no longer used.
     */
    public final void clear() {
        for (ViewModel vm : mMap.values()) {
            vm.clear();
        }
        mMap.clear();
    }
}

ViewModelStoreOwner, ViewModelStore, ViewModel 這三個類別之間的關係非常緊密,其中最讓人佩服的是這設計極其簡單,讓人馬上看懂,同時又可以兼顧很多不同的使用情境,簡直就是物件導向設計的極佳範例,不只符合了SOLID principle 中的 Single Responsibility principle, Dependency inversion principle ,充分結合了工廠模式。也沒有冗余的類別相依,不用依靠 android.util 或是 android.graphics 就可以單獨存在,在寫單元測試的時候沒有額外的負擔。

好了,吹捧完了之後還是得來歸納一些重點:

  1. ViewModel 的生命週期是由 ViewModelStoreOwner 控制的
  2. 要在建立 ViewModel 的時候為他選擇適當的 ViewModelStoreOwner,該使用 Fragment 的時候就不要選擇 Activity ,不然會拿到重複的 ViewModel 而造成不可預期的 bug。

爲 EditTextViewModel 挑選最適當的 ViewModelStoreOwner 吧!

為了怕大家忘記,我們再看一次頁面跳轉的流程吧!

Screen Shot 2021-09-18 at 10.25.22 AM.png

EditorTextScreen 在打開的時候,就會去依據 noteId 去建立一個新的 EditTextViewModel ,然後在頁面關閉的時候,就必須要回收這個實例,不然在下次要編輯另一個便利貼的文字的時候,就會因為沒有回收實例而重用同一個 EditTextViewModel ,這時候編輯的內容就會是錯誤的,可想而知,如果我們使用的一直都是 Activity 這個 ViewModelStoreOwner 的話,就會造成重用實例的這個問題。

然而如果使用 Koin 預設所提供的 delegate 語法來建立或獲取 ViewModel 實例時,其中的 ViewModelStoreOwner 就會是 Activity ,所以我們不能無腦的直接使用 Koin 提供的 viewModel() 。 那我們可以怎麼做呢?還記得上面的程式碼嗎?我在這邊使用了 backStackEntry 來建立或獲得 ViewModel ,那這又是怎麼運作的呢?讓我們來看看 NavBackStackEntry 的實作:

public class NavBackStackEntry private constructor(
  ... // 這邊可以略過
): LifecycleOwner,
    ViewModelStoreOwner,
    HasDefaultViewModelProviderFactory,
    SavedStateRegistryOwner {

這邊我們發現了 NavBackStackEntry 其實實作了 ViewModelStoreOwner ,那我們再來看看 getViewModelStore() 這個函示中做了什麼事:

public override fun getViewModelStore(): ViewModelStore {
    check(lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
        "You cannot access the NavBackStackEntry's ViewModels until it is added to " +
            "the NavController's back stack (i.e., the Lifecycle of the NavBackStackEntry " +
            "reaches the CREATED state)."
    }
    checkNotNull(viewModelStoreProvider) {
        "You must call setViewModelStore() on your NavHostController before accessing the " +
            "ViewModelStore of a navigation graph."
    }
    return viewModelStoreProvider.getViewModelStore(id)
}

NavBackStackEntry 在這邊又委託了 viewModelStoreProvider 來獲取 ViewModelStore,這樣的方式代表了 ViewModelStore 的數量不是只有一個,而是多個。而且該 ViewModelStore 還綁著一個相對應的 id ,因此我們可以判斷這些 ViewModelStore 也有著不同的生命週期,每個不同ViewModelStore 會有相對應的畫面,隨之而生,也隨之消滅。下圖示意了 ViewModelStore 在 NavBackStackEntry 中的狀態:

Screen Shot 2021-09-18 at 10.54.02 AM.png

在時間軸的最左邊,打開了 ScreenA ,到了最後也沒結束它,所以 ViewModelStoreA 一直都存在著。 ScreenB 也是同理,但是 ViewModelStoreB 是在打開 ScreenB 的時候才會去建立的,在這個時候 ViewModelStoreA 跟 ViewModelStoreB 是同時存在的,因此我們可以在 ScreenB 拿到在 ScreenA 中建立的 ViewModel ,他們就跟 Fragment 與 Activity 的關係一樣。接下來看到 ScreenC : 在這段時間軸中,ScreenC 被建立了兩次,因此相對應的 ViewModelStoreC 也會不同,所以這兩次開啟 ScreenC 中的 ViewModel 也會是不同的實體。

最後再來看看之前寫的程式,是不是一切都通了呢?

composable(
    Screen.EditText.route + "/" + "{${Screen.EditText.KEY_NOTE_ID}}"
) { backStackEntry ->
    val viewModel by backStackEntry.viewModel<EditTextViewModel> { 
        parametersOf(backStackEntry.arguments?.getString(Screen.EditText.KEY_NOTE_ID))
    }
    EditTextScreen(viewModel, onLeaveScreen = { navController.popBackStack() }) 
}

使用 backStackEntry.viewModel 就可以拿到最符合該“Scope”所需要的 ViewModel,這樣就行了!甚至用不到 Koin 所設計的 Scope API 。

Scope 是什麼?在使用 Dependency Injection 時,Scope 也是一個需要被考慮的點,他能夠控制指定物件的存活時間,其中最簡單的例子就是 Activity Scope,Activity Scope 中的所有物件會隨著 Activity 的回收而一起被銷毀,如果沒有好好的運用這機制的話,整個 App 將會充滿了“有狀態”的 Singleton ,非常不好管理。

EditTextViewModel 的實作

class EditTextViewModel(
    private val noteRepository: NoteRepository,
    noteId: String
) : ViewModel() {

    private val disposableBag = CompositeDisposable()

    private val noteSubject = BehaviorSubject.create<Note>()
    private val textSubject = BehaviorSubject.createDefault("")
    private val leavePageSubject = PublishSubject.create<Unit>()

    val text: Observable<String> = textSubject.hide()
    val leavePage: Observable<Unit> = leavePageSubject.hide()

    init {
        noteRepository.getNoteById(noteId)
            .firstElement()
            .fromIO()
            .subscribe { note ->
                noteSubject.onNext(note)
                textSubject.onNext(note.text)
            }
            .addTo(disposableBag)
    }

    fun onTextChanged(newText: String) {
        textSubject.onNext(newText)
    }

    fun onConfirmClicked() {
        noteSubject.withLatestFrom(textSubject) { note, text ->
            note.copy(text = text)
        }
            .subscribe { newNote ->
                noteRepository.putNote(note = newNote)
                leavePageSubject.onNext(Unit)
            }
            .addTo(disposableBag)
    }

    fun onCancelClicked() {
        leavePageSubject.onNext(Unit)
    }
}

這邊沒有新概念,所以...看看應該就知道這是怎麼運做的了(其實是懶得解釋XD)。

小結

今天分析了兩種不同的頁面轉換方式,其實 Android 官方是建議第二種做法的,藉由 ViewModel 來更新資料,而不是在關閉頁面之後回傳資料,但是我認為關閉頁面回傳資料還是有存在的價值的,一切端看使用當下的上下文而定(也就是大家很常說的 Context)。很遺憾的是第一種做法在 Jetpack Compose 中我看到的做法都有點麻煩,希望之後會有好用一點的 API 。

另外,在本文的後面還介紹了 "Scope" 的這個概念,在單一 Activity 的架構下這個概念非常重要,沒有好好使用的話,就會開始想各種 workaround 來把之前的狀態清掉(因為實際上使用同一個 ViewModel),對長期維護來說會是一個很大的負擔。


上一篇
Jetpack Compose - Stateful and Stateless
下一篇
專案檔案結構
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30

尚未有邦友留言

立即登入留言