【背上Jetpack之Navigation】想去哪就去哪,Android世界的指南针

13,155 阅读11分钟

前言

androidx Navigation 组件是 Android 中应用内导航的官方库,当前最新的版本为 2.3.0-beta01(2020.05.20)

很多人不喜欢 Navigation 因为其设计不符合开发者的预期,它在管理「平级界面」时来回切换会导致平级的 fragment 重建。网上针对这一问题有一个 重写 Navigator 的方案,大多数人会简单地认为 Navigation 无法保存 fragment 状态是因为使用了 replace(曾经的我也这样认为)

本文的内容为 Navigation 的职能边界,简单使用,高阶使用技巧(例如同一 activity 部分内部分 fragment 共享 ViewModel,模块化)以及关于 Navigation 所谓的「设计问题」的探讨

对 Navigation 的使用及职能边界已经了解的小伙伴可以直接跳过前两部分

由于文章内容较长,已将模块化部分单独成文,地址在这

没有 Navigation 的世界

Android 中,activity 和 fragment 是主要的视图控制器,因此界面间的调转也是围绕 activity / fragment 进行的

// 跳转 activity
val intent = Intent(this, SecondActivity::class.java)
intent.putExtra("key", "value")
startActivity(intent)


// 跳转 fragment
supportFragmentManager.commit {
    addToBackStack(null)
    val args = Bundle().apply { putString("key", "value") }
    replace<HomeFragment>(R.id.container, args = args)
}

如果项目比较大,我们可能会将 KEY 抽取为常量,并且在 activity 和 fragment 中填写静态方法以告诉调用者该界面需要什么参数

// SecondActivity
companion object {
    @JvmStatic
    fun startSecondActivity(context: Context, param: String) {
        val intent = Intent(context, SecondActivity::class.java)
        intent.putExtra(Constant.KEY, param)
        context.startActivity(intent)
    }
}

// HomeFragment
companion object {
    fun newInstance(param: String) = HomeFragment().apply {
        arguments = Bundle().also {
            it.putString(Constant.KEY, param)
        }
    }
}

可以看到,得益于 kotlin 的扩展函数,界面间跳转的代码已足够简洁

但是

  • 如果在每个界面加上跳转动画呢?
  • 当你接手一个较大的项目,如何能快速理清界面间的跳转关系?
  • 在单 activity 的项目中,如何控制几个相关的 fragment 有着相同的 ViewModel 作用域,而不是整个 activity 共享?
  • 组件间的界面跳转?

Navigation 简介

摘自19I/0大会
摘自19I/0大会

Jetpack 导航组件是一套库,工具和指南,为应用内导航提供了强大的导航框架

它是一套库,封装着应用内导航的 API

引入依赖如下

dependencies {
  def nav_version = "2.3.0-beta01"

  // Java language implementation
  implementation "androidx.navigation:navigation-fragment:$nav_version"
  implementation "androidx.navigation:navigation-ui:$nav_version"

  // Kotlin
  implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
  implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

  // Dynamic Feature Module Support
  implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"

  // Testing Navigation
  androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
}

destination
destination

它支持 fragment ,activity,或者是自定义的 destination 间的跳转

Navigation UI
Navigation UI

Navigation UI 库 支持 Drawer,Toolbar 等 UI 组件

它是一套工具,在 Android Studio 中可以可视化管理界面的导航逻辑

Android Studio 提供可视化管理的工具
Android Studio 提供可视化管理的工具

现在我们对 Navigation 有一个初步的认识,接下来我们看看 Navigation 的职能边界

Navigation 能做什么

  • 简化界面跳转的配置
  • 管理返回栈
  • 自动化 fragment transaction
  • 类型安全地传递参数
  • 管理转场动画
  • 简化 deep link
  • 集中并且可视化管理导航

Navigation 工作逻辑

Navigation 主要有三个部分

  • Navigation Graph
  • NavHost
  • NavController

Navigation Graph

Navigation Graph 是一种新的 resource type,它是一个集中管理 navigation 的 xml 文件

Navigation Graph
Navigation Graph

Navigation Graph 中的每一个界面叫:Destination,它可以使 fragment ,activity,或者自定义的 Destination

Navigation 管理的就是 Destination 间的跳转

点击 Destination,可以在屏幕右侧看见 deep link 等信息的配置

destination attitude
destination attitude

Navigation Graph 中的带箭头的线叫:Action,它代表着 Destination 间不同的路径

点击 Action,可以在屏幕右侧看到 Action 的详细配置,动画,Destination 间跳转传递的参数,操作返回栈,Launch Options

action attributes
action attributes

不知道各位小伙伴大学是否学过 图论,个人感觉 Navigation Graph 就像 有向图,而其中的 Destination 和 Action 就像图论中的

NavHost

NavHost 是一个空容器,用于显示 navigation graph 中的 destination。 导航组件提供一个默认的 NavHost 实现 NavHostFragment,它显示 fragment destination

NavHostFragment 是 navigation-fragment 中的类

NavHostFragment
NavHostFragment

它提供了一个可独立导航的区域,使用时大概是这样

NavHostFragment 使用
NavHostFragment 使用

所有的 fragment Destination 都是通过 NavHostFragment 管理,相当于一个容器

每个 NavHostFragment 都有一个 NavController,用于定义 navigation host 中的导航。 它还包括 navigation graph 以及 navigation 状态(例如当前位置和返回栈),它们将与 NavHostFragment 本身一起保存和恢复

NavController

NavController 帮助 NavHost 管理导航,其内部持有 NavGraphNavigator(通过持有 NavigatorProvider 间接持有)

其中 NavGraph 决定了界面间的跳转逻辑,它通常在 Android resource 下创建,同时也支持通过代码动态创建

Navigator 定义了一在应用内导航的机制。它的实现类有 ActivityNavigator, DialogFragmentNavigator, FragmentNavigator, NavGraphNavigator。当然,开发者也可以自定义 Navigator。每种 Navigator 都有自己的导航策略,例如 ActivityNavigator 使用 starActivity 来进行导航

总结

下面引用一张 KunMinX 的专栏 重学 Android Navigation 一文中的配图,帮助大家理解这其中的依赖关系

来自 重学安卓:就算不用 Jetpack Navigation,也请务必领略的声明式编程之美!

我们在 res/navigatoin 创建的 xml 文件叫 Navigation Graph (类似图论中的图)

其内部每个节点叫 Destination(类似图论中的点) ,它对应着 activity/fragment/dialog,代表着屏幕上的界面

连接 DestinationDestination 之间的线叫 Action(类似图论中的边),它是从一个界面跳转另个一个界面的抽象,可以配置跳转动画,传递参数,以及返回栈等信息

NavHost 是显示 Navigation Graph 的容器,实现类为 NavHostFragment,每个 NavHostFragment 中都持有一个 NavController

NavController 是导航的大管家,封装着 navigate navigateUp popBackStack 等方法

Navigator 是对 Destination 之间跳转的封装。由于 Destination 可以是 activity 或 fragment,因此有了 ActivityNavigator FragmentNavigator 等实现类,用于实现具体的界面跳转

Navigation 的使用技巧

Dialog Destination

Navigation 2.1.0 引入,用于实现 navigate 到一个 DialogFragment

使用也很简单,使用 dialog 标签,其他处理的与 fragment 相同

dialog destination
dialog destination

同一 graph 中共享 ViewModel

我们都知道 fragment 可以使用 activity 级别共享 ViewModel,但是对于单 activity 项目,这就意味着所有的 fragment 都能拿到这个共享的 ViewModel。本着最少知道原则,这不是一个好的设计

想要在部分fragment中共享ViewModel
想要在部分fragment中共享ViewModel

Navigation 2.1.0,官方引入了 navigation graph 内共享的 ViewModel,这使得 ViewModel 的作用域得到了细化,业务之间可以很好地被隔离

使用起来非常简单

// kotlin
val viewModel: MyViewModel by navGraphViewModels(R.id.my_graph)
// java
NavBackStackEntry backStackEntry = navController.getBackStackEntry(R.id.my_graph);
MyViewModel viewModel = new ViewModelProvider(backStackEntry).get(MyViewModel.class);

Safe Args

什么是 Safe Args
什么是 Safe Args

什么是 Safe Args?

它是一个 Gradle 插件,可以根据 navigation 文件生成代码,帮助开发者安全地在 Destination 之间传递数据

那么为什么要设计这样一个插件呢?

我们知道使用 bundle 或者 intent 传递数据时,如果出现类型不匹配或者其他异常,是在 Runtime 中才能发现的,而 Safe Args 把校验转移到了编译期

为什么设计Safe Args
为什么设计Safe Args

使用 Safe Args 需要手动引入插件

buildscript {
    repositories {
        google()
    }
    dependencies {
        def nav_version = "2.3.0-alpha06"
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}

前面我们提到 Safe Args 会生成代码

如果要生成 java 代码,则在 app 或其他 module 的 build.gradle 中加入

apply plugin: "androidx.navigation.safeargs"

如果向生产 kotlin 代码,则加入

apply plugin: "androidx.navigation.safeargs.kotlin"

参数是在 action 中配置的,我们只需在加入 argument 标签

<action android:id="@+id/startMyFragment"
    app:destination="@+id/myFragment">
    <argument
        android:name="myArg"
        app:argType="integer"
        android:defaultValue="1" />
</action>

Navigation 支持以下类型

支持的参数类型
支持的参数类型

启用 Safe Args 后,生成的代码将为每个操作以及每个发送和接收 destination 创建以下类型安全的类和方法

  • 为拥有 action 的发送 destination 的创建一个类,该类的类名为 destination 名 + Directions。例如我们的发送 destination 为 SpecifyAmountFragment,那么将会生成 SpecifyAmountFragmentDirections 类。该类会为 destination 的每个 action 创建一个方法
  • 为每个传递参数的 action 创建一个内部类,如果 action 叫 confirmationAction 则会创建 ConfirmationAction 类。如果 action 的参数没有默认值,则要求开发者使用该类设置参数
  • 为接收 destination 创建一个类,该类的类名为 destination 名 + Args。例如我们的接收 destination 为 ConfirmationFragment,那么将会生成 ConfirmationFragmentArgs 类。使用该类的 fromBundle() 方法可以取出从发送 destination 传来的参数

下面的代码展示如何在发送 destination 传递参数

override fun onClick(v: View) {
   val amountTv: EditText = view!!.findViewById(R.id.editTextAmount)
   val amount = amountTv.text.toString().toInt()
   // 在相应的 action 中配置参数
   val action = SpecifyAmountFragmentDirections.confirmationAction(amount)
   v.findNavController().navigate(action)
}

接下来展示如何在接收 destination 中取出传来的参数,kotlin 可以使用 by navArgs() 获取参数

// kotlin
val args: ConfirmationFragmentArgs by navArgs()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    val tv: TextView = view.findViewById(R.id.textViewAmount)
    val amount = args.amount
    tv.text = amount.toString()
}
// java
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
    TextView tv = view.findViewById(R.id.textViewAmount);
    int amount = ConfirmationFragmentArgs.fromBundle(getArguments()).getAmount();
    tv.setText(amount + "")
}

嵌套 navigation graph

有一些 destination 通常是组合使用,并且在多个地方重复调用。例如独立的登录流程,后续的忘记密码,修改密码等 destination 可以看做一个整体来使用

这种情况我们使用嵌套 navigation graph,选中一组可以作为整体的 destination (按住 shift 鼠标点选),然后右击选中 Move to Nested Graph > New Graph 。这样就生成了 嵌套 navigation graph。在 嵌套 navigation graph 上双击即可查看内部的 destination

创建嵌套navigation graph
创建嵌套navigation graph

如果想要引用其他 module 中的 graph,可以使用 include 标签

include 使用
include 使用
include 使用
include 使用

全局 action

您可以为多个 destination 创建共用的 action,例如您可能想要在不同的 destination 中导航至相同的界面

对于这种情况您可以使用全局 action

选中一个 destination 并右击,选择 Add Action > Global ,一个箭头会 出现在 destination 的左边

全局 action
全局 action

使用也很简单,向 navigate 方法传入全局 action 的资源 id 即可

viewTransactionButton.setOnClickListener { view ->
    view.findNavController().navigate(R.id.action_global_mainFragment)
}

条件导航

在开发过程中,我们可能会遇到一个 destination 根据条件跳转不同的 destination 的情况

例如一些 destination 需要用户处于登录状态才能进入,或者在游戏结束后,胜利和失败跳转不同的 destination

下面我们用一个示例来展示 Navigation 如何处理该种场景

该示例中,用户尝试跳转到资料页中,如果该用户处于未登录状态,则需要跳转到登录界面

graph
graph

我们使用 LoginViewModel 来保存登录状态,从 ProfileFragment 点击按钮跳转到 ProfileDetailFragment,在该界面判断登录的状态,如果是未授权则跳转到 LoginFragment,如果已授权则提示欢迎

ProfileDetailFragment
ProfileDetailFragment

在登录界面判断登录状态,如果授权成功则回到 ProfileDetailFragment,授权失败则显示登录失败提示

在登录界面点击返回视为未授权,应该直接返回 ProfileFragment 界面

LoginFragment
LoginFragment

Deep Links

开发过程中我们可能会遇到这类的需求,我们需要让用户打开 app 时直接空降到某个特定页面(例如点开通知栏跳转到特定文章),亦或者我们需要从一个 destination 跳转到一个在其他流程中比较深的位置的 destination ,如下图,从 FriendList 跳转到 Chat 界面

上面的这种需求叫作 deep link ,Navigation 支持两种 deep link 的跳转。

显式 deep link

在 manifest activity -> intent-filter 标签下 加入 action ,category ,data 等标签,满足条件的 intent 可以被打开

详情见 官方文档,这里不再赘述,我们只关注如何通过 Navigation 构建 intent

val pendingIntent = NavDeepLinkBuilder(context)
    .setGraph(R.navigation.nav_graph)
    .setDestination(R.id.android)
    .setArguments(args)
    .createPendingIntent()

如果已经存在 NavController,可以通过 NavController.createDeepLink() 方法创建 deep link

隐式 deep link

在 navigation graph 中支持 deepLink 标签

例如上图从 FriendListFragment 跳转到 ChatFragment,可以在 graph ChatFragment 下加入 deepLink 标签

<fragment
    android:id="@+id/chatFragment"
    android:name="com.flywith24.bottomtest.ChatFragment
    android:label="ChatFragment">
    <argument
        android:name="userId"
        app:argType="string" />
    <deepLink
        android:id="@+id/deepLink"
        app:uri="chat://convention/{userId}" />
</fragment>

NavController#navigate() 方法支持传入 URI

navigate to deep link
navigate to deep link

因此可直接调用

val userId = "1111"
findNavController().navigate("chat://convention/$userId".toUri())

模块化

参见 【奇技淫巧】使用 Navigation + Dynamic Feature Module 实现模块化

Navigation 设计探讨

fragment replace 你真的了解吗

androidx 下 frament replace 的行为你真的了解吗?

该部分内容我们在 【背上 Jetpack】绝不丢失的状态 androidx SaveState ViewModel-SaveState 分析【背上 Jetpack 之 Fragment】从源码角度看 Fragment 生命周期 AndroidX Fragment1.2.2 源码分析 已有分析

因此我们直接说结论

fragment replace 后 前一个 fragment 会执行 onDestroyView 而不执行 onDestroy ,即 fragment 本身未销毁,其内部 view 被销毁

FragmentManager 的 moveToState 方法在触发 fragment 的 onDestroyView 前根据条件会执行 fragmentStateManager.saveViewState() 方法来保存 view 状态(1.2.2,旧版本该处方法方法名略有不同)

而由于 fragment 本身没有销毁,其成员也不会被销毁

所以当返回后 view 的状态会被恢复,而成员状态没有改变,所以 replace 后 fragment 能恢复到之前的状态

那么 Navigation 的所谓 「设计问题」是怎么回事?

被重建的 fragment

我们通过 Android Studio 创建项目,模版选择 Bottom Navigation Activity,可以得到使用 Navigation 实现的 平级 tab 切换模版。我们使用从 HomeFragment 切换至 DashboardFragment,之后切换回 HomeFragment。日志如下

使用navigation切换fragment
使用navigation切换fragment

可以看到 HomeFragment 被重建了,原实例(e6c266),新实例(c3e49cc)

fragment 被重建,这就是原因所在!

那么为什么出现这种现象?我们翻一下源码

navigate方法
navigate方法
通过反射创建新的fragment实例
通过反射创建新的fragment实例

从源码可以看到,其内部通过反射创建了新的 fragment 实例,这导致 fragment 内部的状态无法恢复

不过如果 navigation 导航的所有 destination 没有平级关系,换句话说在一个返回栈内,这样的设计是没有问题的

但是有些时候我们希望使用 navigation 管理一些平级界面,例如 BottomNavigation

不符合 Material Design 的 BottomNavigation

issuetracker 有这样一个 issue,注意提出的时间

issue
issue

主要意思就是现阶段的 bottom tab navigation 不符合 Material Design 的规范

  • 标签间要保存滚动位置
  • 每个标签应该有自己独立的返回栈

相同类型的 issue 还有这些

相同类型的issue
相同类型的issue

Ian Lake 在该条 issue 下给出了详细的解答

我在这里简单介绍一下 Ian Lake,虽然不知道他的职位,但从他的活跃程度看应该是 fragment 和 navigation 的负责人,在 Google I/O 大会 和 Android Dev Summit 多次进行演讲,例如 fragment 的过去,现在和将来,单 activity 项目

官方解答
官方解答

一句话解释:单个 FragmentManager 不支持多个返回栈,以现有的 fragment API 无法做到这一支持,开发者不得不自己实现多返回栈(例如我在 【背上 Jetpack 之 Fragment】从源码的角度看 Fragment 返回栈 附多返回栈 demo 一文中提供的 demo)

但他提供了短期和中期方案

  • 短期:提供一个公开示例,展示使用当前 API 进行多返回栈的实现(即,每个底部导航项都有一个单独的 NavHostFragment 和 navigation graph)。其 demo 在这 ,核心逻辑在 NavigationExtensions.kt

  • 中期:在 Fragment 上构建正确的 API,以便它们可以正确地支持多个返回栈,包括为所有返回栈上的所有 Fragment 正确保存和恢复已保存的实例状态和非配置实例状态。 这项工作目前处于探索阶段,尽管我希望,但我无法提供时间表或保证这项工作将会成功

以上回复发生在 2019 年 2 月

在 2019 年 10 月 Ian Lake 再次回复了开发者的疑问

官方补充1
官方补充1
官方补充2
官方补充2

多返回栈支持计划需要三步走

  • 为 fragment 提供相应的 API 以保证多返回栈的 fragment 状态能够被保存
  • NavController API 提供了通用框架,该框架允许任何 Navigator(括 FragmentNavigator)支持多返回栈
  • NavigationUI 中提供的新 API,可让您在使用 BottomNavigationView 或 NavigationView 时 无论是单返回栈(当前模式)还是多返回栈都能控制 setupWithNavController()

之后他回复了 70 楼的开发者,明确了如果使用单个 NavHostFragment / navigation graph / FragmentManager 能够支持多返回栈,那么从 A 或 B 导航到 C 就不会有任何问题

接着时间来到了 2020 年

2020年的最新回复
2020年的最新回复

今年 1 月 30 日,Ian Lake 再次做了回复

大概意思就是原定 Fragment 1.3.0Navigation 2.3.0 提供多返回栈支持的计划跳水了,计划在 Fragment 1.4.0-alpah01Navigation 2.4.0-alpha01 提供

至此关于 Navigation 所谓的 「设计问题」就探讨结束了

欢迎各位小伙伴在评论区留言说说你的想法

Jetpack 系列

背上 Jetpack 系列到此已经将主要的组件都讲完了,没看过的小伙伴赶快去看看,我将博客整理分类并发在了 这里 ,如果对你有帮助的话记得点赞哦


关于我

我是 Fly_with24