Jetpack 源码分析(七) - 手把手教你认识Navigation(上)

  从今天开始,我正式开始分析Navigation库的基本使用和实现原理。其实本来没打算学习这个库,但是最近组内在整理页面之间的跳转流程,使其能够组件化。而恰好的是,我们业务的页面跳转几乎都是以单Activity + Fragment的方式实现的,我就提了一个建议,是不是可以研究一下Navigation库在我们业务场景的落地可能性呢?于是,我就先需要调研这个Navigation库的现状,以及它的优缺点,再去评估是否可以落地到业务中去。
  如今,我已经成功输出一份Navigation的调研文档,给到了大佬们,当然我对其的使用方式和实现原理基本都了解过。最后,想着自己好像很久没有写博客,于是写下此文记录一下,也供大家学习参考。
  本文内容较多,将分为上下两篇来分析。上篇的主要内容是:

  1. Navigation的基本使用。
  2. Navigation的基本结构,以及核心类的基本解释。
  3. graph文件的分析。包括分析每个元素的含义,节点inflate的过程,以及NavDestination的含义

  下篇的主要内容是:

  1. 页面跳转逻辑的实现。
  2. Navigator的分析。会重点分析FragmentNavigator。
  3. 如何自定义Navigator,以及如何给页面传参。
  4. Navigation的一些设计美学和“缺陷”。这里的缺陷我打了引号,表示仅是我本人的想法,并不能代表Navigation的设计有问题。

  大家从上述列举内容中可以了解到,上篇主要是介绍基础相关的内容,而下篇是重点分析Navigation的实现原理。
  本文参考文章:

  1. Navigation的官方文档
  2. 【背上Jetpack之Navigation】想去哪就去哪,Android世界的指南针
  3. Navigation的源码解析

  本文源码参考都来自于2.3.5版本。

1. 概述

  从官方文档的介绍来看,Navigation库主要目的是支持用户进入和退出不同页面的交互。用通俗的话来说,就是可以支持在一个Activity里面,不同Fragment的切换,且还支持Activity之间跳转。而Activity的跳转系统默认就支持,所以这并不是Navigation的重点所在。我们在选择和学习的时候,肯定是是看中了其切换Fragment的能力,这也是本文介绍的重点。
  使用Navigation库切换Fragment,主要是有如下几个优势:

  1. 能够处理Fragment的事务,保证Fragment生命周期的正确性。
  2. 默认情况下,正确处理往返操作。
  3. 支持自定义Fragment的专场动画。
  4. 支持deepLink的跳转。
  5. 页面之间可以自由的传参,不仅是Parcelable和Serializable,任何数据类型都支持。

  针对上面列举的优势,我重点补充一些第一点。Navigation在实现Fragment切换的时候,是通过FragmentTransaction的replace方法来实现,因此假设从一个Fragment A跳转到Fragment B,那么Fragment A生命周期会走到onDestroyView,当返回到Fragment A,此时又会重新走到onCreateView。这是万万不能接受的,因为我们一般会在onCreateView方法做很多的初始化操作。针对此,网络上一般有两种解法:

  1. 重写Fragment的onCreateView,加上相关判断,使其只会初始化一次View。
  2. 重写FragmentNavigator,使用show和hide方案来控制Fragment的切换。

  我想说的是,上述两种方案各有各的优缺点,分别如下:

方案 优点 缺点
方案一 能保证Fragment生命周
期走到onStop,资源能
到释放
页面的状态可能会出问题,比如说第二次
onCreateView其实用到的View其实是上一次的View,
View的状态很有可能会出问题。
方案二 不会出现在返回重新调
用onCreateView方法的
问题
由于是hide的,上一个Fragment的生命周期不会任何
变化,因此资源得不到有效的释放。

  从上面的分析内容来看,两种方案都各有利弊。那么有没有比较好的方式,既能保证生命周期正确,又不会引入其他的问题呢?当时是可以的,其实我们可以把方案二改造一下,使用setMaxLifecycle来改变Fragment的生命周期,从而兼容两种方案的优缺点。此方案的具体实现和分析会在下篇介绍,这里就先卖一个关子。
  除了功能上的优势,我觉得还有一个设计上的优势,那就是将跳转流程设计成一个可视化的方案,graph文件的存在不仅让一个新人对一个完全陌生的页面能够进行快速理解,同时还将跳转流程的实现配置化,使其后续的维护和扩展工作都能低成本的进行。

2. 基本使用

  既然是手把手的教大家认识Navigation,我们得先弄懂Navigation到底是怎么使用,因为只有了解它的特性,才能更好的理解和分析其实现原理,更进一步的是,能够学以致用。
  我一直认为源码学习的目的不是为了装逼,而是有如下两点好处:

  1. 对库的实现原理理解的更加深入,当在使用库的过程中出现了问题,可以很快的从源码角度排查出问题原因所在,且给出有效的解决方案。
  2. 举一反三,学以致用。我们可以把库的一些设计思想和实现细节运用真实的业务场景当中,使我们的业务代码能够像官方库一样的优雅。

  好了,扯的题外话有点多了,我们还是回归正题中来吧。本节主要介绍如下内容:

  1. Demo的效果展示。
  2. 准备工作--介绍和定义graph,NavHostFragment的使用。
  3. 几种跳转逻辑的使用--action跳转和deepLink跳转,以及activity的跳转。
  4. 特别注意:popUpTopopUpToInclusive跟传统跳转的区别。

(1). Demo 展示

  在正式介绍正式用法之前,我们先使用一个Demo真实的感受一下效果,效果图如下:


  我先介绍一下这个Demo的结构。这个Demo有三个Fragment,分别是NavConatainerFragmentNavChildFragmentANavChildFragmentB。其中NavConatainerFragment使用Navigation可以跳转到另外两个Fragment去,同时从另外两个Fragment也可以成功返回到NavConatainerFragment
  该Demo的完整代码可以参考:NavigationDemo

(2). graph文件的介绍

  graph文件在Navigation中,非常的重要。因为它算是所有跳转流程的配置文件,页面之间的完整跳转过程都能在此文件体现出来,包括后续在使用代码进行动态跳转时,其规范性和合法性都需要参考此文件。所以,创建和定义graph文件是我们学习Navigation的第一步。
  graph文件在工程中是以xml的形式存在的,所以需要创建在res目录下。基本创建过程是这样的:

  1. res目录下,先创建一个名为navigation的目录。
  2. 然后右击navigation目录,选择New->Navigation Resource File,这样就能创建一个graph文件。

  在初次创建的graph文件中,基本内容如下:


   对于截图中的结构,我们熟悉到不能再熟悉了。我们可以从截图中得到两个信息:

  1. graph文件的根元素是navigation。
  2. android:id表示navigation的唯一标识。这个唯一标识非常重要,在Navigation的世界中,不仅fragment和activity是destination(目的地),navigation也就是一个destination。至于什么是NavDestination,我们后续会讲。

  除此之外,我重点介绍一下graph文件中的其他元素:

元素名称 作用
navigation graph文件的跟元素,必须设置idstartDestination。当NavHostFragment
加载graph文件时,会根据startDestination导航到指定的页面上去。
action 表示一个跳转行为,可以作为navigation的子元素,也可以作为其他
destination(fragment或者activity)的子元素,必须设置iddestination
性,其中id是提供给其他destination来寻找具体的跳转行为;destination表示
跳转落到具体destination的id。除了这些属性,还有enterAnimexitAnim
来定义页面入场和退场的动画,以及popUpTopopUpToInclusive用来处理循
环跳转的情况。
deepLink 跟action类似,也表示一个跳转行为,可以作为navigation的子元素,也可以
作为其他destination(fragment或者activity)的子元素。可以通过设置uri
action属性来表示跳到哪个页面。
fragment navigation的子元素之一,表示一个页面(在Navigation中,页面可以用
destination来表示)。其中,id属性表示当前页面的唯一标识,用以给action
元素定义具体的落地页;name属性表示具体的Fragment对应的完整路径。
activity navigation的子元素之一,表示一个页面,跟fragment类似。
include navigation的子元素之一,用于引入另外一个graph文件。该元素有利于graph
文件的独立,从而便于跳转流程的拆分和复用。

  我在上表中列举了常用的元素,我在这里补充几点:

  1. navigation元素的startDestination属性设置的是对应fragment或者activity元素的id,表示当首次加载或者跳转到该graph文件中去,默认跳到指定的页面上去。前面已经说了,navigation本身就是一个destination ,跟fragment和activity是同一级的东西。但是navigation本身不承载Ui,所以它需要一个有UI的destination。
  2. action元素当作为fragment元素的子元素时,表示它只是一个局部action,仅限它所属fragment元素对应的Fragment页面才能使用;当action元素作为navigation的子元素时,表示它是一个全局action,它所属navigation元素下所有的fragment和action都可以使用。且其的destination属性设置的是对应fragment或者activity元素的id。
  3. deeLink元素可以设置在两个地方,分别是:作为fragment和activity元素的子元素;作为navigation的子元素。这两个地方表示含义是不一样的。其中,当作为fragment和activity元素的子元素时,表示其他页面可以使用对应的链接跳到它所属的fragment和activity页面,该链接就是在这里配置的deepLink;当作为navigation的子元素时,表示其他页面可以使用对应的链接跳到它所属graph的startDestination页面。注意,deepLink跳转方式并不支持转场动画,如果有需要,需要自行定义。
  4. 如果我们想要从一个graph文件中的页面跳转到另一个graph文件的某一个页面,必须要在第一个graph文件使用include元素引入另外一个graph文件。

  与此同时,这里我只列举了部分的元素,还有一些不怎么常用的元素并没有体现出来。例如:

  1. dialog元素:它本身页面一个destination,也就是Navigation支持我们从一个页面跳转到一个Dialog里面去。但是我本人不推荐使用这种方式来跳转,因为我对Navigation抱有的态度是:不可不用,也不能全用
  2. 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。在这里,我补充几点:

  1. 如果想要一个Fragment或者Activity可以被其他页面跳转到,必须要在graph文件里面申明。比如,这里的NavChildFragmentANavChildFragmentB,虽然它俩不会跳转到其他页面,但是自身会作为其他业务的落地页,所以也得在文件中申明。
  2. 这里给NavContainerFragment定义了两个action,分别是跳转到NavChildFragmentANavChildFragmentB这两个页面的。且定义的是局部action
  3. 在可视化中,一个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>

  这里我们需要注意几点:

  1. 使用的是FragmentContainerView来加载Fragment。有人会问,传统的fragment也可以吗?当然也是可以的,只不过会丢失很多的特性,比如说Fragment的转场动画可能会出问题。
  2. app:defaultNavHost设置为true,表示当前系统的back事件优先由NavHostFragment来处理。
  3. app:navGraph表示需要加载的配置文件。经过设置这个属性,我们在graph文件配置的信息都生效了,之后我们就在对应Fragment中愉快的使用对应action跳转到对应Fragment了。

  关于app:defaultNavHostapp:navGraph的实现原理,后续会专门分析,这里就不赘述了。

  然后,我们再来看一下动态加载的实现。动态加载分为两步,首先要把FragmentContainerView的三个属性都删除掉:android:nameapp:defaultNavHostapp: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的实现,我们需要注意如下几点:

  1. 使用NavHostFragment的create方法创建对象时,需要传一个graph文件id,表示graph文件需要运用到该Fragment。create方法实现原理其实就是往Fragment的arguments添加一个graph文件id参数,以便Fragment在合适的时机解析和运用其中的配置。
  2. 需要调用setPrimaryNavigationFragment方法,并且将NavHostFragment传进去,表示当前系统的back事件都由该Fragment处理。如果不调用该方法,便不能在NavHostFragment内部正确处理往返操作,这一点需要特别注意。

(4). 页面跳转

  当我们准备好了graph文件和NavHostFragment之后,就可以进行Fragment和Activity跳转。本小节的主要内容如下:

  1. action跳转及其注意事项。
  2. deepLink跳转及其注意事项。
  3. 使用Safe Args进行跳转。
  4. 如何进行标准化传参。

(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,分别是跳转到NavChileFragmentANavChildFragmentB
  那么我们怎么通过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即可。如此便实现了页面的跳转,不过这里还需要注意几点:

  1. 这个action 只能用自身Fragment的局部action,或者当前fragment所在graph文件中的全局action。使用其他地方的action会崩溃。
  2. 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,要分为两步进行,分别如下:

  1. 给指定的Fragment创建一个deepLink。表示外部可以使用该deepLink跳转到自己。
  2. 外部调用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。是不是非常的简单呢?不过,这里我们需要注意如下几点:

  1. 在给一个页面创建deepLink的时候,千万不要落下host,即如上的http://。因为我们不加host的话,那么在匹配的时候,仅接受host为https://http://。比如说,上面我们把NavChildFragmentB的deepLink配置为www.jade.com,外部只能使用https://www.jade.comhttp://www.jade.com跳转,而直接使用www.jade.com会发生崩溃。这是一个隐藏逻辑,需要特别注意。
  2. deepLink的匹配规则遵循正则表达式,更多的细节可以参考官方文档:为目的地创建深层链接
  3. 如果想要使用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还有三个属性,分别如下:

  1. app:action: 是deepLink的组成部分之一,为字符串类型,如果不为空,需要action相同才能成功匹配。
  2. app:mimeType: 是deepLink的组成部分之一,媒体数据类型,需要类型相关才能成功匹配。比如说"image/jpg"与"image/*"匹配。
  3. 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崩溃,不过我本人不推荐使用此方法来进行跳转:

  1. gradle plugin本身依赖于gradle版本,生产环境中gradle可能不能完全兼容safe args的plugin,从而导致编译失败,或者生成的辅助类不能符合预期。
  2. 包大小。safe args在编译过程中,生成了很多的类,其实我们跳转页面,本质上只需要一个action id,safe args生成那些的类完全没必要。
  3. 实现逻辑不透明。safe args生成的类在工程里面是找不到的,就很难直接看到其中的实现逻辑,如果出问题,也很难排查。PS:如果想要对应的生成类,目前我能找到的办法就是反编译Apk,这无疑增加了我们排查问题的难度。

  至于safe args的好处,我觉得不是很重要。因为如果你使用了错误的action,崩溃问题一般会在开发阶段就能出现,所以肯定不会带到线上去。至于其他的参数传递问题,这个safe args自身就不能完全避免,因为它只能检测能放到Bundle的参数,其他参数也是无能为力。所以,我觉得目前的safe args还是比较鸡肋的,简单的项目可以尝试一下。

(D). 如何进行标准化传参呢?

  需要传递的传参一般分为两种:

  1. 可以序列化的参数,例如原始数据类型,以及Parcelable和Serializable类型。
  2. 不可序列化的参数,比如不能实现Parcelable和Serializable接口的。

  我们直接分情况来讨论一下。首先可以序列化的参数,传递起来非常简单,因为NavControllernavigate方法本身有一个Bunble参数,可以用来传递参数:

  而这个Bundle无疑是放到Fragment的argument里面去,有兴趣可以提前看看FragmentNavigatornavigate方法。后续,我们也会重点分析它。

  不可序列化的参数,可以通过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的一个实现,我们需要注意如下几点:

  1. FragmentNavigator内部的Navigator.Extras实现类名为FragmentNavigator.Extras,这个实现类仅支持传递一些View-String的键值对。主要是为了处理共享元素的case。
  2. 我们可以依葫芦画瓢,参考FragmentNavigator.Extras的实现,实现我们自己的Extras类,不过这个得依赖于自定义Navigator。自定义Navigator在下篇内容里面会重点介绍。

(5).popUpTo和popUpToInclusive

  在正式介绍popupTo之前,我们先来介绍一下现在action跳转的方式:

  1. enter & exit:就是传统的进入一个页面,和退出一个页面。比如说,上面我们配置的action,都是通过此方式进入的。此方式最大的特点就是,在进入一个页面的时候,不用管此页面是否已经有实例,都会创建一个新的对象,放入返回栈中。
  2. 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:popUpToapp:popUpToInclusive。上面已经简单解释过这两个属性了,这里我在解释一下:

  1. app:popUpTo:跳转到目前页面的时候,会先在返回栈寻找是否已经有该页面的实例,如果有的话,需要将该页面之上的页面都清空。
  2. 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启动模式的味道,但是也不完全一样。这也是我对它有异议的地方,就目前而言,这个属性存在的宗旨还是比较好的,但却像是一个半成品,比如说:

  1. 当ABA的情况,为啥第二个A页面是一个新的实例呢?
  2. 为啥必须配置popUpToInclusive,才能保证只有一个A实例呢?

  除此之外,action还有一个属性就是:launchSingleTop。这个工作原理跟action的singleTop很像。基本解释如下:

如果当前栈顶元素是目标页面,那么不会重新创建新的实例;如果当前栈顶元素是不是目标页面,那么就会重新创建新的实例。注意的是,该属性不能保证返回栈中只有一个实例。

3. 基本结构

  关于Navigation的使用介绍的差不多了,这里我们即将进行正式的源码分析。但是在分析之前,我们先要对Navigation的执行流程有一个整体的轮廓,这样对我们理解源码具体的含义有很大的帮助。
  本小节主要有如下两个部分的内容:

  1. Navigation的执行流程。
  2. 流程中的核心类解释。

(1). 执行流程

  我们先来看看执行流程的内容。我将执行流程的内容分为如下几步:

  1. NavHostFragment加载过程。
  2. 页面跳转流程。
  3. 页面返回流程。

(A). NavHostFragment的加载

  关于NavHostFragment的解释,前面已经简单解释过了。不过这里详细的分析,它加载过程中,做了哪些事情。


  上面的流程图解释了在NavHostFragment加载过程中所做的事情,主要分为三个阶段。这里,我对上面的内容做一些补充:

  1. onCreate方法里面调用了onCreateNavController方法,而这个方法主要是给NavController添加了很多的Navigator。当NavController创建的时候,会默认添加NavGraphNavigatorActivityNavigator;而在onCreateNavController方法里面,添加了DialogFragmentNavigatorFragmentNavigator。其中FragmentNavigator就是来处理Fragment之间的跳转,这也是后续我们会重点讲解的内容。
  2. onCreate方法通过调用NavController的setGraph方法,给其设置graph id。这个过程就会触发graph文件的解析,这个也是我们后续分析graph文件解析的开始点。
  3. 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的一些核心过程,比如说:

  1. 解析Graph文件。
  2. 创建和初始化NavController,这为后续的页面跳转做好了准备。

(B). NavController

  这个类可以理解为页面导航的控制器。我们在跳转的时候,是直接拿到这个类的对象,然后传递对应的参数。一般来说,我们可以使用两种方式来进行过跳转。

  1. action跳转。NavController在拿到我们传递的action id之后,会在graph中去寻找对应的页面,如果找到了,就可以成功跳转;如果找不到,就会直接崩溃。一般来说,找不到页面的action要么是无效的,要么是非法的。
  2. deepLink跳转。NavController会通过我们传递的信息,去匹配对应的页面,如果匹配到多个页面,会选择匹配度最高的页面;如果没有匹配到,也会崩溃。

(C). NavDestination

  在Navigation中,不同类型的页面都被抽象成为NavDestinationNavDestination分别抽象了如下几个页面:

页面 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方法里面其实做了两件事:

  1. 创建NavInflater对象,并且解析graph文件。
  2. 默认导航到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方法里面的内容主要分为两步:

  1. 首先是拿到当前节点名称,然后通过Navigator创建对应的NavDestination。需要特别注意的是,当前节点的名称只会是NavDestination子类对应的元素名称,不会有其他的。
  2. 其次就是解析该节点所有子元素。这一步需要特别两点:首先是,如果当前节点是include,表示是另外一个graph文件,重头开始解析,这里调用的是只有一个参数的inflate方法;如果当前节点是navigation,解析出来也就是NavGraph,那么需要继续递归解析其子元素,因为它的子元素还有其他的NavDestination,比如说fragment、activity等,这调用的是带4个参数的inflate方法。

  inflate方法的大概内容基本就是这样,其实这里面还有的方法,比如说inflateArgumentForDestinationinflateDeepLink等,这些都是给NavDestination解析相关属性和行为的,有兴趣的同学可以深入到里面去看看,这里就不展开了。
  我们基本熟悉了解析过程,那么解析完成之后,节点是以什么样的数据结构存储的呢?这里我用一张图来展示一下:

  存储完成之后,每个页面(节点)需要跳转的时候,可以使用自身的action,从这个图中去寻找目标页面,从而实现页面的跳转。需要特别注意的是,这里存储数据结构其实是一棵树,这个要跟graph文件可视化的图要区分开来

5. 总结

  到这里,Navigation的上篇内容就结束了,我对本篇内容做一个小小的总结。

  1. graph文件对于Navigation来说,是以一个配置文件的形式存在的。其内容是以节点(destination)和路径(action、deepLink)组成,从而形成一张图。
  2. 页面跳转方式有两种,分别是:action和deepLink。需要注意的是,这两种方式是如何定义和使用。
  3. 每个页面都是以NavDestination的形式存在的,一个graph文件解析出来就是一颗以NavDestination为节点的树。

  本篇内容都比较简单,下篇内容会重点介绍Navigation其他实现原理,敬请期待。

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