Android multiple back stacks導航的幾種實現

語言: CN / TW / HK

Android multiple back stacks導航

談談android中多棧導航的幾種實現.

什麼是multiple stacks

當用戶在app裡切換頁面時, 會需要向後回退到上一個頁面, 頁面歷史被儲存在一個棧裡. 在Android裡我們經常說"back stack".

有時候在app裡我們需要維護多個back stack, 比較典型的場景是bottom navigation bar或者側邊的drawer.

如果需求要求在切換tab的時候儲存每個tab上的歷史, 這樣當用戶返回的時候還是返回到上次離開的地方, 這種就叫multiple stacks.

(與之對應的single stack行為是返回之後回到了tab首頁.)

本文之後的內容都以bottom bar的多棧導航為例.

multi-stack的需求

首先還是討論一下需求.

當bottom bar不支援多棧時, 當點選切換底部tab, 再返回原來的tab, 所有在之上開啟的頁面都會消失, 只有第一層(根)頁面會顯示.

這也是可以接受的, 甚至在material design裡面作為Android平臺的預設行為被提及: material design

但它同時也說了, 如果需要的話, 這個行為是可以被改的.

如果你想保留使用者在上個tab看過的內容狀態, 很可能就需要做multi-stack, 每個tab上的棧是獨立退出, 分別保留的.

通常, 這還不是僅有的需求.

如果使用者點選已選中的tab, 需要重置這個stack嗎?

需要定製轉場動畫嗎?

需要保留tab歷史嗎? 比如從tab A -> B -> C, 在C的根頁面back, 是想回到B還是回到home tab?

在bottom navigation的預設實現中(用Android Studio建立一個Bottom Navigation的新專案), 在非home tab的根節點, 點選back, 總是先回到home tab, 再次back才會退出app. 因為這樣是符合固定start destination的原則的. 使用者在開啟後和關閉前, 看到的是同一個頁面.

但是如果你有儲存tab歷史的需求, 也可以考慮如何定製它.

當你更進一步地涉及到實現層面, 你會遇到更多實際操作的問題, 比如怎麼把一個詳情頁push到一個指定的棧, 如何pop destination.

讓我們列一下幾個需求點:

  • 維護多個棧.
  • 切換tab: 手動點選tab或者其他tab內的互動. 比如dashboard跳轉到某個內容tab.
  • Push/pop destinations.
  • 重選(reselect)tab會重置該棧. (clear history.)
  • 轉場動畫
  • tab歷史.

技術背景

要進行導航的選型, 首先確定一下你的"destination"是什麼.

是composable還是fragment, 或者乾脆是View, 解決方案可能有很大的不同.

以這篇文章的scope來說, 我們就關注一個傳統的android app, 用Activity和Fragment實現. 所以bottom tab上的tab內容, 是不同Fragment.

Fragment lifecycle

為什麼這裡要提一下Fragment的生命週期呢?

因為fragment的生命週期和它的ViewModel緊密關聯, 進一步關係到了在導航過程中我們是否需要關注fragment的狀態恢復和重新整理.

首先複習一下Fragment生命週期的回撥: 什麼時候onDestroy會被呼叫? - 當replacetransaction沒有addToBackStack(). - 當fragment被removed或者被popBackStack().

replacetransaction加上addToBackStack(), 舊的fragment會被壓入棧, 但它的生命週期只調用到onDestroyView(). 當在它之上的其他fragment pop出來以後, 舊的這個fragment例項依然是同一個, 它重新顯示, 重新從onCreateView()開始走.

這是我們在single back stack下預期的行為.

ViewModel的生命週期和Fragment是對齊的, 也即Fragment的onDestroy()呼叫時, ViewModel的onCleared()被呼叫.

在導航切換目的地時, 如果fragment被destroy了, 我們可以儲存一些關注的變數在saved instance bundle或者SavedStateHandle裡, 用於之後的狀態恢復. 但是如果fragment沒有被destroy, 我們可以省下不少力氣做這些狀態恢復.

所以理想的狀態是, 壓棧後的fragment例項不會被銷燬重建.

Navigation庫/可能的方案

為了比較不同的解決方案, 我把一些sample放在了一起: https://github.com/mengdd/bottom-navigation-samples

Jetpack navigation component

官網: https://developer.android.com/guide/navigation

即便在FragmentManager的文件 裡, 也建議開發者使用jetpack的navigation library來處理app的navigation.

multiple back stack的支援是Navigation 2.4.0-alpha01Fragment 1.4.0-alpha01才加的.

試了下這個 demo, 程式碼非常簡單, 我們基本什麼都不用做.

關於這裡面的思想可以看這篇文章: https://medium.com/androiddevelopers/multiple-back-stacks-b714d974f134

優點: - 最知名, 畢竟是官方的庫. - 支援型別安全的引數. - NavigationController支援pop到一個指定的destination. - 可以和Compose navigation庫一起使用.

缺點: - Multi-stack的支援: 當切換tab時, 前一個tab上的所有fragment都會被destroy, 當返回tab時棧內fragment會重建. 所以狀態會丟, 頁面可能會重新整理. - 每個tab都需要是一個內嵌的navigation graph, 如果有一些common的destination, 需要include到每個graph中去. xml的navigation檔案感覺很像一個大塊的樣板程式碼.

FragmentManager

如果我們想做更多的定製, 我們可以考慮用FragmentManager的新APIs自己手動實現.

在文件中doc 介紹的:

FragmentManager allows you to support multiple back stacks with the saveBackStack() and restoreBackStack() methods. These methods allow you to swap between back stacks by saving one back stack and restoring a different one.

這是navigation component實現中實現多棧導航使用的方法. 所以也可以解釋為什麼切tab的時候fragment都被銷燬了.

saveBackStack() works similarly to calling popBackStack() with the optional name parameter: the specified transaction and all transactions after it on the stack are popped. The difference is that saveBackStack() saves the state of all fragments in the popped transactions.

優點: - 精細控制, 開發者獲得更多控制, 也更明白到底是怎麼回事. - 如果我們當前專案沒有采用任何navigation library, 都是手動跳轉, 採用這種方法我們就不用考慮遷移navigation.

缺點: - 要寫很多fragment transaction的樣板程式碼. - 和navigation components一樣: 多棧實現中在切換棧時, 在舊的tab上的Fragments會被銷燬, 返回時全部重建.

Enro

https://github.com/isaac-udy/Enro

對於多module的大型專案來說, 我很推薦這個庫, 它可以幫助我們解耦module間的依賴.

multi-stack的demo

優點: - 基於註解, 所以要寫的程式碼很少, 導航使用很方便. - 多module專案解耦. - 傳型別安全的引數和返回結果都很容易. - 可以在ViewModel中獲取navigation handle, 獲取引數. - 支援Compose做節點. - 對Unit Test也有一個輔助測試的依賴. - multi-stack support: 保持了切換tab的時候fragment例項.

缺點: - 可能目前還不是很知名. 需要說服別人學和採用這個. - Fragment的multi-stack: 不能rest stack到根節點. (嘗試了一下定製這個行為, 有點難).

Simple-stack

https://github.com/Zhuinden/simple-stack

這裡推薦一下這個庫作者的文章Creating a BottomNavigation Multi-Stack using child Fragments with Simple-Stack. 關於如何用simple-stack來做multi-stack.

最開始作者展示了一個不用任何庫, 僅用child fragments來實現的版本.

這是手動實現的另一種思想了.

後來才引入了用simple-stack做的demo 這是採用了原作者提供的sample, 比較簡單, 試了一下以後我發現可能還需要新增更多的程式碼, 來做實際的應用. 比如詳情頁需要獲得某個tab的local stack的例項, 從而把自己push上去.

優點: - 作者在社群十分活躍, 有很多影片和文章介紹simple-stack這個庫. 所以社群支援挺好. - multi-stack support: 保持了切換tab的時候fragment例項. - 支援控制和清空棧的歷史. - 有compose的擴充套件.

缺點: - 如果你的bottom bar當前是在activity的佈局裡, 你需要把bottom bar和相關的東西都挪進一個RootFragment, 作為總的節點. - 作者提供的multi-stack sample還非常簡單, 需要寫更多的程式碼來或者當前正確的棧來做push和pop操作. 不瞭解這個庫可能會寫得很醜.

其他庫

還有一些庫, 不是通用的navigation解決方案, 而只是為多棧導航設計的小庫. 比如:

  • https://github.com/DimaKron/Android-MultiStacks
  • https://github.com/JetradarMobile/android-multibackstack

這些庫都自帶sample.

優點: - 實現簡單, 只用幾個類. 如果我們想定製我們可以用這個程式碼. - 要改動的範圍可以限制在bottom navigation的部分, 而不是整體改變navigation方案.

缺點: - 這些庫都不是很出名, 有不再維護的風險. - 可能和其他的navigation方案不能相容, 比如Navigation Components. 需要考慮整體.

總結

android (fragment實現) multi-stack navigation的可能解決方案:

| 方案 | 流行 | 整體方案 | 活躍 | 支援清空棧 | Fragment被儲存, 不被銷燬 | 支援Multi-modules| Compose擴充套件 | | --- | --- | --- | --- | --- | --- | --- | --- | | Jetpack Navigation Components | 官方, 最出名 | Yes | Yes | Yes | No | Yes | Yes | | Fragment Manager | Android SDK | - | Yes | Yes | No | No | - | | Enro | Star: 188 | Yes | Yes | No | Yes | Yes | Yes | | Simple Stack | Star: 1.2k | Yes | Yes | Yes | Yes | Yes | Yes | | Child Fragments | Android SDK | - | Yes | Yes | Yes | No | - | | JetradarMobile/android-multibackstack | Star: 224 | No | No | Yes | No | No | - | | DimaKron/Android-MultiStacks | Star: 32 | No | Not sure | Yes | Yes | No | - |

注意:

  • 整體方案: 表示該方案可以用於app整體的navigation解決方案, 而不僅僅是解決multi-stack的問題.
  • Fragment被儲存, 不被銷燬: 當跳轉或者切tab時, 被壓入棧中的fragments不會被destroyed. 多棧支援的情況下, 儘管fragment被返回時都會被重建, 但是如果它不被銷燬, 我們就不需要做額外的工作來快取狀態.

References: