Jetpack(一)Navigation基本介绍与常见问题分析

目录

前言

最初我们写Android应用,往往都会一个页面就创建一个Activity,然后不同页面之前就使用startActivity进行跳转。后来出现了Fragment,只需要用一个Activity承载多个Fragment,然后通过FragmentManager管理Fragment来实现页面切换的效果。但是不知道大家在实际开发过程中,有没有觉得FragmentManager用起来并不是那么方便,如果真的想用一个Activtiy多个Fragment来完成整个APP开发,还是感觉不是很方便,Navigation的出现就是为了解决这个问题。下面是官网对Navigation的功能概述

  • 处理 Fragment 事务(代替FragmentManager
  • 默认情况下,正确处理往返操作(管理页面堆栈
  • 为动画和转换提供标准化资源(页面切换动画
  • 实现和处理深层链接(类似Activity的隐式意图
  • 包括导航界面模式,用户只需完成极少的额外工作(封装组件,如抽屉式导航栏和底部导航,方便快速开发
  • Safe Args — 可在目标之间导航和传递数据时提供类型安全的 Gradle 插件(安全的页面跳转传参方式
  • ViewModel 支持 - 您可以将 ViewModel 的范围限定为导航图,以在图表的目标之间共享与界面相关的数据(其他Jetpack组件—ViewModel支持

看完上面这些功能,我们就对Navigation的作用大概有一个了解了,实际上最主要的作用就是制定统一的标准,来方便开发者管理Fragment

了解完Navigation的主要作用之后,下面我们来看一下如何使用Navigation

创建项目

为了帮助新手快速体验一下什么是Navigation,我们这里使用Android Studio的项目模板来快速创建一个包含Navigation的项目。(实际项目开发中我们不推荐这么做,具体原因我们后面会说)

首先创建一个新项目,选择Bottom Navigation Activity

完成项目创建后,项目目录格式如下,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用于设置起始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的一些属性


这里主要可以配置三方面的内容

  1. Arguments:跳转到当前页面的时候,需要携带的参数
    1)Name:参数名
    2)Type:参数类型
    3)Default Value:参数默认值
  2. Actions:当前fragment跳转到下一个目标页的动画
    1)ID:每一个action都要指定一个id
    2)From:当前页面
    3)Destination:跳转到哪个页面
    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>

其实这里总结一下就是说,我们可以通过配置mobile_navigation.xml文件去给fragment设置跳转参数、进出场动画、隐式跳转

获取导航器

配置完成后,页面跳转需要借助导航器(NavController),那NavController如何获取呢?,我们在ActivityFragmentView中都能够获得NavController


  • Fragment#findNavController()
  • View#findNavController()
  • Activity#findNavController(viewId: Int)

注意观察这三种方式的不同,FragmentView获取NavController都不需要传任何参数,而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中配置的导航图进行跳转

拿到导航器(NavController)后,我们就可以通过它的navigate()重载方法进行跳转,接下来我们会详细介绍各种跳转方式

Fragment跳转

常规方式跳转

  1. 发起页面跳转
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. 接收页面跳转参数
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
}

可以看出,这里其实就是用类似跳转的方式,取代了原来的FragmentManager的操作

看到这里可能有的人会有点奇怪,这里的传参和我们以往的项目开发也没啥不同的呀,那为什么前面还要在xml中配置参数呢?

其实这个是要配合一个谷歌官方提供的名为 Safe Args 的 Gradle 插件一起使用的,怎么使用呢?

Safe Args 跳转

  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. 在业务模块的 build.gradle 文件中添加:
apply plugin: "androidx.navigation.safeargs.kotlin"

如果是java就用

apply plugin: "androidx.navigation.safeargs"
  1. 发起页面跳转
//HomeFragment.java
val action =
    HomeFragmentDirections.actionNavigationHomeToNavigationContent("geekholt", true)
findNavController().navigate(action)
  1. 接收页面跳转参数
//ContentFragment.java
val args1 = ContentFragmentArgs.fromBundle(requireArguments()).args1
val args2 = ContentFragmentArgs.fromBundle(requireArguments()).args2

这里的HomeFragmentDirectionsContentFragmentArgs其实都是在编译期间编译期根据xml的一些参数自动生成的,所以在配置完导航图的时候,需要ReBuild一下项目,再进行调用

用这种方式的唯一好处其实就是避免强制类型转换异常,因为fromBundle内部帮我们做了一些try catch的操作

DeepLink跳转

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}是一个参数占位符,如果没有定义具有相同名称的参数,则对参数值使用默认的 String 类型

  1. 在Manifest中为相应的Activity设置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设置<nav-graph/>标签 -->
    <nav-graph android:value="@navigation/second_navigation" />
</activity>

nav-graph标签会在编译时将导航图中的所有deepLink自动转化为下面这种格式

<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. 发起页面跳转

发起页面跳转有三种方案

  1. 通过adb命令进行测试
adb shell am start -a android.intent.action.VIEW -d "nav://www.geekholt.com/1"
  1. 从其他app跳转到当前app,或者在同一个应用内,但是要跳转的fragment不在当前Activity的导航图中
val intent = Intent()
intent.data = Uri.parse("nav://www.geekholt.com/1")
startActivity(intent)
  1. 要跳转的fragment在当前Activity的导航图中
val intent = Intent()
intent.data = Uri.parse("nav://www.geekholt.com/1")
findNavController().handleDeepLink(intent)
  1. 接收参数
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
    ...
    val id = arguments?.getString("id")
    return view;
}

使用BottomNavigationView的问题

如果你是根据上面的流程走下来跟着做的话,到这里我相信你已经对Navigation的作用及用法有一定的了解了。但是实际上上面这个模板在大多数的商业级项目开发中存在一定问题的。什么问题呢?

上面是通过BottomNavigationView对主页的tab进行切换的,通过打印Fragment的生命周期我们会发现,每次切换到一个新的Fragment,原来的Fragment就会执行onDestory、onDetach方法,如果再次回到原来的Fragment,也就被重建了,如果有一定开发经验的同学应该能意识到,这种情况在实际项目开发中显然是不太能接受的,比如列表的滑动位置等状态就丢失了。

问题分析:

我们先来看在MainActivity中是如何使用BottomNavigationView的,关键方法是setupWithNavController,这个方法将BottomNavigationView与导航器NavController关联起来

//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方法就是监听用户点击底部Tab的回调,我们可以看到最终就是调用navController.navigate来进行页面跳转的

//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跳转
        navController.navigate(item.getItemId(), null, options);
        return true;
    } catch (IllegalArgumentException e) {
        return false;
    }
}

但是真正导致Fragment被销毁的实际上不是navigate方法,而是setPopUpTo操作

Builder setPopUpTo(@IdRes int destinationId, boolean inclusive)

设置inclusive=true的时候,会清空回退栈,并回到回退栈中第一个位置

设置inclusive=false的时候,会清空回退栈,且不会回到回退栈中的第一个位置

这里就是通过设置inclusive=false将回退栈全部清空,然后跳转到了一个新的Fragment,所以回退栈中的Fragment显然就被销毁了

所以其实对于主页的tab切换来说,还是更推荐使用ViewPager+Fragment的方式来实现,Android官方提供的一个Jetpack项目中其实就是这么做的,非常推荐看一下这个项目https://github.com/android/sunflower

Navigation replace机制问题

Navigation还有一个经常被大家“诟病”的就是Fragment跳转的replace机制问题,什么意思呢?FragmentNavigatornavigate方法内部是直接调用FragmentManager.replace()方法替换原来的Fragment,导致每次回到上一个页面都重走onCreateView方法。

注意,这个和我们上面说的BottomNavigationView的问题其实是不一样的。使用navigate跳转到新的页面,原来的页面只会只会执行onDestroyView,而不会执行onDestory。所以回到原来的页面后,也只是重新执行onCreateView方法,而不会重走onCreate方法进行完全的重建。这块其实是Fragment相关的知识,在本节中不会详细介绍。如果不清楚原因的,这里给出一些关键词,可以到网上了解一下FragmentTransactionreplace以及addToBackStack的作用

//FragmentNavigator.java
public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
            @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
        ...
        //根据classname反射获取Fragmnent
        final Fragment frag = instantiateFragment(mContext, mFragmentManager,
                className, args);
        frag.setArguments(args);
        //获取Fragment事务
        final FragmentTransaction ft = mFragmentManager.beginTransaction();
        //切换动画设置
        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));
        ........
    }

那么官方为什么要设计成replace呢?用hideshow不好吗?谷歌的想法应该是结合ViewModel使用,View所对应的ViewModel还在,数据并不需要重新加载或者请求,然后再通过数据重新渲染出View。这样做到了数据和视图的分离,也减少了页面的视图层级。ViewModel的生命周期是跟着Fragment走的,只有Frgment onDestoryViewModel才会被销毁

这里再一次推荐吐槽replace机制的同学们去看一下官方提供的项目https://github.com/android/sunflower

关于ViewModel相关的内容,我也会在后续章节中进行介绍

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342