目录
前言
最初我们写Android应用,往往都会一个页面就创建一个Activity
,然后不同页面之前就使用startActivity
进行跳转。后来出现了Fragment
,只需要用一个Activity
承载多个Fragment
,然后通过FragmentManager
管理Fragment
来实现页面切换的效果。但是不知道大家在实际开发过程中,有没有觉得FragmentManager
用起来并不是那么方便,如果真的想用一个Activtiy
多个Fragment
来完成整个APP开发,还是感觉不是很方便,Navigation
的出现就是为了解决这个问题。下面是官网对Navigation
的功能概述
- 处理 Fragment 事务(代替FragmentManager)
- 默认情况下,正确处理往返操作(管理页面堆栈)
- 为动画和转换提供标准化资源(页面切换动画)
- 实现和处理深层链接(类似Activity的隐式意图)
- 包括导航界面模式,用户只需完成极少的额外工作(封装组件,如抽屉式导航栏和底部导航,方便快速开发)
- Safe Args — 可在目标之间导航和传递数据时提供类型安全的 Gradle 插件(安全的页面跳转传参方式)
-
ViewModel
支持 - 您可以将ViewModel
的范围限定为导航图,以在图表的目标之间共享与界面相关的数据(其他Jetpack组件—ViewModel支持)
看完上面这些功能,我们就对Navigation
的作用大概有一个了解了,实际上最主要的作用就是制定统一的标准,来方便开发者管理Fragment
了解完Navigation
的主要作用之后,下面我们来看一下如何使用Navigation
创建项目
为了帮助新手快速体验一下什么是Navigation
,我们这里使用Android Studio的项目模板来快速创建一个包含Navigation的项目。(实际项目开发中我们不推荐这么做,具体原因我们后面会说)
首先创建一个新项目,选择Bottom Navigation Activity
完成项目创建后,项目目录格式如下,Android Studio会自动帮我们生成一个MainActivity
和三个Fragment
,还有一些资源文件
运行一下,看看效果
Navigation可视化配置
配置布局文件
activity_main.xml
我们再来看布局文件activity_main.xml
,这里面有一个fragment
标签,这是我们在以往的布局中是没有见过的。这个fragment
可以看作是存放Fragment的容器,它引用了一个mobile_navigation.xml
资源文件。下面的BottomNavigationView
就是底部的tab,它也引用了一个bottom_nav_menu.xml
资源文件
app:defaultNavHost="true"
的作用是,让Navigation
处理返回事件,点返回按钮时并不是返回上一个Activity
,而是返回上一个「页面」,上一个「页面」有可能是Activity
,也可能是Fragment
配置导航图
mobile_navigation.xml
这里引用了三个Fragment
,分别是HomeFragment
、DashboardFragment
和NotificationsFragment
startDestination
用于设置起始Fragment
切换到Design页面,可以通过可视化的方式配置fragment的一些属性
这里主要可以配置三方面的内容
- Arguments:跳转到当前页面的时候,需要携带的参数
1)Name
:参数名
2)Type
:参数类型
3)Default Value
:参数默认值
- Actions:当前fragment跳转到下一个目标页的动画
1)ID
:每一个action都要指定一个id
2)From
:当前页面
3)Destination
:跳转到哪个页面
4)Transition
:进出场动画
- Deep Links:通过当前url的方式拉起当前页面,类似隐式意图拉起Activity
可视化配置完成后,会自动生成代码,mobile_navigation.xml
如下所示(其实就是多了一些xml属性,这些属性你也可以手动编写)
mobile_navigation.xml
其实这里总结一下就是说,我们可以通过配置mobile_navigation.xml
文件去给fragment设置跳转参数、进出场动画、隐式跳转
获取导航器
配置完成后,页面跳转需要借助导航器(NavController
),那NavController
如何获取呢?,我们在Activity
、Fragment
、View
中都能够获得NavController
Fragment#findNavController()
View#findNavController()
Activity#findNavController(viewId: Int)
注意观察这三种方式的不同,Fragment
和View
获取NavController
都不需要传任何参数,而Activity
需要传递一个viewId
,这个viewId
是什么呢?
这个viewId
其实就是activity_main.xml中的fragment标签的id,所以我们一般会在Activity
中像下面这样获取导航器
val navController = findNavController(R.id.nav_host_fragment)
然后在fragment
标签中配置导航图app:navGraph="@navigation/mobile_navigation"
我们知道,Activity
和View
都不能单独存在,都需要依托于Activity
,所以Fragment
和View
中获取导航器都不需要传递viewId
,因为它们实际上都是基于Activtiy
中配置的导航图进行跳转
拿到导航器(NavController
)后,我们就可以通过它的navigate()
重载方法进行跳转,接下来我们会详细介绍各种跳转方式
Fragment跳转
常规方式跳转
- 发起页面跳转
var bundle = Bundle()
bundle.putString("args1", "geekholt")
bundle.putBoolean("args2", true)
findNavController().navigate(R.id.navigation_content, bundle)
R.id.navigation_content
就是在mobile_navigation.xml
中配置的fragment标签的id
- 接收页面跳转参数
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val root = inflater.inflate(R.layout.fragment_content, container, false)
val arg1 = arguments?.get("args1") as String
val arg2 = arguments?.get("args2") as Boolean
return root
}
可以看出,这里其实就是用类似跳转的方式,取代了原来的FragmentManager
的操作
看到这里可能有的人会有点奇怪,这里的传参和我们以往的项目开发也没啥不同的呀,那为什么前面还要在xml中配置参数呢?
其实这个是要配合一个谷歌官方提供的名为 Safe Args 的 Gradle 插件一起使用的,怎么使用呢?
Safe Args 跳转
- 在project的
build.gradle
文件中添加:
buildscript {
repositories {
google()
}
dependencies {
def nav_version = "2.3.0-alpha01"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
}
}
- 在业务模块的
build.gradle
文件中添加:
apply plugin: "androidx.navigation.safeargs.kotlin"
如果是java就用
apply plugin: "androidx.navigation.safeargs"
- 发起页面跳转
//HomeFragment.java
val action =
HomeFragmentDirections.actionNavigationHomeToNavigationContent("geekholt", true)
findNavController().navigate(action)
- 接收页面跳转参数
//ContentFragment.java
val args1 = ContentFragmentArgs.fromBundle(requireArguments()).args1
val args2 = ContentFragmentArgs.fromBundle(requireArguments()).args2
这里的HomeFragmentDirections
和ContentFragmentArgs
其实都是在编译期间编译期根据xml的一些参数自动生成的,所以在配置完导航图的时候,需要ReBuild一下项目,再进行调用
用这种方式的唯一好处其实就是避免强制类型转换异常,因为fromBundle
内部帮我们做了一些try catch
的操作
DeepLink跳转
DeepLink就类似通过隐式意图打开Activity
- 配置DeepLink
可以像前面介绍的在导航图中通过可视化的方式配置deepLink,也可以直接在xml中配置,如
{id}是一个参数占位符,如果没有定义具有相同名称的参数,则对参数值使用默认的 String
类型
- 在Manifest中为相应的
Activity
设置nav-graph
标签
nav-graph
标签会在编译时将导航图中的所有deepLink自动转化为下面这种格式
- 发起页面跳转
发起页面跳转有三种方案
- 通过adb命令进行测试
adb shell am start -a android.intent.action.VIEW -d "nav://www.geekholt.com/1"
- 从其他app跳转到当前app,或者在同一个应用内,但是要跳转的fragment不在当前Activity的导航图中
val intent = Intent()
intent.data = Uri.parse("nav://www.geekholt.com/1")
startActivity(intent)
- 要跳转的fragment在当前Activity的导航图中
val intent = Intent()
intent.data = Uri.parse("nav://www.geekholt.com/1")
findNavController().handleDeepLink(intent)
- 接收参数
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
...
val id = arguments?.getString("id")
return view;
}
使用BottomNavigationView的问题
如果你是根据上面的流程走下来跟着做的话,到这里我相信你已经对Navigation
的作用及用法有一定的了解了。但是实际上上面这个模板在大多数的商业级项目开发中存在一定问题的。什么问题呢?
上面是通过BottomNavigationView
对主页的tab进行切换的,通过打印Fragment的生命周期我们会发现,每次切换到一个新的Fragment,原来的Fragment就会执行onDestory、onDetach方法,如果再次回到原来的Fragment,也就被重建了,如果有一定开发经验的同学应该能意识到,这种情况在实际项目开发中显然是不太能接受的,比如列表的滑动位置等状态就丢失了。
问题分析:
我们先来看在MainActivity中是如何使用BottomNavigationView
的,关键方法是setupWithNavController
,这个方法将BottomNavigationView
与导航器NavController
关联起来
//MainActivity.kt
val navView: BottomNavigationView = findViewById(R.id.nav_view)
val navController = findNavController(R.id.nav_host_fragment)
....
navView.setupWithNavController(navController)
//BottomNavigationView.kt
fun BottomNavigationView.setupWithNavController(navController: NavController) {
NavigationUI.setupWithNavController(this, navController)
}
setOnNavigationItemSelectedListener
方法就是监听用户点击底部Tab的回调,我们可以看到最终就是调用navController.navigate
来进行页面跳转的
//NavigationUI.java
public static void setupWithNavController(
@NonNull final BottomNavigationView bottomNavigationView,
@NonNull final NavController navController) {
bottomNavigationView.setOnNavigationItemSelectedListener(
new BottomNavigationView.OnNavigationItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(@NonNull MenuItem item) {
//tab切换
return onNavDestinationSelected(item, navController);
}
});
....
}
public static boolean onNavDestinationSelected(@NonNull MenuItem item,
@NonNull NavController navController) {
NavOptions.Builder builder = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim);
if ((item.getOrder() & Menu.CATEGORY_SECONDARY) == 0) {
//清空回退栈
builder.setPopUpTo(findStartDestination(navController.getGraph()).getId(), false);
}
NavOptions options = builder.build();
try {
//Fragment跳转
navController.navigate(item.getItemId(), null, options);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
但是真正导致Fragment被销毁的实际上不是navigate
方法,而是setPopUpTo
操作
Builder setPopUpTo(@IdRes int destinationId, boolean inclusive)
设置inclusive=true的时候,会清空回退栈,并回到回退栈中第一个位置
设置inclusive=false
的时候,会清空回退栈,且不会回到回退栈中的第一个位置
这里就是通过设置inclusive=false
将回退栈全部清空,然后跳转到了一个新的Fragment,所以回退栈中的Fragment显然就被销毁了
所以其实对于主页的tab切换来说,还是更推荐使用ViewPager+Fragment
的方式来实现,Android官方提供的一个Jetpack项目中其实就是这么做的,非常推荐看一下这个项目https://github.com/android/sunflower
Navigation replace机制问题
Navigation
还有一个经常被大家“诟病”的就是Fragment
跳转的replace
机制问题,什么意思呢?FragmentNavigator
的navigate
方法内部是直接调用FragmentManager.replace()
方法替换原来的Fragment
,导致每次回到上一个页面都重走onCreateView方法。
注意,这个和我们上面说的BottomNavigationView
的问题其实是不一样的。使用navigate
跳转到新的页面,原来的页面只会只会执行onDestroyView
,而不会执行onDestory
。所以回到原来的页面后,也只是重新执行onCreateView
方法,而不会重走onCreate
方法进行完全的重建。这块其实是Fragment
相关的知识,在本节中不会详细介绍。如果不清楚原因的,这里给出一些关键词,可以到网上了解一下FragmentTransaction
的replace
以及addToBackStack
的作用
//FragmentNavigator.java
public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
@Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
...
//根据classname反射获取Fragmnent
final Fragment frag = instantiateFragment(mContext, mFragmentManager,
className, args);
frag.setArguments(args);
//获取Fragment事务
final FragmentTransaction ft = mFragmentManager.beginTransaction();
//切换动画设置
int enterAnim = navOptions != null ? navOptions.getEnterAnim() : -1;
int exitAnim = navOptions != null ? navOptions.getExitAnim() : -1;
int popEnterAnim = navOptions != null ? navOptions.getPopEnterAnim() : -1;
int popExitAnim = navOptions != null ? navOptions.getPopExitAnim() : -1;
if (enterAnim != -1 || exitAnim != -1 || popEnterAnim != -1 || popExitAnim != -1) {
enterAnim = enterAnim != -1 ? enterAnim : 0;
exitAnim = exitAnim != -1 ? exitAnim : 0;
popEnterAnim = popEnterAnim != -1 ? popEnterAnim : 0;
popExitAnim = popExitAnim != -1 ? popExitAnim : 0;
ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim);
}
//切换Fragment
ft.replace(mContainerId, frag);
ft.setPrimaryNavigationFragment(frag);
......
ft.addToBackStack(generateBackStackName(mBackStack.size(), destId));
........
}
那么官方为什么要设计成replace
呢?用hide
和show
不好吗?谷歌的想法应该是结合ViewModel
使用,View
所对应的ViewModel
还在,数据并不需要重新加载或者请求,然后再通过数据重新渲染出View
。这样做到了数据和视图的分离,也减少了页面的视图层级。ViewModel
的生命周期是跟着Fragment
走的,只有Frgment
onDestory
了ViewModel
才会被销毁
这里再一次推荐吐槽replace
机制的同学们去看一下官方提供的项目https://github.com/android/sunflower
关于ViewModel
相关的内容,我也会在后续章节中进行介绍