
目錄

前言
最初我們寫Android應(yīng)用,往往都會一個頁面就創(chuàng)建一個Activity,然后不同頁面之前就使用startActivity進行跳轉(zhuǎn)。后來出現(xiàn)了Fragment,只需要用一個Activity承載多個Fragment,然后通過FragmentManager管理Fragment來實現(xiàn)頁面切換的效果。但是不知道大家在實際開發(fā)過程中,有沒有覺得FragmentManager用起來并不是那么方便,如果真的想用一個Activtiy多個Fragment來完成整個APP開發(fā),還是感覺不是很方便,Navigation的出現(xiàn)就是為了解決這個問題。下面是官網(wǎng)對Navigation的功能概述
- 處理 Fragment 事務(wù)(代替FragmentManager)
- 默認情況下,正確處理往返操作(管理頁面堆棧)
- 為動畫和轉(zhuǎn)換提供標準化資源(頁面切換動畫)
- 實現(xiàn)和處理深層鏈接(類似Activity的隱式意圖)
- 包括導航界面模式,用戶只需完成極少的額外工作(封裝組件,如抽屜式導航欄和底部導航,方便快速開發(fā))
- Safe Args — 可在目標之間導航和傳遞數(shù)據(jù)時提供類型安全的 Gradle 插件(安全的頁面跳轉(zhuǎn)傳參方式)
-
ViewModel支持 - 您可以將ViewModel的范圍限定為導航圖,以在圖表的目標之間共享與界面相關(guān)的數(shù)據(jù)(其他Jetpack組件—ViewModel支持)
看完上面這些功能,我們就對Navigation的作用大概有一個了解了,實際上最主要的作用就是制定統(tǒng)一的標準,來方便開發(fā)者管理Fragment
了解完Navigation的主要作用之后,下面我們來看一下如何使用Navigation
創(chuàng)建項目
為了幫助新手快速體驗一下什么是Navigation,我們這里使用Android Studio的項目模板來快速創(chuàng)建一個包含Navigation的項目。(實際項目開發(fā)中我們不推薦這么做,具體原因我們后面會說)
首先創(chuàng)建一個新項目,選擇Bottom Navigation Activity

完成項目創(chuàng)建后,項目目錄格式如下,Android Studio會自動幫我們生成一個MainActivity和三個Fragment,還有一些資源文件

運行一下,看看效果

Navigation可視化配置
配置布局文件
activity_main.xml
我們再來看布局文件activity_main.xml,這里面有一個fragment標簽,這是我們在以往的布局中是沒有見過的。這個fragment可以看作是存放Fragment的容器,它引用了一個mobile_navigation.xml資源文件。下面的BottomNavigationView就是底部的tab,它也引用了一個bottom_nav_menu.xml資源文件
app:defaultNavHost="true"的作用是,讓Navigation處理返回事件,點返回按鈕時并不是返回上一個Activity,而是返回上一個「頁面」,上一個「頁面」有可能是Activity,也可能是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"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/nav_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/mobile_navigation" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:layout_marginEnd="0dp"
android:background="?android:attr/windowBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/bottom_nav_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>
配置導航圖
mobile_navigation.xml
這里引用了三個Fragment,分別是HomeFragment、DashboardFragment和NotificationsFragment
startDestination用于設(shè)置起始Fragment
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/mobile_navigation"
app:startDestination="@+id/navigation_home">
<fragment
android:id="@+id/navigation_home"
android:name="com.geekholt.ifun.ui.home.HomeFragment"
android:label="@string/title_home"
tools:layout="@layout/fragment_home" />
<fragment
android:id="@+id/navigation_dashboard"
android:name="com.geekholt.ifun.ui.dashboard.DashboardFragment"
android:label="@string/title_dashboard"
tools:layout="@layout/fragment_dashboard" />
<fragment
android:id="@+id/navigation_notifications"
android:name="com.geekholt.ifun.ui.notifications.NotificationsFragment"
android:label="@string/title_notifications"
tools:layout="@layout/fragment_notifications" />
</navigation>
切換到Design頁面,可以通過可視化的方式配置fragment的一些屬性

這里主要可以配置三方面的內(nèi)容
-
Arguments:跳轉(zhuǎn)到當前頁面的時候,需要攜帶的參數(shù)
1)Name:參數(shù)名
2)Type:參數(shù)類型
3)Default Value:參數(shù)默認值
-
Actions:當前fragment跳轉(zhuǎn)到下一個目標頁的動畫
1)ID:每一個action都要指定一個id
2)From:當前頁面
3)Destination:跳轉(zhuǎn)到哪個頁面
4)Transition:進出場動畫
- Deep Links:通過當前url的方式拉起當前頁面,類似隱式意圖拉起Activity

可視化配置完成后,會自動生成代碼,mobile_navigation.xml如下所示(其實就是多了一些xml屬性,這些屬性你也可以手動編寫)
mobile_navigation.xml
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/mobile_navigation"
app:startDestination="@+id/navigation_home">
<fragment
android:id="@+id/navigation_home"
android:name="com.geekholt.ifun.ui.home.HomeFragment"
android:label="@string/title_home"
tools:layout="@layout/fragment_home" >
<argument
android:name="arg1"
app:argType="string"
android:defaultValue="1" />
<action
android:id="@+id/id_action"
app:destination="@id/navigation_dashboard"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popUpTo="@id/navigation_notifications"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<deepLink
android:id="@+id/deepLink"
app:uri="nav://www.geekholt.com/{id}" />
</fragment>
<fragment
android:id="@+id/navigation_dashboard"
android:name="com.geekholt.ifun.ui.dashboard.DashboardFragment"
android:label="@string/title_dashboard"
tools:layout="@layout/fragment_dashboard" />
<fragment
android:id="@+id/navigation_notifications"
android:name="com.geekholt.ifun.ui.notifications.NotificationsFragment"
android:label="@string/title_notifications"
tools:layout="@layout/fragment_notifications" />
</navigation>
其實這里總結(jié)一下就是說,我們可以通過配置mobile_navigation.xml文件去給fragment設(shè)置跳轉(zhuǎn)參數(shù)、進出場動畫、隱式跳轉(zhuǎn)
獲取導航器
配置完成后,頁面跳轉(zhuǎn)需要借助導航器(NavController),那NavController如何獲取呢?,我們在Activity、Fragment、View中都能夠獲得NavController
Fragment#findNavController()View#findNavController()Activity#findNavController(viewId: Int)
注意觀察這三種方式的不同,Fragment和View獲取NavController都不需要傳任何參數(shù),而Activity需要傳遞一個viewId,這個viewId是什么呢?
這個viewId其實就是activity_main.xml中的fragment標簽的id,所以我們一般會在Activity中像下面這樣獲取導航器
val navController = findNavController(R.id.nav_host_fragment)
然后在fragment標簽中配置導航圖app:navGraph="@navigation/mobile_navigation"
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
.....
app:navGraph="@navigation/mobile_navigation" />
我們知道,Activity和View都不能單獨存在,都需要依托于Activity,所以Fragment和View中獲取導航器都不需要傳遞viewId,因為它們實際上都是基于Activtiy中配置的導航圖進行跳轉(zhuǎn)
拿到導航器(NavController)后,我們就可以通過它的navigate()重載方法進行跳轉(zhuǎn),接下來我們會詳細介紹各種跳轉(zhuǎn)方式
Fragment跳轉(zhuǎn)
常規(guī)方式跳轉(zhuǎn)
- 發(fā)起頁面跳轉(zhuǎn)
var bundle = Bundle()
bundle.putString("args1", "geekholt")
bundle.putBoolean("args2", true)
findNavController().navigate(R.id.navigation_content, bundle)
R.id.navigation_content就是在mobile_navigation.xml中配置的fragment標簽的id
- 接收頁面跳轉(zhuǎn)參數(shù)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val root = inflater.inflate(R.layout.fragment_content, container, false)
val arg1 = arguments?.get("args1") as String
val arg2 = arguments?.get("args2") as Boolean
return root
}
可以看出,這里其實就是用類似跳轉(zhuǎn)的方式,取代了原來的FragmentManager的操作
看到這里可能有的人會有點奇怪,這里的傳參和我們以往的項目開發(fā)也沒啥不同的呀,那為什么前面還要在xml中配置參數(shù)呢?
其實這個是要配合一個谷歌官方提供的名為 Safe Args 的 Gradle 插件一起使用的,怎么使用呢?
Safe Args 跳轉(zhuǎn)
- 在project的
build.gradle文件中添加:
buildscript {
repositories {
google()
}
dependencies {
def nav_version = "2.3.0-alpha01"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
}
}
- 在業(yè)務(wù)模塊的
build.gradle文件中添加:
apply plugin: "androidx.navigation.safeargs.kotlin"
如果是java就用
apply plugin: "androidx.navigation.safeargs"
- 發(fā)起頁面跳轉(zhuǎn)
//HomeFragment.java
val action =
HomeFragmentDirections.actionNavigationHomeToNavigationContent("geekholt", true)
findNavController().navigate(action)
- 接收頁面跳轉(zhuǎn)參數(shù)
//ContentFragment.java
val args1 = ContentFragmentArgs.fromBundle(requireArguments()).args1
val args2 = ContentFragmentArgs.fromBundle(requireArguments()).args2
這里的HomeFragmentDirections和ContentFragmentArgs其實都是在編譯期間編譯期根據(jù)xml的一些參數(shù)自動生成的,所以在配置完導航圖的時候,需要ReBuild一下項目,再進行調(diào)用
用這種方式的唯一好處其實就是避免強制類型轉(zhuǎn)換異常,因為fromBundle內(nèi)部幫我們做了一些try catch的操作
DeepLink跳轉(zhuǎn)
DeepLink就類似通過隱式意圖打開Activity
- 配置DeepLink
可以像前面介紹的在導航圖中通過可視化的方式配置deepLink,也可以直接在xml中配置,如
<navigation 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:id="@+id/second_navigation"
app:startDestination="@+id/navigation_content">
<fragment
android:id="@+id/navigation_content"
android:name="com.geekholt.jetpack_navigation.ui.content.ContentFragment"
android:label="@string/title_content"
tools:layout="@layout/fragment_content">
<deepLink
android:id="@+id/deepLink"
app:uri="nav://www.geekholt.com/{id}" />
</fragment>
</navigation>
{id}是一個參數(shù)占位符,如果沒有定義具有相同名稱的參數(shù),則對參數(shù)值使用默認的 String 類型
- 在Manifest中為相應(yīng)的
Activity設(shè)置nav-graph標簽
<activity
android:name=".SecondActivity"
android:exported="true">
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.VIEW" />
</intent-filter>
<!-- 為Activity設(shè)置<nav-graph/>標簽 -->
<nav-graph android:value="@navigation/second_navigation" />
</activity>
nav-graph標簽會在編譯時將導航圖中的所有deepLink自動轉(zhuǎn)化為下面這種格式
<intent-filter>
<action
android:name="android.intent.action.VIEW" />
<category
android:name="android.intent.category.DEFAULT" />
<category
android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="nav" />
<data
android:host="www.geekholt.com" />
<data
android:pathPrefix="/" />
</intent-filter>
- 發(fā)起頁面跳轉(zhuǎn)
發(fā)起頁面跳轉(zhuǎn)有三種方案
- 通過adb命令進行測試
adb shell am start -a android.intent.action.VIEW -d "nav://www.geekholt.com/1"
- 從其他app跳轉(zhuǎn)到當前app,或者在同一個應(yīng)用內(nèi),但是要跳轉(zhuǎn)的fragment不在當前Activity的導航圖中
val intent = Intent()
intent.data = Uri.parse("nav://www.geekholt.com/1")
startActivity(intent)
- 要跳轉(zhuǎn)的fragment在當前Activity的導航圖中
val intent = Intent()
intent.data = Uri.parse("nav://www.geekholt.com/1")
findNavController().handleDeepLink(intent)
- 接收參數(shù)
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
...
val id = arguments?.getString("id")
return view;
}
使用BottomNavigationView的問題
如果你是根據(jù)上面的流程走下來跟著做的話,到這里我相信你已經(jīng)對Navigation的作用及用法有一定的了解了。但是實際上上面這個模板在大多數(shù)的商業(yè)級項目開發(fā)中存在一定問題的。什么問題呢?
上面是通過BottomNavigationView對主頁的tab進行切換的,通過打印Fragment的生命周期我們會發(fā)現(xiàn),每次切換到一個新的Fragment,原來的Fragment就會執(zhí)行onDestory、onDetach方法,如果再次回到原來的Fragment,也就被重建了,如果有一定開發(fā)經(jīng)驗的同學應(yīng)該能意識到,這種情況在實際項目開發(fā)中顯然是不太能接受的,比如列表的滑動位置等狀態(tài)就丟失了。
問題分析:
我們先來看在MainActivity中是如何使用BottomNavigationView的,關(guān)鍵方法是setupWithNavController,這個方法將BottomNavigationView與導航器NavController關(guān)聯(lián)起來
//MainActivity.kt
val navView: BottomNavigationView = findViewById(R.id.nav_view)
val navController = findNavController(R.id.nav_host_fragment)
....
navView.setupWithNavController(navController)
//BottomNavigationView.kt
fun BottomNavigationView.setupWithNavController(navController: NavController) {
NavigationUI.setupWithNavController(this, navController)
}
setOnNavigationItemSelectedListener方法就是監(jiān)聽用戶點擊底部Tab的回調(diào),我們可以看到最終就是調(diào)用navController.navigate來進行頁面跳轉(zhuǎn)的
//NavigationUI.java
public static void setupWithNavController(
@NonNull final BottomNavigationView bottomNavigationView,
@NonNull final NavController navController) {
bottomNavigationView.setOnNavigationItemSelectedListener(
new BottomNavigationView.OnNavigationItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(@NonNull MenuItem item) {
//tab切換
return onNavDestinationSelected(item, navController);
}
});
....
}
public static boolean onNavDestinationSelected(@NonNull MenuItem item,
@NonNull NavController navController) {
NavOptions.Builder builder = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim);
if ((item.getOrder() & Menu.CATEGORY_SECONDARY) == 0) {
//清空回退棧
builder.setPopUpTo(findStartDestination(navController.getGraph()).getId(), false);
}
NavOptions options = builder.build();
try {
//Fragment跳轉(zhuǎn)
navController.navigate(item.getItemId(), null, options);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
但是真正導致Fragment被銷毀的實際上不是navigate方法,而是setPopUpTo操作
Builder setPopUpTo(@IdRes int destinationId, boolean inclusive)
設(shè)置inclusive=true的時候,會清空回退棧,并回到回退棧中第一個位置
設(shè)置inclusive=false的時候,會清空回退棧,且不會回到回退棧中的第一個位置
這里就是通過設(shè)置inclusive=false將回退棧全部清空,然后跳轉(zhuǎn)到了一個新的Fragment,所以回退棧中的Fragment顯然就被銷毀了
所以其實對于主頁的tab切換來說,還是更推薦使用ViewPager+Fragment的方式來實現(xiàn),Android官方提供的一個Jetpack項目中其實就是這么做的,非常推薦看一下這個項目https://github.com/android/sunflower
Navigation replace機制問題
Navigation還有一個經(jīng)常被大家“詬病”的就是Fragment跳轉(zhuǎn)的replace機制問題,什么意思呢?FragmentNavigator的navigate方法內(nèi)部是直接調(diào)用FragmentManager.replace()方法替換原來的Fragment,導致每次回到上一個頁面都重走onCreateView方法。
注意,這個和我們上面說的BottomNavigationView的問題其實是不一樣的。使用navigate跳轉(zhuǎn)到新的頁面,原來的頁面只會只會執(zhí)行onDestroyView,而不會執(zhí)行onDestory。所以回到原來的頁面后,也只是重新執(zhí)行onCreateView方法,而不會重走onCreate方法進行完全的重建。這塊其實是Fragment相關(guān)的知識,在本節(jié)中不會詳細介紹。如果不清楚原因的,這里給出一些關(guān)鍵詞,可以到網(wǎng)上了解一下FragmentTransaction的replace以及addToBackStack的作用
//FragmentNavigator.java
public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
@Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
...
//根據(jù)classname反射獲取Fragmnent
final Fragment frag = instantiateFragment(mContext, mFragmentManager,
className, args);
frag.setArguments(args);
//獲取Fragment事務(wù)
final FragmentTransaction ft = mFragmentManager.beginTransaction();
//切換動畫設(shè)置
int enterAnim = navOptions != null ? navOptions.getEnterAnim() : -1;
int exitAnim = navOptions != null ? navOptions.getExitAnim() : -1;
int popEnterAnim = navOptions != null ? navOptions.getPopEnterAnim() : -1;
int popExitAnim = navOptions != null ? navOptions.getPopExitAnim() : -1;
if (enterAnim != -1 || exitAnim != -1 || popEnterAnim != -1 || popExitAnim != -1) {
enterAnim = enterAnim != -1 ? enterAnim : 0;
exitAnim = exitAnim != -1 ? exitAnim : 0;
popEnterAnim = popEnterAnim != -1 ? popEnterAnim : 0;
popExitAnim = popExitAnim != -1 ? popExitAnim : 0;
ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim);
}
//切換Fragment
ft.replace(mContainerId, frag);
ft.setPrimaryNavigationFragment(frag);
......
ft.addToBackStack(generateBackStackName(mBackStack.size(), destId));
........
}
那么官方為什么要設(shè)計成replace呢?用hide和show不好嗎?谷歌的想法應(yīng)該是結(jié)合ViewModel使用,View所對應(yīng)的ViewModel還在,數(shù)據(jù)并不需要重新加載或者請求,然后再通過數(shù)據(jù)重新渲染出View。這樣做到了數(shù)據(jù)和視圖的分離,也減少了頁面的視圖層級。ViewModel的生命周期是跟著Fragment走的,只有Frgment onDestory了ViewModel才會被銷毀

這里再一次推薦吐槽replace機制的同學們?nèi)タ匆幌鹿俜教峁┑捻椖?a target="_blank">https://github.com/android/sunflower
關(guān)于ViewModel相關(guān)的內(nèi)容,我也會在后續(xù)章節(jié)中進行介紹

