JetPack指路明灯—Navigation

1,804 阅读9分钟

国际惯例,官网镇楼

developer.android.com/guide/navig…

很多人在学习JetPack的时候喜欢到处找资料和各种学习的博客,但其实,官网上的资料已经很丰富了,而且写的很好,大部分时间,只需要先将官网上的资料吃透,基本上已经秒杀市面上80%的博客和文章了。

这篇文章并不会花大篇幅讲解Navigation的各种使用,因为官网文档已经无比详细了,本篇文章更重要的是讲解设计原理和核心概念的分析。

Navigation是JetPack中非常重要的一员,他对现代化的Android JetPack架构,提供了基础,是构建整体架构的核心组件。同时,Navigation也是一个优秀的Fragment管理工具(当然,不仅仅是管理Fragment,Activity也是可以的),可以很好的处理之前使用Fragment那些不是很好的方面,通过Navigation,开发者可以将重点放在业务开发上,避免处理太多了Fragment管理代码和调用代码,从而加速业务开发效率。

  • 提供了Fragment管理容器
  • 支持Deeplink、URL Link定位到Fragment
  • Fragment、Activity间更加安全的参数传递
  • 更加方便的处理过渡动画

使用Navigation主要需要创建以下几个部分的代码:

  • Navigation Graph:用于对Fragment进行配置的配置文件,需要在res/navigation/下创建的xml文件
  • FragmentContainerView/NavHostFragment:一系列Fragment的容器,用于承载Fragment
  • NavController:用于处理Fragment路由跳转

下面通过一个简单的例子,来演示下,如何使用Navigation。

引入依赖

implementation "androidx.fragment:fragment-ktx:1.2.0"
implementation "androidx.navigation:navigation-fragment-ktx:2.3.0"
implementation "androidx.navigation:navigation-ui-ktx:2.3.0"

创建测试Fragment和Activity

class LoginFragment : Fragment(R.layout.fragment_login) {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
    }
}

类似这样的测试Fragment,不浪费笔墨了。

创建Navigation Graph

在res文件夹下创建navigation文件夹,并定义一个xxxx.xml文件,选择类型为navigation。

这时候,将测试的Fragment导入Design视图,就可以看见这些Fragment的界面了,通过每个视图左右拉出来的箭头,就可以生产一个路由Action,如图所示。

截屏2020-11-23 20.31.35

通过可视化界面,可以很清楚的看见Fragment间的路由路径,同时要注意的是,单个Fragment可以生成不止一个Action,例如一个Fragment可以跳转多个其他Fragment。

通过Design生成的代码如下所示。

截屏2020-11-23 20.36.34

对于navigation标签来说,最重要的是它的startDestination属性,即类似MainActivity的概念,代表了路由的起点。多个destination连接起来就组成了一个栈导航图,destination之间连接就是action。

每个fragment标签,代表了一层路由,当然,这里不仅仅可以是fragment,也可以是Activity、Dialog。

在每个fragment标签里面的action标签,就代表路由的具体行为,destination就是该路由的终点。

创建Activity并引入NavHostFragment

在Activity的xml布局中,通过FragmentContainerView来创建这些Fragment的容器,代码如下所示。

截屏2020-11-24 19.16.27

FragmentContainerView是一个特殊的Fragment,只能添加Fragment,

  • app:navGraph:这里需要指定前面在res文件夹下创建的navigation文件
  • app:defaultNavHost="true":代表可以拦截系统的返回键,用来托管路由
  • android:name="androidx.navigation.fragment.NavHostFragment":代表这个容器就是用来管理Fragment的容器

FragmentContainerView内部会通过反射的方式,初始化名为name所指定的class——NavHostFragment,它就是所有需要管理的Fragment的Container。

在NavHostFragment中,有两个重要的参数,即mGraphId和mDefaultNavHost,保存着我们从xml中解析出来的数据。同时,在onCreate的时候,创建了NavController,与mGraphId进行绑定。

使用路由

在Fragment中,可以通过NavController来进行路由,代码如下所示。

class LoginFragment : Fragment(R.layout.fragment_login) {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        login.setOnClickListener {
            Navigation.findNavController(it).navigate(R.id.action_loginFragment_to_registerFragment)
        }
    }
}

同时,也可以通过Bundle来进行参数的传递,这跟之前使用Fragment基本类似,代码如下。

Navigation.findNavController(it).navigate(R.id.action_registerFragment_to_mainListFragment, bundleOf("name" to "xuyisheng"))

所以这里可以很方便的进行路由选择,针对不同的判断条件,选择不同的路由action。

为什么能获取

这里有个地方很有意思,那就是为什么通过view可以获取NavController。

Navigation.findNavController(View)

从源码中可以发现。

image-20201130142720552

实际上,他是从Tag中取出的,而这个Tag,则是在NavHostFragment的onViewCreated中创建的。

image-20201130142846939

这样的API设计,可以让用户传入View后进行遍历,通过查找指定Tag来获取NavController,简化了调用方式。

路由跳转

通过NavController进行路由跳转,有多种方式,比如通过路由action指定,也可以指定跳转的destination。

action

这就是前面提到的路由方式,也是最常用的路由方式,代码如下所示。

Navigation.findNavController(it).navigate(R.id.action_loginFragment_to_registerFragment)

不过要注意的是,使用action进行路由跳转,要保证当前页面的实例是存在的,否则会抛出异常。

destination

直接使用destination的id,同样可以跳转到指定的destination,代码如下所示。

Navigation.findNavController(it).navigate(R.id.mainListFragment)

这种方式,同样是创建一个新的页面实例。

返回控制

路由的返回控制,有两种方式,navigateUp和popBackStack。下面通过一个例子来演示下,如何对路由进行返回控制,下面有三个Fragment,A-B-C。

navigateUp

navigateUp与物理返回键的功能类似,即返回当前页面堆栈的栈顶页面,代码如下所示。

Navigation.findNavController(it).navigateUp()

当我们从A路由到B,B路由到C后,通过上面的代码,使用navigateUp返回,则路由返回路径为C到B,B到A,如果在A继续调用navigateUp,则不会响应,因为当前栈中只有唯一一个页面,而且是startDestination,所以不会再响应返回操作。

popBackStack

navigateUp只能响应向上一级的路由控制,而不能跨级进行路由返回,popBackStack则是对其的补充,可以指定路由返回的action,代码如下所示。

Navigation.findNavController(it).popBackStack(R.id.loginFragment, true)

当我们从A路由到B,B路由到C后,通过popBackStack返回,指定要返回到的Fragment的id,即可直接返回到指定位置,第二个参数inclusive,代表返回操作是否包含指定的Fragment id。

这里要注意的是,当你指定返回到A,同时inclusive为true的时候,A也是不会被移除的,因为A是栈顶。

实际上,navigateUp内部就是通过popBackStack实现的。

借助popBackStack的返回值,可以在跳转失败时,创建新的Fragment。

val flag = Navigation.findNavController(getView()).popBackStack(R.id.someFragment, false)
if (!flag){
    Navigation.findNavController(getView()).navigate(R.id.someFragment)
}

defaultNavHost

app:defaultNavHost="true"这个属性是我们最早在FragmentContainerView中设置的,通过这个属性,可以让当前的NavHostFragment拦截系统的返回键,也就是说,只要当前Fragment堆栈中有元素,就拦截系统返回键,用于Fragment堆栈的出栈,直到堆栈中只剩下一个元素,则将系统返回值的功能交还给Activity。

popupTo

当我们通过navigation去进行路由的时候,每次都会创建一个新的实例,所以,当navigation出现下面的循环图时,如下所示。

截屏2020-11-25 20.28.20

这样的循环图,会导致页面路由变成这样A—B—C—A—B—C,这就导致页面栈中存在了大量重复的页面。

所以在这种场景下,就需要在A—B—C之后,在C—A的路由中,配置popUpTo="@id/A",同时设置popUpToInclusive=true,将旧的A界面也移除,这样,C—A路由之后,页面栈中就只剩下A了(如果是false,则会存在两个A的实例),代码如下所示。

<fragment
    android:id="@+id/mainListFragment"
    android:name="com.example.navigation.MainListFragment"
    android:label="MainList">
    <action
        android:id="@+id/action_mainListFragment_to_loginFragment"
        app:destination="@id/loginFragment"
        app:popUpTo="@id/loginFragment"
        app:popUpToInclusive="true" />
</fragment>

再考虑下面这样一个场景,A—B,B路由到C的时候,设置popUpTo="@id/A",如果popUpToInclusive=false,则跳转到C之后的路由栈为A—C,如果设置为true,则只剩下A在路由栈中,代码如下所示。

<fragment
    android:id="@+id/registerFragment"
    android:name="com.example.navigation.RegisterFragment"
    android:label="Register">
    <action
        android:id="@+id/action_registerFragment_to_mainListFragment"
        app:destination="@id/mainListFragment"
        app:popUpTo="@id/loginFragment"
        app:popUpToInclusive="true" />
</fragment>

这个场景可以使用于登录注册之后跳转主页的场景,当跳转主页后,就应该把登录和注册的界面pop出栈。

所以,从上面的实例就可以分析出,在action中配置popUpTo属性,指的是在当前路由中,一直将页面出栈,直到指定的页面为止,而popUpToInclusive,则是代表包含关系,是否包含指定的页面。

个人感觉这个API命名为popUntil可能更合适一点。

在代码中,也存在类似的调用方法。

NavOptions.Builder()
	.setPopUpTo(R.id.fragmentOne, true)
	.build()

Navigation动态加载

除了在xml中设置navGraph,有很多场景下,我们会根据业务场景动态设置一些navGraph,或者某些navGraph是需要动态获取一些参数之后才去初始化的,这时候,就可以使用Navigation的动态加载方案。

首先,需要在Fragment容器中,去掉navGraph的引用,然后在Activity中,动态指定要引用的navGraph,代码如下所示。

// 动态加载
val navHostFragment = supportFragmentManager.findFragmentById(R.id.navFragmentHost) as NavHostFragment??:return
val navigation = navHostFragment.navController.navInflater.inflate(R.navigation.nav_graph_base)
navigation.startDestination = R.id.loginFragment
navHostFragment.navController.graph = navigation

实际上和动态Inflate布局再添加布局到容器的场景非常类似,Navigation动态加载也是将navGraph从xml中创建好之后设置给navigation,接收参数的话,与正常的参数传递是一样的。

添加路由动画

路由切换动画是action的属性,当我们使用action进行路由时,可以指定目标Page,和原Page的动画切换效果,它包含下面几个属性。

  • enterAnim:目标Page进入动画
  • exitAnim:目标Page进入时,原Page退出动画
  • popEnterAnim:目标Page退出动画
  • popExitAnim:目标Page退出时,原Page退出动画

有点绕,但是这个和原来Activity间使用的overridePendingTransition是一样的。这里的动画,可以通过在Design界面中,直接选中action来设置,也可以直接在代码中指定。设置好后,代码如下所示。

image-20201125200459714

动画文件比较简单,就是常见的补间动画。

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

    <translate
        android:fromXDelta="-100%"
        android:toXDelta="0%"
        android:fromYDelta="0%"
        android:toYDelta="0%"
        android:duration="700" />
</set>

在代码中,这些动画是通过NavOptions来承载的,并赋值给navigate()的参数。

总结

Navigation的引入,是Google在JetPack上下的第一步棋,通过Navigation,Google指明了在JetPack下Android开发的大方向:

  • 单Activity架构:Google这次重写了Fragment,希望能回到设计它的初衷,从目前来看,整个方向是对的
  • 申明式编程:将原始的命令式编程,向神明式编程转变,将逻辑申明出来,这很挑战老程序员的思维转变
  • 为其它组件铺路:Navigation的架构,适合与其它组件组合使用,例如,虽然每次都会创建Fragment的实例,但是通过LiveData来共享和恢复数据

总的来说,Navigation组件为新的现代化Android开发铺平了道路,但是要在现有的工程基础上进行改造,则成本是比较大的,大家应该先掌握Navigation的设计思想,这样可以更好的掌握其它JetPack组件。