Android Jetpack 之 Navigation 全面剖析 | 开发者说·DTalk

本文原作者: 搜狐视频客户端,原文发布于: 搜狐技术产品

https://mp.weixin.qq.com/s/1URoDU0zgoYlSQM8zYqx9w

Navigation 是 Android JetPack 框架中的一员,是一套新的 Fragment 管理框架,可以帮助开发者很好的处理 fragment 之间的跳转,优雅的支持 fragment 之间的转场动画,支持通过 deeplink 直接定位到 fragment,通过第三方的插件支持 fragment 之间安全的参数传递,可以可视化的编辑各个组件之间的跳转关系。导航组件的推出,使得我们在搭架应用架构的时候,可以考虑一个功能模块就是一个 Activity,模块中每个子页面使用 Fragment 实现,使用 Navigation 处理 Fragment 之间的导航。更有甚者,设计一个单 Activity 的应用也不是没有可能。最后还要提一点,Navigation 不只是能管理 Fragment,它还支持 Activity,小伙伴们请注意这一点。

下面我们来详细介绍下 Navigation 的使用,在使用之前我们来先了解 3 个核心概念:

1、Navigation Graph 这是 Navigation 的配置文件,位于 res/navigation/ 目录下的 xml 文件,这个文件是对导航中各个组件的跳转关系的预览。在 design 模式下,可以很清晰的看到组件之间关系,如图 1 所示。

2、NavHost 一个空白的父容器,承担展示目的 fragment 的作用。源码中父容器的实现是 NavHostFragment,在 Activity 中引入这个 fragment 才能使用 Navigation 的能力。

3、NavController 导航组件的跳转控制器,管理导航的对象,控制 NavHost 中目标页面的展示。

下面我们从一个简单的例子先看下 Navigation 的基本用法。

一、工程搭建

我们设计一个应用,分别实现首页,详情页,购买页,登录页,注册页。跳转关系如下: 首页->详情页->购买页->首页,首页->登录页->注册页->首页。如果使用 FragmentManager 管理,需要对页面创建,参数传递以及页面回退做许多工作,下面我们看一下 Navigation 是如何管理这些页面的。首先,创建一个空白的工程,只包含一个 activity,修改工程的 build.gradle 文件使之包含下面的引用


def nav_version ="2.3.0"

// 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"

在 "Project" 窗口中,右键点击 res 目录,然后依次选择 New > Android Resource File,此时系统会显示 New Resource File 对话框。在 File name 字段中输入名称,例如 "nav_graph"。从 Resource type 下拉列表中选择 Navigation,然后点击 OK,生成的导航的 xml (图 1 中 1 位置)

图一

在可视化编辑模式下,点击左上角的 icon (图 1 中 2 位置) 在 xml 中添加导航页面。 添加完导航页面,选中一个页面,在右侧的属性栏,可以为页面添加跳转 action, deeplink 和跳转传参。直接把两个页面之间连线,也可以建立跳转的 action。选中一条页面间的连线,可以编辑这个 action,为 action 添加转场动画,出栈属性和传参默认值。右键点击一个页面,在右键菜单中选择 edit, 就可以编辑对应 fragment 的 xml 文件。都配置完成后,最终的导航图就如图 2 所示。建立完导航图,我们还需要设置一个当做首页的 Fragment 一启动就展示,在要设置的 Fragment 上点击右键,选择 Set Start Destination,将它设置为首页,设置完成后,被选中的 Fragment 会有一个 start 标签 (图 1 中 3 位置) 当 Activity 启动的时候,它会做为默认的页面替换布局中的 NavHostFragment。

下面是 nav_graph.xml 配置文件部分内容,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/nav_graph"
    app:startDestination="@id/homeFragment">
    <fragment
        android:id="@+id/homeFragment"
        android:name="com.example.navicasetest.HomeFragment"
        android:label="fragment_home"
        tools:layout="@layout/fragment_home" >
        <action
            android:id="@+id/action_homeFragment_to_detailFragment"
            app:destination="@id/detailFragment"
            app:enterAnim="@anim/slide_in_right"
            app:exitAnim="@anim/slide_out_left"
            app:popEnterAnim="@anim/slide_in_left"
            app:popExitAnim="@anim/slide_out_right" />
        <action
            android:id="@+id/action_homeFragment_to_loginFragment"
            app:destination="@id/loginFragment" />
    </fragment>
    <!--这里省略其他的fragment的配置-->
    ...
</navigation>

通过上面的配置,我们就完整的创建了一个导航图。如下图所示

下面就需要把导航添加到 activity 中。在 MainActivity 的 xml 中,添加 Navigation 的容器 NavHostFragment,NavHostFragment 是系统类,我们后面分析它内部的实现。xml 配置如下

<fragment
    android:id="@+id/fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_graph"
    />

我们发现 xml 中有 2 个新的配置项,app:navGraph 指定导航配置文件。app:defaultNavHost 值为 true,标识是让当前的导航容器 NavHostFragment 处理系统返回键,在 Navigation 容器中如果有页面的跳转,点击返回按钮会先处理容器中 Fragment 页面间的返回,处理完容器中的页面,再处理 Activity 页面的返回。如果值为 false 则直接处理 Activity 页面的返回。

二、页面跳转和参数传递

页面间的跳转是通过 action 来实现,我们在 HomeFragment 中增加 detail button 的点击响应,实现从首页到详情页的跳转,代码实现如下。这里用到了 NavController,我们后面会详细介绍它,这里先看它的用法。

mBtnGoDetail.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        NavController contorller = Navigation.findNavController(view);
        contorller.navigate(R.id.action_homeFragment_to_detailFragment);
    }
});

下面介绍如何在导航之间传递参数

1、Bundle 方式

第一种方式是通过 Bundle 的方式。NavController 的 navigate 方法提供了传入参数是 Bundle 的方法,下面看一下实例代码。从首页传参到商品详情页,首页传入参数

Bundle bundle = new Bundle();
bundle.putString("product_name","苹果");
bundle.putFloat("price",10.5f);
NavController contorller = Navigation.findNavController(view);
contorller.navigate(R.id.action_homeFragment_to_detailFragment, bundle);

解析传参

if (getArguments() != null) {
    mProductName = getArguments().getString("product_name");
    mPrice = getArguments().getFloat("price");
}

如果两个 fragment 直接传递的参数较多,这种传参方法就显得很不友好,需要定义好多名字,并且不能保证传参的一致性,还容易出错或者自定义一个 model,实现序列化方法。这样也是比较繁琐。

Android 系统还提供一种 SafeArg 的传参方式。比较优雅的处理参数的传递。

2、安全参数 (SafeArg) 

第一步,在工程的 build.gradle 中添加下面的引用

classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0"

在 app 的 build.gradle 中增加

apply plugin: 'androidx.navigation.safeargs'

第二步,编辑 navigation 的 xml 文件在本例中是 nav_graph.xml. 可以通过可视化编辑,也可以直接编辑 xml. 编辑完毕如下图

<fragment
    android:id="@+id/detailFragment"
    android:name="com.example.myapplication.DetailFragment"
    android:label="fragment_detail"
    tools:layout="@layout/fragment_detail" >
    <action
        android:id="@+id/action_detailFragment_to_payFragment"
        app:destination="@id/payFragment" />
    <argument
        android:name="productName"
        app:argType="string"
        android:defaultValue="unknow" />
    <argument
        android:name="price"
        app:argType="float"
        android:defaultValue="0" />
</fragment>

修改完 xml 后,编译一下工程,在 generate 文件夹下会生成几个文件。如下图

在首页的跳转函数中,写下如下代码

mBtnGoDetailBySafe.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Bundle bundle = new DetailFragmentArgs.Builder().setProductName("苹果").setPrice(10.5f).build().toBundle();
        NavController contorller = Navigation.findNavController(view);
        contorller.navigate(R.id.action_homeFragment_to_detailFragment, bundle);
    }
});

在详情页接收传参的地方,解析传参的代码

Bundle bundle = getArguments();
if(bundle != null){
    mProductName = DetailFragmentArgs.fromBundle(bundle).getProductName();
    mPrice = DetailFragmentArgs.fromBundle(bundle).getPrice();
}

DetailFragmentArgs 内部是使用了 builder 模式构建传参的 bundle。并且以 getter,setter 的方式设置属性值,这样开发人员使用起来比较简洁,和使用普通 java bean 的方式基本一致。细心的同学发现了,上面除了 DetailFragmentArgs 还生成了 2 个 direction 类,我们以 HomeFragmentDirections 为例看下用法,HomeFragmentDirections 能够直接提供跳转的 OnClickListener,

mBtnGoDetailBySafe.setOnClickListener(Navigation.createNavigateOnClickListener(HomeFragmentDirections.
        actionHomeFragmentToDetailFragment().setProductName("苹果").setPrice(10.5f)));

分析 HomeFragmentDirections 代码不难发现,本质是将 action id 与 argument 封装成一个 NavDirections,内部通过解析它来获取 action id 与 argument,最终还是会执行 NavController 的 navigation 方法执行跳转。下面看一下 HomeFragmentDirections 的内部实现。

@NonNull
public static ActionHomeFragmentToDetailFragment actionHomeFragmentToDetailFragment(){
    return new ActionHomeFragmentToDetailFragment();
}

public static class ActionHomeFragmentToDetailFragment implements NavDirections {
    private final HashMap arguments = new HashMap();

    private ActionHomeFragmentToDetailFragment() {
    }

    @NonNull
    public ActionHomeFragmentToDetailFragment setProductName(@NonNull String productName) {
        if (productName == null) {
            throw new IllegalArgumentException("Argument \"productName\" is marked as non-null but was passed a null value.");
        }
        this.arguments.put("productName", productName);
        return this;
    }

    @NonNull
    public ActionHomeFragmentToDetailFragment setPrice(float price) {
        this.arguments.put("price", price);
        return this;
    }

    @Override
    public int getActionId() {
        return R.id.action_homeFragment_to_detailFragment;
    }

    @SuppressWarnings("unchecked")
    @NonNull
    public String getProductName() {
        return (String) arguments.get("productName");
    }

    @SuppressWarnings("unchecked")
    public float getPrice() {
        return (float) arguments.get("price");
    }

}

3、ViewModel. 

导航架构中,也可以通过 ViewModel 的方式共享数据,后面我们还会讲到使用 ViewModel 的必要性。每个 Destination 共享一份 ViewModel,这样有利于及时监听数据变化,同时把数据展示和存储隔离。在上面的例子中,每个页面都需要登录状态,我们把用户登录状态封装成 UserViewModel,在需要监听登录数据变化的页面实现如下代码

userViewModel.getUserModel().observe(getViewLifecycleOwner(), new Observer<UserModel>() {
    @Override
    public void onChanged(UserModel userModel) {
        if(userModel != null){
            //登录成功,展示用户名
            mUserName.setText(userModel.getUserName());
        } else {
            mUserName.setText("未登录");
        }
    }
});

这样当用户登录后,各个页面都会得到通知,刷新当前的昵称展示。

三、动画

多数场景下,2 个页面之间的切换,我们希望有转场动画,Navigation 对动画的支持也很简单。可以在 xml 中直接配置。

<fragment
    android:id="@+id/homeFragment"
    android:name="com.example.navicasetest.HomeFragment"
    android:label="fragment_home"
    tools:layout="@layout/fragment_home" >
    <action
        android:id="@+id/action_homeFragment_to_detailFragment"
        app:destination="@id/detailFragment"
        app:enterAnim="@anim/slide_in_right"
        app:exitAnim="@anim/slide_out_left"
        app:popEnterAnim="@anim/slide_in_left"
        app:popExitAnim="@anim/slide_out_right" />
</fragment>

enterAnim: 配置进场时目标页面动画 exitAnim: 配置进场时原页面动画 popEnterAnim: 配置回退时目标页面动画 popExitAnim: 配置回退时原页面动画配置完后,动画展示如下

四、导航堆栈管理

Navigation 有自己的任务栈,每次调用 navigate() 函数,都是一个入栈操作,出栈操作有以下几种方式,下面详细介绍几种出栈方式和使用场景。

1、系统返回键

首先需要在 xml 中配置 app:defaultNavHost="true",才能让导航容器拦截系统返回键,点击系统返回键,是默认的出栈操作,回退到上一个导航页面。如果当栈中只剩一个页面的时候,系统返回键将由当前 Activity 处理。

2、自定义返回键

如果页面上有返回按钮,那么我们可以调用 popBackStack() 或者 navigateUp() 返回到上一个页面。我们先看一下 navigateUp 源码

public boolean navigateUp() {
    if (getDestinationCountOnBackStack() == 1) {
        // If there's only one entry, then we've deep linked into a specific destination
        // on another task so we need to find the parent and start our task from there
        NavDestination currentDestination = getCurrentDestination();
        int destId = currentDestination.getId();
        NavGraph parent = currentDestination.getParent();
        while (parent != null) {
            if (parent.getStartDestination() != destId) {
                //省略部分代码
                return true;
            }
            destId = parent.getId();
            parent = parent.getParent();
        }
        // We're already at the startDestination of the graph so there's no 'Up' to go to
        return false;
    } else {
        return popBackStack();
    }
}

从源码可以看出,当栈中任务大于 1 个的时候,两个函数没什么区别。当栈中只有一个导航首页 (start destination) 的时候,navigateUp() 不会弹出导航首页,它什么都不做,直接返回 false。popBackStack 则会把导航首页也出栈,但是由于没有回退到任何其他页面,此时 popBackStack 会返回 false, 如果此时又继续调用 navigate() 函数,会发生 exception。所以 google 官网说不建议把导航首页也出栈。如果导航首页出栈了,此时需要关闭当前 Activity。或者跳转到其他导航页面。示例代码如下

...

if (!navController.popBackStack()) {
    // Call finish() on your Activity
    finish();
}

3、popUpTo 和 popUpToInclusive

还有一种出栈方式,就是通过设置 popUpTo 和 popUpToInclusive 在导航过程中弹出页面。popUpTo 指出栈直到某目标,字面意思比较难理解,我们看下面这个例子。假设有 A,B,C 3 个页面,跳转顺序是 A to B,B to C,C to A。依次执行几次跳转后,栈中的顺序是 A>B>C>A>B>C>A。此时如果用户按返回键,会发现反复出现重复的页面,此时用户的预期应该是在 A 页面点击返回,应该退出应用。此时就需要在 C 到 A 的 action 中设置 popUpTo="@id/a"。这样在 C 跳转 A 的过程中会把 B,C 出栈。但是还会保留上一个 A 的实例,加上新创建的这个 A 的实例,就会出现 2 个 A 的实例。此时就需要设置 popUpToInclusive=true. 这个配置会把上一个页面的实例也弹出栈,只保留新建的实例。

下面再分析一下设置成 false 的场景。还是上面 3 个页面,跳转顺序 A to B,B to C. 此时在 B 跳 C 的 action 中设置 popUpTo="@id/a", popUpToInclusive=false。跳到 C 后,此时栈中的顺序是 AC。B 被出栈了。如果设置 popUpToInclusive=true 此时栈中的保留的就是 C。AB 都被出栈了。在咱们的示例中,在注册界面,用户注册完成后,希望直接返回首页。这样我们就需要在从 RegisterFragment 到 HomeFragment 的跳转过程中,弹出之前栈中的首页,登录页和注册页,添加如下配置既可达到我们想要的效果。

<fragment
    android:id="@+id/registerFragment"
    android:name="com.example.navicasetest.RegisterFragment"
    android:label="fragment_register"
    tools:layout="@layout/fragment_reg" >
    <action
        android:id="@+id/action_registerFragment_to_homeFragment"
        app:destination="@id/homeFragment"
        app:popUpTo="@id/homeFragment"
        app:popUpToInclusive="true"/>
</fragment>

五、DeepLink

Navigation 组件提供了对深层链接 (DeepLink) 的支持。通过该特性,我们可以利用 PendingIntent 或者一个真实的 URL 链接,直接跳转到应用程序的某个 destination 下面我们分别看一下这两种的使用方式。

1、PendingIntent

创建一个通知栏,通过 Navigition 创建 PendingIntent。


private void createNotification(){

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        int importance = NotificationManager.IMPORTANCE_DEFAULT;
        NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "ChannelName", importance);
        channel.setDescription("description");
        NotificationManager notificationManager = getSystemService(NotificationManager.class);
        notificationManager.createNotificationChannel(channel);
    }

    NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle("促销水果")
            .setContentText("香蕉")
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setContentIntent(getPendingIntent())//设置PendingIntent
            .setAutoCancel(true);

    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
    notificationManager.notify(100001, builder.build());
}

private PendingIntent getPendingIntent() {
    Bundle bundle = new Bundle();
    bundle.putString("productName", "香蕉");
    bundle.putFloat("price",6.66f);
    return Navigation
            .findNavController(this,R.id.fragment)
            .createDeepLink()
            .setGraph(R.navigation.nav_graph)
            .setDestination(R.id.detailFragment)
            .setArguments(bundle)
            .createPendingIntent();
}

在 DetailFragment 解析传参即可。参考上面的传参小节。效果如下所示

2、URL 连接

URL 的使用也比较简单,我们下面给商品详情页 (DetailFragment) 添加 deeplink 支持,URL 格式如下。www.mywebsite.com/detail?productName={productName}price={price} 首先,需要在导航 xml 中,添加 deeplink 支持,添加完成 xml 如下

<fragment
    android:id="@+id/detailFragment"
    android:name="com.example.navicasetest.DetailFragment"
    android:label="fragment_detail"
    tools:layout="@layout/fragment_detail">
    <action
        android:id="@+id/action_detailFragment_to_payFragment"
        app:destination="@id/payFragment"
        app:enterAnim="@anim/slide_in_right"
        app:exitAnim="@anim/slide_out_left"
        app:popEnterAnim="@anim/slide_in_left"
        app:popExitAnim="@anim/slide_out_right" />
    <argument
        android:name="productName"
        android:defaultValue="unknow"
        app:argType="string" />
    <argument
        android:name="price"
        android:defaultValue="0.0f"
        app:argType="float" />
    <deepLink
        android:autoVerify="true"
        app:uri="www.mywebsite.com/detail?productName={productName}price={price}" />
</fragment>

然后,在 Manifest 文件中,添加如下配置

<nav-graph android:value="@navigation/nav_graph"/>

我们的 DetailFragment 中已经做了对参数 productName 和 price 的解析。安装 app 后,使用 adb 命令测试 deeplink 连接

adb shell am start -a android.intent.action.VIEW -d "http://www.mywebsite.com/detail?productName="香蕉"price=10"

执行 adb 命令后,商品详情页被正常拉起。

六、场景对比

上面介绍了 Navigation 的基本用法,这一小节我们将构建一个页面,分别看一下使用 Navigation 和不使用 Navigation 对页面架构的影响。在我们以往的项目开发过程中,业务复杂且包含的模块比较多的页面,我们经常用独立的 fragment 来承担不同的业务子页面,但是 fragment 之间的跳转,转场动画,以及回退栈管理,开发者需要自己实现相关逻辑。我们看下面的例子:

实现上面包含 3 个 tab 的首页,常规做法是使用 BottomNavigationView + fragment 来搭架。代码如下,需要自己管理 fragment 的创建以及加载。

public class MainActivity2 extends AppCompatActivity {

    private int laseSelectPos = 0;
    private Fragment[] fragments;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main2);
        HomeFragment homeFragment = new HomeFragment();
        DashboardFragment dashboardFragment = new DashboardFragment();
        NotificationsFragment notificationsFragment = new NotificationsFragment();
        fragments = new Fragment[]{homeFragment, dashboardFragment, notificationsFragment};

        laseSelectPos = 0;
        
        getSupportFragmentManager()
                .beginTransaction()
                .add(R.id.fl_con, homeFragment)
                .show(homeFragment)//展示
                .commit();
        BottomNavigationView navView = findViewById(R.id.nav_vew_2);

        navView.setOnNavigationItemSelectedListener(new BottomNavigationView.OnNavigationItemSelectedListener() {
            @Override
            public boolean onNavigationItemSelected(@NonNull MenuItem item) {
                switch (item.getItemId()){
                    case R.id.navigation_home:
                        if (0 != laseSelectPos) {
                            setDefaultFragment(0);
                            laseSelectPos = 0;
                        }
                        return true;
                    case R.id.navigation_dashboard:
                        if (1 != laseSelectPos) {
                            setDefaultFragment(1);
                            laseSelectPos = 1;
                        }
                        return true;
                    case R.id.navigation_notifications:
                        if (2 != laseSelectPos) {
                            setDefaultFragment(2);
                            laseSelectPos = 2;
                        }
                        return true;
                }
                return false;
            }
        });
    }

    private void setDefaultFragment( int index) {
        FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
        transaction.replace(R.id.fl_con, fragments[index]);
        transaction.commit();
    }
}

配置文件如下:

<?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"
    android:paddingTop="?attr/actionBarSize">


    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_vew_2"
        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_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:menu="@menu/bottom_nav_menu" />

    <FrameLayout
        android:id="@+id/fl_con"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toTopOf="@+id/nav_vew_2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

如果我们使用 Navigation + BottomNavigationView 来搭建上述要页面,代码如下:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        BottomNavigationView navView = findViewById(R.id.nav_view);
        // Passing each menu ID as a set of Ids because each
        // menu should be considered as top level destinations.
        AppBarConfiguration appBarConfiguration = new AppBarConfiguration.Builder(
                R.id.navigation_home, R.id.navigation_dashboard, R.id.navigation_notifications)
                .build();
        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment);
        NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);
        NavigationUI.setupWithNavController(navView, navController);
    }

}

配置文件如下

<?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"
    android:paddingTop="?attr/actionBarSize">

    <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" />

    <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" />

</androidx.constraintlayout.widget.ConstraintLayout>

比较上面 2 份代码,明显 Navigation 的方式实现更简洁,框架帮我们做了好多创建和管理的工作,我们只要专注每个 fragment 的业务即可。例子中只是单纯的展示 fragment,后面如果要加 deeplink 跳转,转场动画等需求,就会更加体现 navigation 优势。

七、源码分析

Navigation 暴露给开发者的就是 NavHostFragment,NavController 以及导航图。导航图又在 xml 文件中设置给了 NavHostFragment。所以我们就主要分析这两个类 NavHostFragment 和 NavController。我们带着下面几个问题来分析下源码:

  1. 导航图是如何解析?

  2. 页面跳转是如何实现的?

  3. 为什么从一个静态方法随便传入一个 view,就能拿到 NavController 实例?

  4. 导航框架不仅支持 fragment 还支持 activity,是如何做到的?

为了避免大量的代码影响阅读体验,后面的源码分析只把关键的代码做了展示,本文中未列出的代码,读者可以自行参考源码。

1、NavHostFragment

要在某个 Activity 中实现导航,首先就是要在 xml 中引入 NavHostFragment,xml 中通过指定 app:navGraph="@navigation/nav_graph" 来指定导航图,那么应该是这个 Fragment 来负责解析并加载导航图。我们就从这个 Fragment 创建流程入手,来看一下源码。

1、onInflate 在这个流程中解析出我们上面提到的在 xml 配置的两个参数 defaultNavHost 和 navGraph,并保存在成员变量中 mGraphId,mDefaultNavHost。

final TypedArray navHost = context.obtainStyledAttributes(attrs,
                androidx.navigation.R.styleable.NavHost);
final int graphId = navHost.getResourceId(
        androidx.navigation.R.styleable.NavHost_navGraph, 0);
if (graphId != 0) {
    mGraphId = graphId;
}
navHost.recycle();

final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.NavHostFragment);
final boolean defaultHost = a.getBoolean(R.styleable.NavHostFragment_defaultNavHost, false);
if (defaultHost) {
    mDefaultNavHost = true;
}
a.recycle();

2、onCreate,在 OnCreate 中,我们发现了 NavController 是在这里创建的,这就说明一个导航图对应一个 NavController,在 OnCreate 中还把上面的 mGraphId,设置给了 NavController。

mNavController = new NavHostController(context);
//省略部分代码
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);
    }
}

3、onCreateView 在这个函数中,只是创建了一个 FragmentContainerView,这个 View 是一个 FrameLayout,用于加载导航的 Fragment

@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                         @Nullable Bundle savedInstanceState) {
    FragmentContainerView containerView = new FragmentContainerView(inflater.getContext());
    // When added via XML, this has no effect (since this FragmentContainerView is given the ID
    // automatically), but this ensures that the View exists as part of this Fragment's View
    // hierarchy in cases where the NavHostFragment is added programmatically as is required
    // for child fragment transactions
    containerView.setId(getContainerId());
    return containerView;
}

4、onViewCreated 在这个函数中,把 NavController 设置给了父布局的 view 的中的 ViewTag 中。这里的设计比较关键,为什么要放到 tag 中呢?其实这样的设计是为了让我们外部获取这个实例比较便捷,我们上面的问题 3 的答案就在这里,我们先看一下查找 NavController 的函数 Navigation.findNavController(View),请注意 API 的设计,似乎传递任意一个 view 的引用都可以获取 NavController,这里就是通过递归遍历 view 的父布局,查找是否有 view 含有 id 为 R.id.nav_controller_view_tag 的 tag,tag 有值就找到了 NavController。如果 tag 没有值说明当前父容器没有 NavController。这里我们贴一下保存和查找的代码。

public static void setViewNavController(@NonNull View view,
                                        @Nullable NavController controller) {
    view.setTag(R.id.nav_controller_view_tag, controller);
}

@Nullable
private static NavController findViewNavController(@NonNull View view) {
    while (view != null) {
        NavController controller = getViewNavController(view);
        if (controller != null) {
            return controller;
        }
        ViewParent parent = view.getParent();
        view = parent instanceof View ? (View) parent : null;
    }
    return null;
}
   

以上 4 步,就是 NavHostFragment 的主要工作,我们通过上面的分析可以看到,这个 Fragment 没有承担任何 Destination 的创建和导航工作。也没有看到导航图的解析工作,这个 Fragment 只是创建了个容器,创建了 NavController,然后只是单纯的把 mGraphId 设置给了 NavController。我们猜测导航的解析和创建工作应该都在 NavController 中。我们来看一下 NavController 的源码。

2、NavController

导航的主要工作都在 NavController 中,涉及 xml 解析,导航堆栈管理,导航跳转等方面。下面我们带着上面剩余的 3 个问题,分析下 NavController 的实现。

  1. 上面我们提到 NavHostFragment 把导航文件的资源 id 传给了 NavController,我们继续分析代码发现,NavController 把导航 xml 文件传递给了 NavInflater, NavInflater 主要负责解析导航 xml 文件,解析完毕后,生成 NavGraph,NavGraph 是个目标管理容器,保存着 xml 中配置的导航目标 NavDestination。

@NonNull
private NavDestination inflate(@NonNull Resources res, @NonNull XmlResourceParser parser,
                               @NonNull AttributeSet attrs, int graphResId)
        throws XmlPullParserException, IOException {
    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;
    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) {
            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) dest).addDestination(inflate(res, parser, attrs, graphResId));
        }
    }

    return dest;
}
  1. 导航目标解析完毕,具体的页面跳转是如何实现的呢,在使用过程中我们调用的是 NavController 的 navigate 函数,抽丝剥茧,发现导航最终调用的是 Navigator 的 navigate 函数。

Navigator<NavDestination> navigator = mNavigatorProvider.getNavigator(
        node.getNavigatorName());
Bundle finalArgs = node.addInDefaultArgs(args);
NavDestination newDest = navigator.navigate(node, finalArgs,
        navOptions, navigatorExtras);

我们看到导航的具体实现是 Navigator,我们上面的例子是以 Fragment 为导航目标,但是 Navigation 的目标对象不只是 Fragment,还可以是 Activity,后面可能还会扩展其他种类,这里谷歌把导航抽象成了 Navigator,NavController 中没有持有具体的导航种类,而是持有的抽象类 Navigator,把所有 Navigator 的实例保存在了 NavigatorProvider 中。这里就运用了设计模式中的依赖倒置原则,要面向接口编程,而不是具体实现。同时也符合了开闭原则,后面在扩展新的导航种类,不会影响到现有的种类。通过以上的分析,问题 2 和问题 4 也就得到了解答。我们以 FragmentNavigator 为例,看一下具体的导航逻辑的实现。只分析部分关键代码片段

String className = destination.getClassName();
if (className.charAt(0) == '.') {
    className = mContext.getPackageName() + className;
}
final Fragment frag = instantiateFragment(mContext, mFragmentManager,
        className, args);
......
frag.setArguments(args);
final FragmentTransaction ft = mFragmentManager.beginTransaction();



ft.replace(mContainerId, frag);
ft.setPrimaryNavigationFragment(frag);

从以上代码可以看出,Fragment 实例是通过 instantiateFragment 创建的,这个函数中是通过反射的方式创建的 Fragment 实例,Fragment 还是通过 FragmentManager 进行管理,是用 replace 方法替换新的 Fragment,这就是说每次导航产生的 Fragment 都是一个新的实例,不会保存之前 Fragment 的状态。这样的话,可能会造成数据不同步的现象。所以 Google 建议导航和 ViewModel 配合使用效果更佳。

综上所述,NavController 是导航的核心类,它负责页面加载,页面导航,和堆栈管理。但是这些逻辑没有都耦合在这个类中,而是采用组合的方式,把这些实现都拆分成了单独的模块。NavController 需要实现哪些功能,调用相应功能即可。

八、总结

上面我们列举了导航的基本用法以及源码分析,通过上面的学习,大家也了解到了,导航组件是一个页面的管理框架,创建简洁,使用方便,在构架业务复杂的页面时,架构清晰,功能多样,可以使开发者可以专注于业务逻辑的开发,是一个优秀的框架。我们在学习的过程中,不仅要学会如何使用,还要深入地学习其架构原理,为我们以后的项目架构,提供可借鉴的方案。

参考文献:

https://developer.android.google.cn/guide/navigation/navigation-getting-started https://www.jianshu.com/p/ad040aab0e66


长按右侧二维码

查看更多开发者精彩分享

"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。

 

 点击屏末 |  | 即刻报名参与 "开发者说·DTalk" 

 


已标记关键词 清除标记
相关推荐