從今天開始,我正式開始分析Navigation庫的基本使用和實現原理。其實本來沒打算學習這個庫,但是最近組內在整理頁面之間的跳轉流程,使其能夠組件化。而恰好的是,我們業務的頁面跳轉幾乎都是以單Activity + Fragment的方式實現的,我就提了一個建議,是不是可以研究一下Navigation庫在我們業務場景的落地可能性呢?於是,我就先需要調研這個Navigation庫的現狀,以及它的優缺點,再去評估是否可以落地到業務中去。
如今,我已經成功輸出一份Navigation的調研文檔,給到了大佬們,當然我對其的使用方式和實現原理基本都瞭解過。最後,想着自己好像很久沒有寫博客,於是寫下此文記錄一下,也供大家學習參考。
本文內容較多,將分爲上下兩篇來分析。上篇的主要內容是:
- Navigation的基本使用。
- Navigation的基本結構,以及核心類的基本解釋。
- graph文件的分析。包括分析每個元素的含義,節點inflate的過程,以及NavDestination的含義
下篇的主要內容是:
- 頁面跳轉邏輯的實現。
- Navigator的分析。會重點分析FragmentNavigator。
- 如何自定義Navigator,以及如何給頁面傳參。
- Navigation的一些設計美學和“缺陷”。這裏的缺陷我打了引號,表示僅是我本人的想法,並不能代表Navigation的設計有問題。
大家從上述列舉內容中可以瞭解到,上篇主要是介紹基礎相關的內容,而下篇是重點分析Navigation的實現原理。
本文參考文章:
本文源碼參考都來自於2.3.5版本。
1. 概述
從官方文檔的介紹來看,Navigation庫主要目的是支持用戶進入和退出不同頁面的交互。用通俗的話來說,就是可以支持在一個Activity裏面,不同Fragment的切換,且還支持Activity之間跳轉。而Activity的跳轉系統默認就支持,所以這並不是Navigation的重點所在。我們在選擇和學習的時候,肯定是是看中了其切換Fragment的能力,這也是本文介紹的重點。
使用Navigation庫切換Fragment,主要是有如下幾個優勢:
- 能夠處理Fragment的事務,保證Fragment生命週期的正確性。
- 默認情況下,正確處理往返操作。
- 支持自定義Fragment的專場動畫。
- 支持deepLink的跳轉。
- 頁面之間可以自由的傳參,不僅是Parcelable和Serializable,任何數據類型都支持。
針對上面列舉的優勢,我重點補充一些第一點。Navigation在實現Fragment切換的時候,是通過FragmentTransaction的replace方法來實現,因此假設從一個Fragment A跳轉到Fragment B,那麼Fragment A生命週期會走到onDestroyView,當返回到Fragment A,此時又會重新走到onCreateView。這是萬萬不能接受的,因爲我們一般會在onCreateView方法做很多的初始化操作。針對此,網絡上一般有兩種解法:
- 重寫Fragment的onCreateView,加上相關判斷,使其只會初始化一次View。
- 重寫FragmentNavigator,使用show和hide方案來控制Fragment的切換。
我想說的是,上述兩種方案各有各的優缺點,分別如下:
方案 | 優點 | 缺點 |
---|---|---|
方案一 | 能保證Fragment生命周 期走到onStop,資源能 到釋放 |
頁面的狀態可能會出問題,比如說第二次 onCreateView其實用到的View其實是上一次的View, View的狀態很有可能會出問題。 |
方案二 | 不會出現在返回重新調 用onCreateView方法的 問題 |
由於是hide的,上一個Fragment的生命週期不會任何 變化,因此資源得不到有效的釋放。 |
從上面的分析內容來看,兩種方案都各有利弊。那麼有沒有比較好的方式,既能保證生命週期正確,又不會引入其他的問題呢?當時是可以的,其實我們可以把方案二改造一下,使用setMaxLifecycle來改變Fragment的生命週期,從而兼容兩種方案的優缺點。此方案的具體實現和分析會在下篇介紹,這裏就先賣一個關子。
除了功能上的優勢,我覺得還有一個設計上的優勢,那就是將跳轉流程設計成一個可視化的方案,graph文件的存在不僅讓一個新人對一個完全陌生的頁面能夠進行快速理解,同時還將跳轉流程的實現配置化,使其後續的維護和擴展工作都能低成本的進行。
2. 基本使用
既然是手把手的教大家認識Navigation,我們得先弄懂Navigation到底是怎麼使用,因爲只有瞭解它的特性,才能更好的理解和分析其實現原理,更進一步的是,能夠學以致用。
我一直認爲源碼學習的目的不是爲了裝逼,而是有如下兩點好處:
- 對庫的實現原理理解的更加深入,當在使用庫的過程中出現了問題,可以很快的從源碼角度排查出問題原因所在,且給出有效的解決方案。
- 舉一反三,學以致用。我們可以把庫的一些設計思想和實現細節運用真實的業務場景當中,使我們的業務代碼能夠像官方庫一樣的優雅。
好了,扯的題外話有點多了,我們還是迴歸正題中來吧。本節主要介紹如下內容:
- Demo的效果展示。
- 準備工作--介紹和定義graph,NavHostFragment的使用。
- 幾種跳轉邏輯的使用--action跳轉和deepLink跳轉,以及activity的跳轉。
- 特別注意:
popUpTo
和popUpToInclusive
跟傳統跳轉的區別。
(1). Demo 展示
在正式介紹正式用法之前,我們先使用一個Demo真實的感受一下效果,效果圖如下:
我先介紹一下這個Demo的結構。這個Demo有三個Fragment,分別是NavConatainerFragment
、NavChildFragmentA
和NavChildFragmentB
。其中NavConatainerFragment
使用Navigation可以跳轉到另外兩個Fragment去,同時從另外兩個Fragment也可以成功返回到NavConatainerFragment
。
該Demo的完整代碼可以參考:NavigationDemo。
(2). graph文件的介紹
graph文件在Navigation中,非常的重要。因爲它算是所有跳轉流程的配置文件,頁面之間的完整跳轉過程都能在此文件體現出來,包括後續在使用代碼進行動態跳轉時,其規範性和合法性都需要參考此文件。所以,創建和定義graph文件是我們學習Navigation的第一步。
graph文件在工程中是以xml的形式存在的,所以需要創建在res目錄下。基本創建過程是這樣的:
- 在
res
目錄下,先創建一個名爲navigation
的目錄。- 然後右擊
navigation
目錄,選擇New
->Navigation Resource File
,這樣就能創建一個graph文件。
在初次創建的graph文件中,基本內容如下:
對於截圖中的結構,我們熟悉到不能再熟悉了。我們可以從截圖中得到兩個信息:
- graph文件的根元素是navigation。
android:id
表示navigation
的唯一標識。這個唯一標識非常重要,在Navigation的世界中,不僅fragment和activity是destination(目的地),navigation也就是一個destination。至於什麼是NavDestination,我們後續會講。
除此之外,我重點介紹一下graph文件中的其他元素:
元素名稱 | 作用 |
---|---|
navigation | graph文件的跟元素,必須設置id 和startDestination 。當NavHostFragment加載graph文件時,會根據 startDestination 導航到指定的頁面上去。 |
action | 表示一個跳轉行爲,可以作爲navigation的子元素,也可以作爲其他 destination(fragment或者activity)的子元素,必須設置 id 和destination 屬性,其中id是提供給其他destination來尋找具體的跳轉行爲;destination表示 跳轉落到具體destination的id。除了這些屬性,還有 enterAnim 和exitAnim 用來定義頁面入場和退場的動畫,以及 popUpTo 和popUpToInclusive 用來處理循環跳轉的情況。 |
deepLink | 跟action類似,也表示一個跳轉行爲,可以作爲navigation的子元素,也可以 作爲其他destination(fragment或者activity)的子元素。可以通過設置 uri 或action 屬性來表示跳到哪個頁面。 |
fragment | navigation的子元素之一,表示一個頁面(在Navigation中,頁面可以用 destination來表示)。其中, id 屬性表示當前頁面的唯一標識,用以給action元素定義具體的落地頁; name 屬性表示具體的Fragment對應的完整路徑。 |
activity | navigation的子元素之一,表示一個頁面,跟fragment類似。 |
include | navigation的子元素之一,用於引入另外一個graph文件。該元素有利於graph 文件的獨立,從而便於跳轉流程的拆分和複用。 |
我在上表中列舉了常用的元素,我在這裏補充幾點:
- navigation元素的
startDestination
屬性設置的是對應fragment或者activity元素的id,表示當首次加載或者跳轉到該graph文件中去,默認跳到指定的頁面上去。前面已經說了,navigation本身就是一個destination
,跟fragment和activity是同一級的東西。但是navigation本身不承載Ui,所以它需要一個有UI的destination。- action元素當作爲fragment元素的子元素時,表示它只是一個局部action,僅限它所屬fragment元素對應的Fragment頁面才能使用;當action元素作爲navigation的子元素時,表示它是一個全局action,它所屬navigation元素下所有的fragment和action都可以使用。且其的
destination
屬性設置的是對應fragment或者activity元素的id。- deeLink元素可以設置在兩個地方,分別是:作爲fragment和activity元素的子元素;作爲navigation的子元素。這兩個地方表示含義是不一樣的。其中,當作爲fragment和activity元素的子元素時,表示其他頁面可以使用對應的鏈接跳到它所屬的fragment和activity頁面,該鏈接就是在這裏配置的
deepLink
;當作爲navigation的子元素時,表示其他頁面可以使用對應的鏈接跳到它所屬graph的startDestination
頁面。注意,deepLink跳轉方式並不支持轉場動畫,如果有需要,需要自行定義。- 如果我們想要從一個graph文件中的頁面跳轉到另一個graph文件的某一個頁面,必須要在第一個graph文件使用
include元素
引入另外一個graph文件。
與此同時,這裏我只列舉了部分的元素,還有一些不怎麼常用的元素並沒有體現出來。例如:
- dialog元素:它本身頁面一個destination,也就是Navigation支持我們從一個頁面跳轉到一個Dialog裏面去。但是我本人不推薦使用這種方式來跳轉,因爲我對Navigation抱有的態度是:不可不用,也不能全用。
- argument元素:它可以作爲fragment或者activity元素的子元素,表示給該頁面傳遞指定的參數。也是如此,我本人不推薦使用該元素給頁面傳參,因爲它的侷限性太大了,我們還有其他的方式進行靈活的傳參,這個在下篇內容會介紹到。關於此元素的更多信息,大家可以參考官方文檔:在目的地之間傳遞數據。
關於graph文件的含義,已經介紹的差不多了。這裏我以上面的Demo爲例,來直觀的感受graph文件的定義:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/default_graph"
app:startDestination="@id/fragment_container">
<fragment
android:id="@+id/fragment_container"
android:name="com.example.navigationdemo.NavContainerFragment">
<action
android:id="@+id/action_to_child_a"
app:destination="@id/fragment_nav_child_a" />
<action
android:id="@+id/action_to_child_b"
app:destination="@id/fragment_nav_child_b" />
</fragment>
<fragment
android:id="@+id/fragment_nav_child_a"
android:name="com.example.navigationdemo.NavChildFragmentA" />
<fragment
android:id="@+id/fragment_nav_child_b"
android:name="com.example.navigationdemo.NavChildFragmentB" />
</navigation>
在default_graph
中,navigation元素下面只有一種子元素--fragment
。在這裏,我補充幾點:
- 如果想要一個Fragment或者Activity可以被其他頁面跳轉到,必須要在graph文件裏面申明。比如,這裏的
NavChildFragmentA
和NavChildFragmentB
,雖然它倆不會跳轉到其他頁面,但是自身會作爲其他業務的落地頁,所以也得在文件中申明。- 這裏給NavContainerFragment定義了兩個action,分別是跳轉到
NavChildFragmentA
和NavChildFragmentB
這兩個頁面的。且定義的是局部action。在可視化中,一個destination表示一個節點,一個局部action就表示一條線。當節點數量和action數量達到一定的程度,那麼就會構造成爲一個圖。這也是爲啥跳轉流程的配置文件又被稱爲graph文件呢?從這裏就可以得到。比如說,下圖就是我們Demo的效果:
(3). NavHostFragment的介紹
當我們定義好了graph文件,這表示我們已經構造好了完整的跳轉流程,那麼由誰來處理和實現跳轉流程呢?那就是本小節的主角--NavHostFragment。
NavHostFragment作爲Fragment的一個實現類,自然繼承了Fragment的特性。所以,要想真正使用NavHostFragment,必須將其加載到Activity上去。而NavHostFragment的加載可以分爲兩種,分別是:動態加載和靜態加載。聽上去跟普通Fragment的加載沒啥差別?其實還是很大的差別,我們來看看具體的代碼實現,先來看看靜態加載:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/default_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>
這裏我們需要注意幾點:
- 使用的是
FragmentContainerView
來加載Fragment。有人會問,傳統的fragment也可以嗎?當然也是可以的,只不過會丟失很多的特性,比如說Fragment的轉場動畫可能會出問題。app:defaultNavHost
設置爲true,表示當前系統的back事件優先由NavHostFragment來處理。app:navGraph
表示需要加載的配置文件。經過設置這個屬性,我們在graph文件配置的信息都生效了,之後我們就在對應Fragment中愉快的使用對應action跳轉到對應Fragment了。
關於app:defaultNavHost
和 app:navGraph
的實現原理,後續會專門分析,這裏就不贅述了。
然後,我們再來看一下動態加載的實現。動態加載分爲兩步,首先要把FragmentContainerView的三個屬性都刪除掉:android:name
、app:defaultNavHost
和app:navGraph
,如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
然後使用代碼動態加載Fragment:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val hostFragment = NavHostFragment.create(R.navigation.default_graph)
supportFragmentManager.beginTransaction()
.add(R.id.nav_host_fragment_container, hostFragment)
// 只有設置這個屬性,NavHostFragment 才能成功攔截系統的back事件。
.setPrimaryNavigationFragment(hostFragment)
.commitAllowingStateLoss()
}
}
動態加載Fragment的實現,我們需要注意如下幾點:
- 使用NavHostFragment的create方法創建對象時,需要傳一個graph文件id,表示graph文件需要運用到該Fragment。create方法實現原理其實就是往Fragment的arguments添加一個graph文件id參數,以便Fragment在合適的時機解析和運用其中的配置。
- 需要調用
setPrimaryNavigationFragment
方法,並且將NavHostFragment傳進去,表示當前系統的back事件都由該Fragment處理。如果不調用該方法,便不能在NavHostFragment內部正確處理往返操作,這一點需要特別注意。
(4). 頁面跳轉
當我們準備好了graph文件和NavHostFragment之後,就可以進行Fragment和Activity跳轉。本小節的主要內容如下:
- action跳轉及其注意事項。
- deepLink跳轉及其注意事項。
- 使用Safe Args進行跳轉。
- 如何進行標準化傳參。
(A). action跳轉
在前面介紹graph文件時,我們已經知道,我們可以給每個Fragment設置很多的action,例如下面的代碼:
<fragment
android:id="@+id/fragment_container"
android:name="com.example.navigationdemo.NavContainerFragment">
<action
android:id="@+id/action_to_child_a"
app:destination="@id/fragment_nav_child_a" />
<action
android:id="@+id/action_to_child_b"
app:destination="@id/fragment_nav_child_b" />
</fragment>
這裏我們就給NavContainerFragment設置了兩個action,分別是跳轉到NavChileFragmentA
和NavChildFragmentB
。
那麼我們怎麼通過action來實現跳轉呢?那就是依靠NavController來實現。在Navigation中,獲取NavController的對象非常簡單,Java和kotlin的方式有所不同,分別如下:
Kotlin:
* Fragment.findNavController()
* View.findNavController()
* Activity.findNavController(viewId: Int)
Java:
* NavHostFragment.findNavController(Fragment)
* Navigation.findNavController(Activity, @IdRes int viewId)
* Navigation.findNavController(View)
我們從NavController的獲取方式來看,似乎這個NavController跟某一個View有關係,猜測的沒錯。這個NavController以tag的形式存儲在NavHostFragment的根View中,這個Tag的id名稱就是nav_controller_view_tag
,參考NavHostFragment中的代碼實現:
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
// ......
Navigation.setViewNavController(view, mNavController);
// When added programmatically, we need to set the NavController on the parent - i.e.,
// the View that has the ID matching this NavHostFragment.
if (view.getParent() != null) {
mViewParent = (View) view.getParent();
if (mViewParent.getId() == getId()) {
Navigation.setViewNavController(mViewParent, mNavController);
}
}
// ......
}
當拿到NavController對象時,就可以直接進行跳轉了,實現代碼如下:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mViewGroup = view.findViewById(R.id.viewGroup)
addViewWithClickListener("跳轉到NavChildFragmentA") {
findNavController().navigate(R.id.action_to_child_a)
}
addViewWithClickListener("跳轉到NavChildFragmentB") {
findNavController().navigate(R.id.action_to_child_b)
}
}
代碼非常的簡單,直接調用NavController的navigate方法,然後傳一個action的id即可。如此便實現了頁面的跳轉,不過這裏還需要注意幾點:
- 這個action 只能用自身Fragment的局部action,或者當前fragment所在graph文件中的全局action。使用其他地方的action會崩潰。
- action 都是在graph文件靜態寫死的,如果我們有動態的需求怎麼辦呢?其實有三個方法:第一種方式就是,在配置文件寫出當前fragment所有的action;第二種方式就是,通過deepLink來跳轉,這個我們馬上就講解;一般前面兩種方式基本能覆蓋絕部分的場景,但是不排除有些奇葩邏輯,需要根據動態下發的數據,跳轉到一個特殊的頁面,此時可以動態給當前Fragment添加一個action,然後在進行跳轉。如下代碼:
val newId = View.generateViewId()
findNavController().currentDestination?.putAction(newId, R.id.fragment_nav_child_b)
addViewWithClickListener("跳轉到NavChildFragmentB") {
findNavController().navigate(newId)
}
(B). deepLink跳轉
要想使用deepLink跳轉到指定的Fragment,要分爲兩步進行,分別如下:
- 給指定的Fragment創建一個deepLink。表示外部可以使用該deepLink跳轉到自己。
- 外部調用NavController的navigate方法,傳遞指定的deepLink。
具體實現代碼如下,假設我們給NavChildFragmentB創建了一個deepLink:
<fragment
android:id="@+id/fragment_nav_child_b"
android:name="com.example.navigationdemo.NavChildFragmentB">
<!-- 創建deepLink,使外部能夠該鏈接能夠跳進來-->
<deepLink app:uri="http://www.jade.com" />
</fragment>
然後我們可以通過如下代碼進行跳轉:
addViewWithClickListener("使用deepLink跳轉到NavChildFragmentB") {
findNavController().navigate("http://www.jade.com".toUri())
}
當我們點擊這個View的時候,就會通過deepLink跳轉到NavChildFragmentB
。是不是非常的簡單呢?不過,這裏我們需要注意如下幾點:
- 在給一個頁面創建deepLink的時候,千萬不要落下host,即如上的
http://
。因爲我們不加host的話,那麼在匹配的時候,僅接受host爲https://
和http://
。比如說,上面我們把NavChildFragmentB
的deepLink配置爲www.jade.com
,外部只能使用https://www.jade.com
和http://www.jade.com
跳轉,而直接使用www.jade.com
會發生崩潰。這是一個隱藏邏輯,需要特別注意。- deepLink的匹配規則遵循正則表達式,更多的細節可以參考官方文檔:爲目的地創建深層鏈接。
- 如果想要使用deepLink傳參,可以在下一個Fragment的
arguments
裏面獲取一個key爲android-support-nav:controller:deepLinkIntent
的Intent,然後從中獲取Uri就能拿到相關參數。將deepLink放到arguments的代碼如下:
public void navigate(@NonNull NavDeepLinkRequest request, @Nullable NavOptions navOptions,
@Nullable Navigator.Extras navigatorExtras) {
NavDestination.DeepLinkMatch deepLinkMatch =
mGraph.matchDeepLink(request);
if (deepLinkMatch != null) {
NavDestination destination = deepLinkMatch.getDestination();
Bundle args = destination.addInDefaultArgs(deepLinkMatch.getMatchingArgs());
if (args == null) {
args = new Bundle();
}
NavDestination node = deepLinkMatch.getDestination();
Intent intent = new Intent();
intent.setDataAndType(request.getUri(), request.getMimeType());
intent.setAction(request.getAction());
// 這裏將deepLink放到arguments中去。
args.putParcelable(KEY_DEEP_LINK_INTENT, intent);
navigate(node, args, navOptions, navigatorExtras);
} else {
throw new IllegalArgumentException("Navigation destination that matches request "
+ request + " cannot be found in the navigation graph " + mGraph);
}
}
上面我們只介紹了uri屬性,其實deepLink還有三個屬性,分別如下:
app:action
: 是deepLink的組成部分之一,爲字符串類型,如果不爲空,需要action相同才能成功匹配。app:mimeType
: 是deepLink的組成部分之一,媒體數據類型,需要類型相關才能成功匹配。比如說"image/jpg"與"image/*"匹配。android:autoVerify
: 要求 Google 驗證您是相應 URI 的所有者。如需瞭解詳情,請參閱驗證 Android 應用鏈接。這個我們在Fragment跳轉中一般不會用到,所以可以忽略。
(C). Safe Args跳轉
Safe Args是一個gradle plugin,所以單獨引入一下。配置如下,首先在project的build.gradle文件引入對應的plugin:
dependencies {
// ......
// 引入sage args的plugin
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
}
然後在自己App Module的build.gradle文件中應用對應的plugin即可:
plugins {
id "androidx.navigation.safeargs"
}
在引入safe args的plugin之後,我們在編輯graph文件的時候,會以Fragment爲維度,對應生成相關類,以便我們調用。比如說:
<fragment
android:id="@+id/fragment_container"
android:name="com.example.navigationdemo.NavContainerFragment">
<action
android:id="@+id/action_to_child_a"
app:destination="@id/fragment_nav_child_a" />
</fragment>
我們給NavContainerFragment
添加了一個跳轉到NacChildFragmentA的action,safe args plugin對應的生成一個NavContainerFragmentDirections
類,這個有一個名爲actionToChildA
的方法,用以我們調用navigate
方法進行跳轉,如下:
addViewWithClickListener("使用SafeArgs跳轉到NavChildFragmentB") {
findNavController().navigate(NavContainerFragmentDirections.actionToChildA())
}
至於其中的原理,其實是非常的簡單。當我們在graph文件給Fragment添加action時,plugin會自動給每個Fragment生成一個名爲Fragment名稱+Directions
的類,這個類裏面有很多的靜態方法,方法名稱跟action id有關,同時方法返回類型是NavDirections
。這個類的作用也非常的簡單,類似於一個wrapper類,用來包裝action id 和argument。
所以,關於safe args的實現非常簡單。就是通過plugin掃描graph文件,然後給每個Fragment生成能夠輔助跳轉的wrapper類。這樣有一個好處就是,你不會因爲使用了錯誤的action導致App崩潰,不過我本人不推薦使用此方法來進行跳轉:
- gradle plugin本身依賴於gradle版本,生產環境中gradle可能不能完全兼容safe args的plugin,從而導致編譯失敗,或者生成的輔助類不能符合預期。
- 包大小。safe args在編譯過程中,生成了很多的類,其實我們跳轉頁面,本質上只需要一個action id,safe args生成那些的類完全沒必要。
- 實現邏輯不透明。safe args生成的類在工程裏面是找不到的,就很難直接看到其中的實現邏輯,如果出問題,也很難排查。PS:如果想要對應的生成類,目前我能找到的辦法就是反編譯Apk,這無疑增加了我們排查問題的難度。
至於safe args的好處,我覺得不是很重要。因爲如果你使用了錯誤的action,崩潰問題一般會在開發階段就能出現,所以肯定不會帶到線上去。至於其他的參數傳遞問題,這個safe args自身就不能完全避免,因爲它只能檢測能放到Bundle的參數,其他參數也是無能爲力。所以,我覺得目前的safe args還是比較雞肋的,簡單的項目可以嘗試一下。
(D). 如何進行標準化傳參呢?
需要傳遞的傳參一般分爲兩種:
- 可以序列化的參數,例如原始數據類型,以及Parcelable和Serializable類型。
- 不可序列化的參數,比如不能實現Parcelable和Serializable接口的。
我們直接分情況來討論一下。首先可以序列化的參數,傳遞起來非常簡單,因爲NavController
的navigate
方法本身有一個Bunble參數,可以用來傳遞參數:
而這個Bundle無疑是放到Fragment的argument裏面去,有興趣可以提前看看FragmentNavigator
的navigate
方法。後續,我們也會重點分析它。
不可序列化的參數,可以通過navigate方法的另一個參數來傳遞,名爲Navigator.Extras
。Navigator.Extras是一個接口,我們可以自行實現接口,然後傳遞自己的參數,最後在FragmentNavigator
裏面拿到這個接口裏面的參數,傳遞給Fragment,如下:
@Override
public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
@Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
// ······
if (navigatorExtras instanceof Extras) {
Extras extras = (Extras) navigatorExtras;
for (Map.Entry<View, String> sharedElement : extras.getSharedElements().entrySet()) {
ft.addSharedElement(sharedElement.getKey(), sharedElement.getValue());
}
}
// ······
}
這是FragmentNavigator
的一個實現,我們需要注意如下幾點:
FragmentNavigator
內部的Navigator.Extras
實現類名爲FragmentNavigator.Extras
,這個實現類僅支持傳遞一些View-String的鍵值對。主要是爲了處理共享元素的case。- 我們可以依葫蘆畫瓢,參考
FragmentNavigator.Extras
的實現,實現我們自己的Extras類,不過這個得依賴於自定義Navigator
。自定義Navigator
在下篇內容裏面會重點介紹。
(5).popUpTo和popUpToInclusive
在正式介紹popupTo
之前,我們先來介紹一下現在action跳轉的方式:
- enter & exit:就是傳統的進入一個頁面,和退出一個頁面。比如說,上面我們配置的action,都是通過此方式進入的。此方式最大的特點就是,在進入一個頁面的時候,不用管此頁面是否已經有實例,都會創建一個新的對象,放入返回棧中。
- popEnter & popExit:當進入一個頁面的時候,先判斷當前返回棧是否有該頁面的實例,如果有的話,那就直接清空當前實例以上的所有頁面。如果
popUpToInclusive
設置爲true,那麼也會把自身的實例給清空。
上面解釋了很多,那麼怎麼來使用呢?popUpTo其實是action元素的一個屬性,我們來看一下:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/default_graph"
app:startDestination="@id/fragment_container">
<!-- 省略部分的代碼-->
<fragment
android:id="@+id/fragment_nav_child_a"
android:name="com.example.navigationdemo.NavChildFragmentA">
<action
android:id="@+id/action_child_a_to_b_by_popUp"
app:destination="@id/fragment_nav_child_b"
app:popUpTo="@id/fragment_nav_child_b"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/fragment_nav_child_b"
android:name="com.example.navigationdemo.NavChildFragmentB">
<!-- 創建deepLink,使外部能夠該鏈接能夠跳進來-->
<deepLink app:uri="www.jade.com" />
<action
android:id="@+id/action_child_b_to_a_by_popUp"
app:destination="@id/fragment_nav_child_a"
app:popUpTo="@id/fragment_nav_child_a"
app:popUpToInclusive="true" />
</fragment>
</navigation>
這裏我給兩個ChildFragment都新增了一個action,跟傳統的action不一樣的是,每個action都新增兩個屬性,分別是:app:popUpTo
和app:popUpToInclusive
。上面已經簡單解釋過這兩個屬性了,這裏我在解釋一下:
- app:popUpTo:跳轉到目前頁面的時候,會先在返回棧尋找是否已經有該頁面的實例,如果有的話,需要將該頁面之上的頁面都清空。
- app:popUpToInclusive:如果只配置app:popUpTo,是不會清空頁面本身的實例,那麼在返回棧中就有該頁面的兩個實例,這個是不符合預期的。所以此時需要將
app:popUpToInclusive
設置爲true,表示可以清空頁面自身的實例。
上面的解釋描述可能比較抽象,我用一個實例來解釋一下。假設,我從A頁面跳轉到B頁面,再從B頁面跳轉到A頁面。如果不使用popupTo屬性的話,返回棧的實例是:A' B A'';如果我們設置popupTo,返回棧的實例是:A' A'',因爲從B跳轉到A時,我們會清空A以上的實例,那個B的實例自然就被清空了;如果我們同時配置了popupTo和popUpToInclusive,那麼返回棧中的實例是:A'',需要注意的是,這裏是A'',也就是新創建的實例。
從上面的描述中,我們可以看出來,這個兩個屬性搭配有點Activity的singleTask啓動模式的味道,但是也不完全一樣。這也是我對它有異議的地方,就目前而言,這個屬性存在的宗旨還是比較好的,但卻像是一個半成品,比如說:
- 當ABA的情況,爲啥第二個A頁面是一個新的實例呢?
- 爲啥必須配置popUpToInclusive,才能保證只有一個A實例呢?
除此之外,action還有一個屬性就是:launchSingleTop
。這個工作原理跟action的singleTop很像。基本解釋如下:
如果當前棧頂元素是目標頁面,那麼不會重新創建新的實例;如果當前棧頂元素是不是目標頁面,那麼就會重新創建新的實例。注意的是,該屬性不能保證返回棧中只有一個實例。
3. 基本結構
關於Navigation的使用介紹的差不多了,這裏我們即將進行正式的源碼分析。但是在分析之前,我們先要對Navigation的執行流程有一個整體的輪廓,這樣對我們理解源碼具體的含義有很大的幫助。
本小節主要有如下兩個部分的內容:
- Navigation的執行流程。
- 流程中的核心類解釋。
(1). 執行流程
我們先來看看執行流程的內容。我將執行流程的內容分爲如下幾步:
- NavHostFragment加載過程。
- 頁面跳轉流程。
- 頁面返回流程。
(A). NavHostFragment的加載
關於NavHostFragment的解釋,前面已經簡單解釋過了。不過這裏詳細的分析,它加載過程中,做了哪些事情。
上面的流程圖解釋了在NavHostFragment加載過程中所做的事情,主要分爲三個階段。這裏,我對上面的內容做一些補充:
- onCreate方法裏面調用了onCreateNavController方法,而這個方法主要是給NavController添加了很多的Navigator。當NavController創建的時候,會默認添加
NavGraphNavigator
和ActivityNavigator
;而在onCreateNavController方法裏面,添加了DialogFragmentNavigator
和FragmentNavigator
。其中FragmentNavigator
就是來處理Fragment之間的跳轉,這也是後續我們會重點講解的內容。- onCreate方法通過調用NavController的setGraph方法,給其設置graph id。這個過程就會觸發graph文件的解析,這個也是我們後續分析graph文件解析的開始點。
- setGraph不僅會觸發graph的解析,還會默認跳轉到graph文件的startDestination標記的頁面。
(B). 頁面跳轉流程
在此之前,我們知道,頁面跳轉主要是依靠NavController的navigate方法實現。可是,在前面我們僅僅停留在外部的調用層面,關於內部的調用流程,並沒有清晰的理解。
頁面跳轉流程中,多出來了一個Navigator。這個類主要是處理頁面跳轉和返回的具體邏輯,後續我們會正式介紹它。
(C). 頁面返回流程
頁面返回主要是涉及到Activity back事件,NavHostFragment會攔截back事件,然後根據自身的返回棧來處理。執行流程如下:
在這裏,我們看到了Navigator的popBackStack方法,這個方法是跟navigate方法對應的,此方法主要是爲了處理返回事件的邏輯。後續我們會重點分析的。
(2). 核心類含義
在整個Navigation框架中,有很多的核心類來輔助實現各種各樣的功能,在這裏,我們都對其中涉及到的類統一解釋一下,方便大家理解。
(A). NavHostFragment
這個Fragment是Navigation中的容器,理論上來說,所有需要被Navigation切換的Fragment,都必須是該Fragment的child。除此之外,該類內部還維護了Navigation的一些核心過程,比如說:
- 解析Graph文件。
- 創建和初始化
NavController
,這爲後續的頁面跳轉做好了準備。
(B). NavController
這個類可以理解爲頁面導航的控制器。我們在跳轉的時候,是直接拿到這個類的對象,然後傳遞對應的參數。一般來說,我們可以使用兩種方式來進行過跳轉。
- action跳轉。NavController在拿到我們傳遞的action id之後,會在graph中去尋找對應的頁面,如果找到了,就可以成功跳轉;如果找不到,就會直接崩潰。一般來說,找不到頁面的action要麼是無效的,要麼是非法的。
- deepLink跳轉。NavController會通過我們傳遞的信息,去匹配對應的頁面,如果匹配到多個頁面,會選擇匹配度最高的頁面;如果沒有匹配到,也會崩潰。
(C). NavDestination
在Navigation中,不同類型的頁面都被抽象成爲NavDestination
,NavDestination
分別抽象瞭如下幾個頁面:
頁面 | NavDestination實現類 | 對應graph文件的元素 |
---|---|---|
Activity | ActivityNavigator$Destination | activity元素 |
Fragment | FragmentNavigator$Destination | fragment元素 |
Diglog | DiglogNavigator$Destination | dialog元素 |
沒有具體的頁面 | NavGraph | navigation元素 |
上表中,需要特別注意的是NavGraph
。
NavGraph
沒有代表具體的頁面,在graph文件中,對應的是navigation
元素。這個一般用在嵌套試圖中,當我們在一個graph文件引入了一個graph文件,同時需要從這個graph文件中某一個頁面跳轉到另一個graph文件的頁面中去,會遇見它的身影。但是,這個對我們外部使用來說,都是透明的,不需要感知。
既然NavDestination
代表的是一個頁面,所以我們在graph文件給頁面定義的屬性,都能在NavDestination
中找到對應的字段。比如說在NavDestination
中有一個數組用來存儲action,表示該頁面可以跳轉的action。
同時,NavDestination
及其子類一般不是獨自存在的,而是需要搭配我們即將要說的Navigator
。
(D). Navigator
Navigator
直接翻譯是導航器的意思。顧名思義,頁面切換的真實邏輯都是在這個類進行維護。前面所說的NavController,其實就是根據傳入的action id或者deepLink找到對應的NavDestination
,然後通過NavDestination
對應的元素名稱找到對應的Navigator
,最後就是通過Navigator
來實現頁面的切換。
我們再來看一下Navigator
不同子類的含義。
名稱 | 對應的元素名 |
---|---|
ActivityNavigator | activity |
DialogFragmentNavigator | dialog |
FragmentNavigator | fragment |
NavGraphNavigator | navigation |
我們從上表中可以看到,每個Navigator
都對應了一個graph元素。所以,如果我們需要定義Navigator,需要標註一下對應的元素名稱,那怎麼來標註呢?直接使用Navigator.Name
註解即可:
然後在graph文件就能些對應的元素名稱了。
(E). 其他類
除此之外,還有其他的類,我就簡單的介紹一下。
名稱 | 作用 |
---|---|
NavAction | 對於graph文件中action的封裝。 |
NavDeepLink | deepLink的封裝。 |
NavOptions | 對應action一些屬性進行封裝,比如說enterAnim和exitAnim。 |
NavInflater | 解析graph文件中的元素。 |
這些類中,我們需要特別注意一下NavInflater
,這個類還是比較重要的,我們馬上就要分析它。Navigation是怎麼把graph文件中的元素轉化成爲對應的代碼中的各種實體類,就是NavInflater
在幫忙處理的。
4. NavInflater解析過程
前面說過,graph文件就是一個跳轉流程的配置文件,配置文件中定義了每個頁面之間的跳轉關係。那麼這種跳轉關係是怎麼生效的呢?我們在使用NavController進行跳轉的時候,配置文件是怎麼限制本次跳轉是符合預期的呢?這一切都要從NavInflater
開始說起。
我們先來看NavInflater
的解析過程。首先,解析的開始是在NavHostFragment的onCreate
方法裏面:
public void onCreate(@Nullable Bundle savedInstanceState) {
/ ······
if (mGraphId != 0) {
// Set from onInflate()
mNavController.setGraph(mGraphId);
} else {
// See if it was set by NavHostFragment.create()
final Bundle args = getArguments();
final int graphId = args != null ? args.getInt(KEY_GRAPH_ID) : 0;
final Bundle startDestinationArgs = args != null
? args.getBundle(KEY_START_DESTINATION_ARGS)
: null;
if (graphId != 0) {
mNavController.setGraph(graphId, startDestinationArgs);
}
}
// ······
}
在setGraph
方法裏面其實做了兩件事:
- 創建NavInflater對象,並且解析graph文件。
- 默認導航到graph文件中使用
startDestination
屬性標記的頁面。
關於第二件事,我們這裏不進行分析,先看看NavInflater的解析過程。直接看inflate方法:
private NavDestination inflate(@NonNull Resources res, @NonNull XmlResourceParser parser,
@NonNull AttributeSet attrs, int graphResId)
throws XmlPullParserException, IOException {
// 1. 首先根據節點名稱,讓對應的Navigator創建NavDestination。
Navigator<?> navigator = mNavigatorProvider.getNavigator(parser.getName());
final NavDestination dest = navigator.createDestination();
dest.onInflate(mContext, attrs);
final int innerDepth = parser.getDepth() + 1;
int type;
int depth;
// 2. 解析該NavDestination下面的所有子元素
while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
&& ((depth = parser.getDepth()) >= innerDepth
|| type != XmlPullParser.END_TAG)) {
if (type != XmlPullParser.START_TAG) {
continue;
}
if (depth > innerDepth) {
continue;
}
final String name = parser.getName();
if (TAG_ARGUMENT.equals(name)) {
inflateArgumentForDestination(res, dest, attrs, graphResId);
} else if (TAG_DEEP_LINK.equals(name)) {
inflateDeepLink(res, dest, attrs);
} else if (TAG_ACTION.equals(name)) {
inflateAction(res, dest, attrs, parser, graphResId);
} else if (TAG_INCLUDE.equals(name) && dest instanceof NavGraph) {
// 如果當前節點是include,那麼就遞歸解析新的graph文件。並且
// 將include的NavGraph作爲本NavGrap的子節點。
final TypedArray a = res.obtainAttributes(
attrs, androidx.navigation.R.styleable.NavInclude);
final int id = a.getResourceId(
androidx.navigation.R.styleable.NavInclude_graph, 0);
((NavGraph) dest).addDestination(inflate(id));
a.recycle();
} else if (dest instanceof NavGraph) {
// 如果當前節點是NavGraph,那麼繼續解析其的子元素,並且將解析出來的節點作爲NavGraph的子節點
((NavGraph) dest).addDestination(inflate(res, parser, attrs, graphResId));
}
}
return dest;
}
inflate方法裏面的內容主要分爲兩步:
- 首先是拿到當前節點名稱,然後通過
Navigator
創建對應的NavDestination。需要特別注意的是,當前節點的名稱只會是NavDestination
子類對應的元素名稱,不會有其他的。- 其次就是解析該節點所有子元素。這一步需要特別兩點:首先是,如果當前節點是
include
,表示是另外一個graph文件,重頭開始解析,這裏調用的是隻有一個參數的inflate方法;如果當前節點是navigation
,解析出來也就是NavGraph
,那麼需要繼續遞歸解析其子元素,因爲它的子元素還有其他的NavDestination,比如說fragment、activity等,這調用的是帶4個參數的inflate方法。
inflate方法的大概內容基本就是這樣,其實這裏面還有的方法,比如說inflateArgumentForDestination
、inflateDeepLink
等,這些都是給NavDestination
解析相關屬性和行爲的,有興趣的同學可以深入到裏面去看看,這裏就不展開了。
我們基本熟悉瞭解析過程,那麼解析完成之後,節點是以什麼樣的數據結構存儲的呢?這裏我用一張圖來展示一下:
存儲完成之後,每個頁面(節點)需要跳轉的時候,可以使用自身的action,從這個圖中去尋找目標頁面,從而實現頁面的跳轉。需要特別注意的是,這裏存儲數據結構其實是一棵樹,這個要跟graph文件可視化的圖要區分開來。
5. 總結
到這裏,Navigation的上篇內容就結束了,我對本篇內容做一個小小的總結。
- graph文件對於Navigation來說,是以一個配置文件的形式存在的。其內容是以節點(destination)和路徑(action、deepLink)組成,從而形成一張圖。
- 頁面跳轉方式有兩種,分別是:action和deepLink。需要注意的是,這兩種方式是如何定義和使用。
- 每個頁面都是以
NavDestination
的形式存在的,一個graph文件解析出來就是一顆以NavDestination
爲節點的樹。
本篇內容都比較簡單,下篇內容會重點介紹Navigation其他實現原理,敬請期待。