Jetpack系列—— Navigation 使用入门篇

5,902 阅读9分钟

Navigation 是 Android Jetpack 组件包 中的重要一员,借助于 Single Activity 和 多个 Fragment 碎片,优化 Android Activity 启动的开销和简化 Activity 之间的数据通信问题。

Navigation 是一套完整的导航框架,内置支持普通 Fragment、Activity 和 DialogFragment 组件的跳转,也就是所有 Dialog 或 PopupWindow 都建议使用 DialogFragment 实现,这样可以涵盖所有常用的跳转场景,统一返回栈的管理。另外,基于 Fragment 实现可以做到状态存储和恢复。

假设你是一名传统的基于 Activity 开发者,现在想迁移到 Navigation 导航架构,你一定会下面几个疑问:

  1. 全都用 Fragment?那原本 Activity 跳转的启动类型 (singleTask、singleTop) 如何提供支持?
  2. Fragment 之间的如何通信?
  3. 原本 onActivityForResult 现在应该如何实现一个 "onFragmentResult" ?
  4. 我的项目已经用 Activity 开发完成了,如何向 Navigation 迁移?迁移成本有多大?可以 Acitivity 和 Navigation 混合开发吗?

下面,我们从 Navigation 的使用出发,逐一回答上面的问题。

创建导航图

引入依赖

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

目前最新的稳定版本为 2.3.5,其他历史版本可查看 Android Navigation Releate Note

新建导航图资源

  1. 在 res 目录下新建 navigation 目录,然后新建 navigation 资源文件,我们假设为 home.xml

新建.png

创建 Fragment

这里假设包括欢迎页(Title)和 关于页(About)。 都是简单的显示文字一段文字,不再贴出代码。

在导航图中添加目的地,设置起始目的地。

借助 Navigation 图形化工具 Design 快速开发一个导航图,支持按组件名称筛选。

目的地.png

我们可以设置目的地的预览布局,使导航图看起来更直观,添加完成 Title 和 About 目的地后预览效果如下:

预览.png

我们切换到 “Code” 模式看看生成的内容:

xml结构.png

默认情况下,第一个添加到导航图的组件会被认为是起始目的地,也可以通过修改 navigation 根节点的 app:startDestination 指定起始目的地。当一次导航指向导航图本身的 Id 时,会直接导航到 这个 起始目的地。

组织跳转关系

最后我们希望点击 Title 页面的一个按钮时跳转到 About 页面。

此时在 Navigation Design 面板下从 Title 页面出发,引出一条指向 About 页面的线即可添加一条跳转路径。

跳转.png

可以看到可以为这次跳转设置转场动画和启动类型。

我们再次切换到 “Code” 模式,看看发生了什么。

结构.png

可以看到,在 Title Fragment 标签下自动生成了 Action 标签,其 destination 属性指向 About 页面,表示目的地为 About 页面。

二、创建布局文件

接下来,我们创建主 Activity 的布局文件,主要是把 第一步创建的 导航图 inflate 进来。

<!-- main_activity.xml -->
<LinearLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <!--Fragment 容器-->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_container"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        app:defaultNavHost="true"
        app:navGraph="@navigation/home" />
    <!-- 底部导航 -->
    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_nav"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:menu="@menu/bottom_nav"/>
</LinearLayout>

NavHostFragment

FragmentContainerView 为一个 Fragment 容器,实际的 inflate 进来的 Fragment 为 androidx.navigation.fragment.NavHostFragment

这里目前是固定写法,NavHostFragment 内部实现了 导航图的解析、NavController 的创建 (NavController 是面向上层、操作导航图的实现类,后面会详细解释) 以及起始目的地的创建和导航,也就是说当导航图 inflate 完成后页面上就会展示出 上面设置的起始目的地。

一个 NavHostFragment 对应一个 NavController,后文讲到获取 NavController 对象的过程跟这里息息相关。

然后我们创建一个也是唯一一个 Activity,与普通的 Activity 没有任何区别。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)
    }
}

app:defaultNavHost 属性

设置为 true,表示此导航图将优先代理 系统返回键,用户通过手势或虚拟返回键返回时会优先弹出 Fragment 返回栈;若设置为 false,将会直接退出 Activity。

全局仅有一处可以将此属性设置为 true。

三、获取导航控制器实现页面跳转

此时,我们需要为 Title 页面添加跳转事件。

class Title : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.fragment_title, container, false)

        view.findViewById<Button>(R.id.about_btn).setOnClickListener {
            // 使用 Navigation 跳转,传递的 ID 为导航图中 Action标签声明的 ID
            findNavController().navigate(R.id.action_titleScreen_to_aboutScreen)
        }
        return view
    }
}

最后,来看一下跳转效果。

navigation.gif

这里有个非常关键的 API:findNavController(),它是 Fragment 的扩展方法,返回一个 NavController 对象,通过调用这个对象的 navigate 方法完成最终的跳转。我们接下来重点看一下 NavController 是如何获取到的, 另外它到底提供了哪些功能。

四、NavController 的获取及其能力

NavController 的获取

上面的例子中,我们通过 Fragment 的扩展方法可以拿到此 Fragment 从属的 NavController,另外还有一些重载的方法:

// 根据 viewId 向上查找
NavController findNavController(Activity activity, int viewId)

// 根据 view 向上查找
NavController findNavController(View view)

本质上 findNavController 就是在当前 view 树中,查找距离指定 view 最近的父 NavHostFragment 对应的 NavController,目前仅做了解即可。

NavController 的能力

对于应用层来说,整个 Navigation 框架,我们只跟 NavController 打交道,它提供了常用的跳转、返回和获取返回栈等能力。

NavController.png

  • 跳转:我们可以通过各种重载的 navigate 方法完成跳转,重载方法可以设置跳转动画、跳转参数、启动模式等。
  • 返回:我们可以通过 popBackStack 或者 navigateUp 方法完成返回,二者的主要区别是当返回栈仅剩余一个页面时 navigateUp 方法不会退出当前 Activity(除 deeplink 的场景,后面单独讨论),而 popBackStack 会立即退出 Activity;其他场景二者逻辑完全一致。
  • 获取返回栈:通过 getBackStackEntry 方法可以获取整个返回栈,getCurrentBackStackEntry 可以拿到当前页面的返回栈。返回栈可以用于页面的恢复重建,同时 NavBackStackEntry 具备生命周期感知和 ViewModelStoreOwner,因此可以用于导航图级别的 ViewModel 数据通信(这里 Navigation 原理篇会详细介绍)。
  • 监听页面跳转的变化:使用 addOnDestinationChangedListener 接口可以在感知导航图内任意页面的变化,这一点非常实用,比如实现底部导航选中态与 内容区的联动。

四、跳转时传递参数

通过带 bundle 参数的 navigate 方法传递参数

通过指定 bundle 参数可以为目的地传递参数,比如:

findNavController().navigate(R.id.action_to_aboutScreen, bundleOf("key" to "title"))

在目的地 Fragment 可以直接通过 getArguments() 方法获取 这个bundle。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val value = arguments?.getString("key")
    ...
}

通过 safeArgs 插件

可以发现传统跳转方式需要在起始目的地两侧约定好 key 的名称和类型,否则容易出现未知的错误。

因此,Navigation 框架单独提供了 safeArgs 插件,以声明 xml 配置文件的方式,在编译器自动生成 所有参数的包装类,帮助开发者快速封装和解封跳转参数。

假设现在我们想在跳转 About 的同时传递一个 String 类型的参数:

  1. 项目根目录添加 safeArgs 插件
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$versions.nav_version"
  1. 主工程build.gradle 添加 safeArgs plugin
apply plugin: "androidx.navigation.safeargs"
  1. 导航图中为 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"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/home"
    app:startDestination="@+id/titleScreen">

    <fragment
        android:id="@+id/titleScreen"
        android:name=".Title"
        android:label="@string/title_home"
        tools:layout="@layout/fragment_title">
        <!--声明跳转参数-->
        <action
            android:id="@+id/action_titleScreen_to_aboutScreen"
            app:destination="@id/aboutScreen">
            <argument
                android:name="key"
                app:argType="string"/>
        </action>
    </fragment>
    
    <fragment
        android:id="@+id/aboutScreen"
        android:name=".About"
        android:label="@string/title_about"
        tools:layout="@layout/fragment_about">
        <!--声明接收参数-->
        <argument
            android:name="key"
            app:argType="string"/>

    </fragment>
</navigation>
  1. 项目执行 rebuild 后会生成两个类,分别为 XxxDirections 和 YyyyArgs 形式的 TitleDirections 和 AboutArgs,xxx 分别为起点 Action 的 id 后缀和 终点 fragment 的 name 后缀,此时跳转代码改为:
//Title 起点跳转
findNavController().navigate(TitleDirections.actionTitleScreenToAboutScreen("value"))

//About 终点解析
class About : Fragment() {
    // saftArgs API
    private val args by navArgs<AboutArgs>()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val key = args.key
        ...
    }
}

五、回答开篇问题

好了,我们回过头来看看开篇的问题。

1. Fragment 跳转的启动类型 (singleTask、singleTop) 如何提供支持?

对于 singleTop 的支持,在创建导航图的章节中我们注意到,action 标签有一个 app:launchSingleTop 属性,当属性值为 true 时效果与 Activity 的 singleTop 启动模式一致,即当返回栈顶与目的地一样时,不再重新创建新的目的地。

不同的是,栈顶的 Activity 会收到 onNewIntent 回调以更新跳转参数,而 Fragment 是没有这个回调的,代替它的是,需要在 Fragment 中添加 onDestinationChanged 监听器,并使用新的跳转参数(如需)。

findNavController().addOnDestinationChangedListener {
    controller, destination, arguments ->
    ...
}

对于 singleTask Navigation 没有直接的支持,取而代之的是更加通用的 popup 配置。

action 标签有两个关于 popup 的属性,可以实现 singleTask 的效果。

<action
    android:id="@+id/action_to_title"
    app:destination="@id/titleScreen"
    app:popUpTo="@id/titleScreen"
    app:popUpToInclusive="true"
/>
  • popUpTo:指定先 pop 到那个目的地再执行跳转动作。
  • popUpToInclusive:上述 pop 动作是否包含 popUpTo 给定的目的地,默认为 false。

举个例子,假设当前的返回栈是 A -> B -> C,C 添加一个 action 并指定 destination 为 A、popUpTo 为 A、popUpToInclusive 为 true,则跳转后的返回栈为 A' (因为旧的 A 已经弹出返回栈);反之,如果 popUpToInclusive 指定为 false,则目前的返回栈为 A -> A'。

所以,可以发现 Navigation 实现的类 singleTask 模式,与 Activity 本身还是有一些区别的。

如果确定返回栈中本身就有 A,则还可以通过 popBackStack 实现回到 A 的效果。

findNavController().popBackStack(R.id.A, inclusive = false)

2. Fragment 之间的如何通信?

Fragment 中的通信还可以分为两种场景,假设目前 返回栈中有两个Fragment 分别为 A 和 B。

  • 若 A 与 B 在同级子图中,可以在两端通过创建 导航图级别的 ViewModel 完成交互。

例如当前返回栈为 NavGraphA -> NavDestinationB -> NavDestinationC -> NavDestinationD

若想实现 C 与 D 的通信,需要使用 可以使用 节点B 创建 ViewModel。

val vm by navGraphViewModels<TitleVm>(R.id.nav_destination_b)

R.id.home 为二者的最近的公共父 Graph,在父 Graph 销毁前,二者通信都是有效的。

  • 若 A 与 B 不在同级子图中,可以使用距离二者最近的公共父 Graph 完成通信。

例如当前返回栈为 NavGraphA -> NavDestinationB -> NavGraphC -> NavDestinationD

若想实现 B 与 D 的通信,需要使用 A节点创建 ViewModel。

val vm by navGraphViewModels<TitleVm>(R.id.home)

最后,当然可以直接使用 Activity 级别的 ViewModel 完成通信,但是 ViewModel 生命周期将会更长,你应该根据实际情况选择最短生命周期的 ViewModel 范围。

3. 我的项目已经用 Activity 开发完成了,如何向 Navigation 迁移?可以 Acitivity 和 Navigation 混合开发吗?

要回答这个问题,你需要知道 Navigation 是如何管理返回栈的,本篇不做深入的展开,只给出结论,后文原理篇会有详细解释。

可以混合开发,但你需要保证,不能存在混合跳转的场景,假设 FragmentA 和 FragmentC 处在同一个导航图中,那么下面的跳转是无法支持的: FragmentA -> ActivityB -> Framgment C。所以在条件允许的情况下,可以将完全独立的深度较深的次级页面以低优先级迁移 Navigation。

这一点与 Flutter 不能很好的支持混合栈开发是一样的。

4. 原本 onActivityForResult 现在应该怎么一个 "onFragmentResult" 实现?

这个问题完全可以用 问题2 中 Fragment 的通信机制实现,但是我们需要创建一个新的 ViewModel 来承载这个 Result,还有一种更优雅的方式:通过 SaveStateHandle 完成,在下一节 Naviagtion 原理篇中我们再做解释。

关于我

  • 掘金
  • 公众号 wanderingTech