Jetpack(一)Navigation基本介紹與常見問題分析

目錄

前言

最初我們寫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,分別是HomeFragmentDashboardFragmentNotificationsFragment

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)容

  1. Arguments:跳轉(zhuǎn)到當前頁面的時候,需要攜帶的參數(shù)
    1)Name:參數(shù)名
    2)Type:參數(shù)類型
    3)Default Value:參數(shù)默認值
  2. Actions:當前fragment跳轉(zhuǎn)到下一個目標頁的動畫
    1)ID:每一個action都要指定一個id
    2)From:當前頁面
    3)Destination:跳轉(zhuǎn)到哪個頁面
    4)Transition:進出場動畫
  3. 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)

注意觀察這三種方式的不同,FragmentView獲取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" />

我們知道,ActivityView都不能單獨存在,都需要依托于Activity,所以FragmentView中獲取導航器都不需要傳遞viewId,因為它們實際上都是基于Activtiy中配置的導航圖進行跳轉(zhuǎn)

拿到導航器(NavController)后,我們就可以通過它的navigate()重載方法進行跳轉(zhuǎn),接下來我們會詳細介紹各種跳轉(zhuǎn)方式

Fragment跳轉(zhuǎn)

常規(guī)方式跳轉(zhuǎn)

  1. 發(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

  1. 接收頁面跳轉(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)

  1. 在project的 build.gradle 文件中添加:
    buildscript {
        repositories {
            google()
        }
        dependencies {
            def nav_version = "2.3.0-alpha01"
            classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
        }
    }
    
  1. 在業(yè)務(wù)模塊的 build.gradle 文件中添加:
apply plugin: "androidx.navigation.safeargs.kotlin"

如果是java就用

apply plugin: "androidx.navigation.safeargs"
  1. 發(fā)起頁面跳轉(zhuǎn)
//HomeFragment.java
val action =
    HomeFragmentDirections.actionNavigationHomeToNavigationContent("geekholt", true)
findNavController().navigate(action)
  1. 接收頁面跳轉(zhuǎn)參數(shù)
//ContentFragment.java
val args1 = ContentFragmentArgs.fromBundle(requireArguments()).args1
val args2 = ContentFragmentArgs.fromBundle(requireArguments()).args2

這里的HomeFragmentDirectionsContentFragmentArgs其實都是在編譯期間編譯期根據(jù)xml的一些參數(shù)自動生成的,所以在配置完導航圖的時候,需要ReBuild一下項目,再進行調(diào)用

用這種方式的唯一好處其實就是避免強制類型轉(zhuǎn)換異常,因為fromBundle內(nèi)部幫我們做了一些try catch的操作

DeepLink跳轉(zhuǎn)

DeepLink就類似通過隱式意圖打開Activity

  1. 配置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 類型

  1. 在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>
  1. 發(fā)起頁面跳轉(zhuǎn)

發(fā)起頁面跳轉(zhuǎn)有三種方案

  1. 通過adb命令進行測試
adb shell am start -a android.intent.action.VIEW -d "nav://www.geekholt.com/1"
  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)
  1. 要跳轉(zhuǎn)的fragment在當前Activity的導航圖中
val intent = Intent()
intent.data = Uri.parse("nav://www.geekholt.com/1")
findNavController().handleDeepLink(intent)
  1. 接收參數(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機制問題,什么意思呢?FragmentNavigatornavigate方法內(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)上了解一下FragmentTransactionreplace以及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呢?用hideshow不好嗎?谷歌的想法應(yīng)該是結(jié)合ViewModel使用,View所對應(yīng)的ViewModel還在,數(shù)據(jù)并不需要重新加載或者請求,然后再通過數(shù)據(jù)重新渲染出View。這樣做到了數(shù)據(jù)和視圖的分離,也減少了頁面的視圖層級。ViewModel的生命周期是跟著Fragment走的,只有Frgment onDestoryViewModel才會被銷毀

這里再一次推薦吐槽replace機制的同學們?nèi)タ匆幌鹿俜教峁┑捻椖?a target="_blank">https://github.com/android/sunflower

關(guān)于ViewModel相關(guān)的內(nèi)容,我也會在后續(xù)章節(jié)中進行介紹

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容