Navigation实践总结

    科技2022-08-11  108

     

    背景

    Navigation作为Jetpack四大组件之一,可以为单Activity多Fragment结构提供重要支持。我们知道Activity是属于比较重的组件,而Fragment是比较轻量化的,因此这种结构对界面性能方面有很大影响

    但是每种框架都有他的优缺点,Navigation对外宣传时的确优点很多,比如fragment各种状态管理,参数类型限制,快速切换fragment等,当然这种也是吸引我们尝试这种框架的原因,不过有些特点只有在使用过程中才会发现,这也是为什么我们经常要做预研的原因,只有充分了解框架特性,我们才能决定适不适合引入以及怎么引入,所以这边初步总结一下使用过程中遇到的Navigation特性来作为大家是否引入的一个参考

     

    Navigation使用

    1、我们先来设计一个使用Navigation的结构图,如下,麻雀虽小五脏俱全,Navigation作为页面入口,充分利用它的特性来作为页面切换工具:

     

    2、接下来开始进入具体代码阶段,先提出一个问题:Navigation作为fragment切换管理中心,那它实现原理是什么呢?抱着这个问题先来看一下它的使用过程:

    1)和Gilde一样,一句话代码实现功能,如下:

    NavHostFragment.findNavController(this@PanelHomeFragment).navigate(R.id.action_panelHomeFragment_to_creatorFilterFragment, generateArguments())

    2)接着深入navigate函数:

    //...此处省略一大堆代码,直接进入主题 Navigator<NavDestination> navigator = mNavigatorProvider.getNavigator(         node.getNavigatorName()); Bundle finalArgs = node.addInDefaultArgs(args); NavDestination newDest = navigator.navigate(node, finalArgs,         navOptions, navigatorExtras);

    上面是Navigation跳转的核心代码,省略代码大多是它对自身栈的维护逻辑,这边就不详细介绍了。可以看到上面代码通过mNavigatorProvider来获取一个Navigator对象,然后通过它来实现跳转,这个方法传了一个node.getNavigatorName()参数,这个参数哪来的呢,要解释这个我们先来看一下Navigation跳转定义代码,如下:

    <fragment     android:id="@+id/filterFragment"     android:name="com.test.fragment.FilterFragment"     android:label="FilterFragment" />

    上面就是答案了,<fragment/>标签名就是这个参数了,所以最终我们拿到的Navigator对象就是FragmentNavigator

    3)焦点转到FragmentNavigator来看看:

    @Navigator.Name("fragment") public class FragmentNavigator extends Navigator<FragmentNavigator.Destination> {     private static final String TAG = "FragmentNavigator";     private static final String KEY_BACK_STACK_IDS = "androidx-nav-fragment:navigator:backStackIds"; @Nullable @Override public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,@Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {     if (mFragmentManager.isStateSaved()) {         Log.i(TAG, "Ignoring navigate() call: FragmentManager has already"                 + " saved its state");         return null;     }     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();     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);     }     ft.replace(mContainerId, frag);     ft.setPrimaryNavigationFragment(frag);     final @IdRes int destId = destination.getId();     final boolean initialNavigation = mBackStack.isEmpty();     // TODO Build first class singleTop behavior for fragments     final boolean isSingleTopReplacement = navOptions != null && !initialNavigation             && navOptions.shouldLaunchSingleTop()             && mBackStack.peekLast() == destId;     boolean isAdded;     if (initialNavigation) {         isAdded = true;     } else if (isSingleTopReplacement) {         // Single Top means we only want one instance on the back stack         if (mBackStack.size() > 1) {             // If the Fragment to be replaced is on the FragmentManager's             // back stack, a simple replace() isn't enough so we             // remove it from the back stack and put our replacement             // on the back stack in its place             mFragmentManager.popBackStack(                   generateBackStackName(mBackStack.size(), mBackStack.peekLast()),                     FragmentManager.POP_BACK_STACK_INCLUSIVE);             ft.addToBackStack(generateBackStackName(mBackStack.size(), destId));         }         isAdded = false;     } else {         ft.addToBackStack(generateBackStackName(mBackStack.size() + 1, destId));         isAdded = true;     }     if (navigatorExtras instanceof Extras) {         Extras extras = (Extras) navigatorExtras;         for (Map.Entry<View, String> sharedElement : extras.getSharedElements().entrySet()) {             ft.addSharedElement(sharedElement.getKey(), sharedElement.getValue());         }     }     ft.setReorderingAllowed(true);     ft.commit();     // The commit succeeded, update our view of the world     if (isAdded) {         mBackStack.add(destId);         return destination;     } else {         return null;     }   } }

    先提示一下上一步说的node.getNavigatorName为什么对应这个对象呢,注意看一下@Navigator.Name("fragment")这个注解,这个就是答案,Navigation通过这个值来注册FragmentNavigator,所以根据fragment标签获取到的Navigator就是FragmentNavigator,这里其实还包含了一个自定义功能,后面再介绍

    重点看一下navigate函数吧,可以看到里面就是我们平时用到的fragment切换代码,只不过这边比较特别,是用的getChildFragmentManager()加replace方式替换fragment,如果对这两个特性了解的同学应该知道这意味着:

    每次fragment切换都是销毁前一个对象的,所以不管是回退还是跳转下一个,fragment都会重新执行oncreateView等生命周期流程因为基于getChildFragmentManager(),所以Navigation里面的fragment都是属于NavHostFragment的子fragment,这个特性后面也会用到

     

    3、既然我们初步了解了Navigation跳转逻辑,就来看一下使用过程中首先需要考虑的几个点以及解决办法吧:

    问题:

    每次切换fragment都会重新创建,对于新项目或者新功能而言是可以接受的,但是如果要混合一些旧代码你就要三思了,因为销毁fragment同时意味着数据全部消失,而旧代码基本是不会缓存数据的,如果盲目使用只会中途左右为难

    解决方案:

    a) 坚决拥护Navigation设计初衷,即UI与数据分离,利用viewModel作为数据缓存工具,配合Navigation使用,这样可以解决数据丢失问题。如果不想回退的时候也重绘UI,那么根据google官方推荐可以保存view对象,进行判重:

     

    b) 当然如果我非要用Navigation,同时又不想每次重新创建呢,答案当然也是可以的,不过这种方式个人感觉有点背离Navigation设计初衷,多个fragment同时存在对于内存是挺大压力的,特别像主页多面板这种还容易造成OOM,另外每次刷新UI对用户体验其实影响不大,最耗时的其实是数据获取,只要我们缓存了数据就不会有太大问题。同时刷新UI也会及时刷新数据,这方面来看反而对体验有帮助。

    说了一堆劝阻大家不要改动Naviagtion的理由,最后还是得介绍一下怎样改动它来实现多个fragment共存。首先fragment不能共存的原因是因为使用了replace方式,那换成add不就行了(^_^),既然有思路那就简单了,那怎么换呢,又不能改它的代码。

    别急,答案其实在上面逐步透漏了,还记得我们跳转为什么是进来FragmentNavigator而不是其他对象吗,因为Navigation只注册了一个FragmentNavigator给我们用,既然我们要改跳转逻辑,那就自己注册一个不就行了,说干就干,怎么注册呢?

    首先定义一个和FragmentNavigator对象类似的(把它代码全拷过来,改个名字),然后把@Navigator.Name("fragment")这个换成我们自定义标签,比如state_fragment定义好了怎么告诉Navigation呢,先来看一下FragmentNavigator怎么添加的吧: @CallSuper protected void onCreateNavController(@NonNull NavController navController) {     navController.getNavigatorProvider().addNavigator(             new DialogFragmentNavigator(requireContext(), getChildFragmentManager()));     navController.getNavigatorProvider().addNavigator(createFragmentNavigator()); }

    很简单的几行代码,那我们可以这样添加吗?当然是可以的,因为我们外面是可以拿到navController的,自然也能像它一样操作了

    经过上面两步,基本差不多了,接下来怎么用呢,很简单,在navigation.xml把所有<fragment/>标签换成<state_fragment/>就行了,如下: <state_fragment     android:id="@+id/filterFragment"     android:name="com.test.fragment.FilterFragment"     android:label="FilterFragment" />

    上面就是如何自定义跳转的主要过程了,虽然提供了方法,但是个人还是建议保持架构原本特性,毕竟google也是选了最适合的方式来作为默认方式,改造了之后后面升级维护也是一笔开销。

     

    4、经过上面的介绍,大家是不是发现了预研的重要性,如果在不了解Navigation特性的前提下直接在项目中使用它,可能会经历以下几个心路历程:

    刚开始测就发现数据没了,心里无比懊恼,心想这是什么坑人的框架呀,都想放弃了。踩了第一个坑后,有了沉默成本,这时候放弃不值得呀,继续咬牙干吧,但是与旧有代码怎么兼容呢,心态又崩了沉默成本变多,更加不能放弃了,咬咬牙改造旧代码吧,改着改着发现改出更多问题了,心态炸裂,早知道就早点放弃了

    反过来,如果提前知道Navigation这些特性,就能在架构设计时就做出正确决定,或者在兼容旧代码时自定义不销毁的fragment就行了,能少走很多弯路

     

    ViewModel数据缓存使用

    经过上面对Navigation的初步实践总结,我们发现在考虑使用Navigation的同时要考虑数据缓存的接入,两者是相辅相成的,缺一不可,而目前比较推荐的数据缓存框架就是ViewModel了,但是这个使用起来也是有一些坑的,需要提前了解才行!

    1、viewModel使用

    按照我们对viewModel基本了解,viewModel是连接UI与数据的桥梁,因此需要定义一个LiveData来通知UI进行相关处理,如下:

    /**  * uiState作为内部交互通道  */ protected val _uiState = MutableLiveData<EventModel>() val uiState: LiveData<EventModel>     get() = _uiState protected val uiModel = EventModel("", "")

    然后fragment只要监听uiState就能收到消息了,想想感觉挺简单的呀,这样就结束了?那是不现实的,这样会遇到几个问题:

    uiState作为数据交互桥梁,传递数据给fragment这点没错,但是除了数据还有其他事件也要传递的,而viewModel是不保存中间状态的,也就是数据会被覆盖,那说好的缓存呢?既然一个uiState不够,再来一个不就行了,简单嘛,如下: /**  * dataMode主要作为缓存所有列表数据  */ private val dataMode = EventModel("", "") private val _dataState = MutableLiveData<EventModel>() val dataState: LiveData<EventModel>     get() = _dataState 好了,数据覆盖问题被解决了。真的解决了吗?viewModel生命周期是跟随谁这个有考虑过吗,activity or fragment?思前想后,只能跟activity了,不然fragemnt一销毁不是一无所有,何谈缓存数据。再问自己一下,如果activity是MainActivity,这样做好吗,虽然viewModel不会销毁了,但是什么时候释放呢,答案是基本上不会释放,除非退后台一段时间,要哭了(^_^)最后揭晓答案,还记得之前提到的Navigation切换fragment用的是childFragmentManager,了解的同学是不是恍然大悟了,原来所有的fragment的父fragment都是NavHostFragment,那就简单了,跟随它的生命周期不就行了,于是憋出了一行代码: override fun initVM(): StickerViewModel = ViewModelProvider(requireParentFragment())[StickerViewModel::class.java]

     

    2、终于解决完了viewmodel定义了,怎么感觉这行代码写的相当不容易(^_^),结束了吗?问这个时候就代表又遇到坑了。虽然数据缓存是解决了,但是事件通知就这么结束了吗?

    考虑一个问题:fragment每次都是重新创建的,那是不是每次都要重新注册监听?那注册的时候LiveData是不是很友好的把上一次的数据发给你了?

    是不是突然发现了,fragment退出重进是会收到上一次遗留消息的,如果不注意可能找bug要找半天,那说了这么多怎么解决呢?其实也不难,在基类fragment里面清除状态就行了,如下:

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {     mViewModel = initVM()     if (isNeedClearState()) {         mViewModel.clearUIState()     }     getPanelType()?.let {         PanelHostViewModel.get().register(it, mViewModel)     }     initView()     initData()     startObserve()     super.onViewCreated(view, savedInstanceState) }

    3、这回总该没问题了吧?又立flag了,Navigation还有一个坑,考虑一个问题:如果跳转fragment的时候传了一堆参数,这个参数比如是deeplink相关,即让它继续跳转。那你会发现一个现象,就是跳完后狂点返回键就是回不去,难道手机出问题了?

    那是不可能的,答案就是:

    Navigation虽然会销毁fragment,但是回退的fragment各种变量值还是在的(这也是能复用view的原因)当你收到arguments进行跳转到下一个fragment的时候,再点击返回键,这时候上一个fragment(收到参数的那个)又执行了初始化操作,发现arguments参数还在,那怎么办,继续跳转呗,这不又到了下一个fragment,所以无论你把返回键点爆了还是怎么的,它就是回不去了,有种你杀进程呀~那怎么办呢,也很简单,同样在基类fragment里面销毁时清空argument,附上完整基类代码: /**  * 需要用到ViewModel的Fragment继承这个类  */ abstract class BaseVMFragment<VM : BaseViewModel> : Fragment() {     protected var mContentView: View? = null     protected lateinit var mViewModel: VM     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {         if (mContentView == null) {             mContentView = inflater.inflate(getLayoutResId(), container, false)         }         return mContentView     }     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {         mViewModel = initVM()         if (isNeedClearState()) {             mViewModel.clearUIState()         }         getPanelType()?.let {             PanelHostViewModel.get().register(it, mViewModel)         }         initView()         initData()         startObserve()         super.onViewCreated(view, savedInstanceState)     }     override fun onDestroyView() {         //需要清除参数和uiState,否则下次从panelHome跳转过来viewModel会收到change_layer消息,从stickerFragment返回会重新应用jumpPanelType,导致异常         arguments?.clear()         super.onDestroyView()     }     open fun isNeedClearState(): Boolean {         return true     }     abstract fun getLayoutResId(): Int     abstract fun getPanelType(): PanelType?     abstract fun initVM(): VM     abstract fun initView()     abstract fun initData()     abstract fun startObserve() }

     

    总结

    从上面介绍可以看出,单单从Google官网或者网上一些介绍,我们只能了解到Navigation使用的一些基本要求以及优缺点,但是真要应用到项目里,特别是和旧代码混合使用就要慎重了,不然中途会左右为难,放弃吧,又投入了这么多时间精力。继续搞吧,又搞不下去了。

    因此这篇文章也是帮助大家避雷,毕竟基于足够的了解才能做出正确的决策,相信大家在看了上面介绍后,使用的时候会提前思考多种情况,如果都满足的话大概率能顺利融合Navigation,避免中途的尴尬处境!

     

     

     

    Processed: 0.016, SQL: 9