Fragivity:像使用Activity一样使用Fragment

4,494 阅读5分钟

" An app only needs an Activity, you can use Fragments, just don't use the backstack with fragments "
-- Jake Wharton @Droidcon NYC 2017

近年来,SPA,即单Activity架构逐渐开始受到欢迎,随之而生了很多优秀的三方库,大部分是基于Fragment作为实现方案,其中最有代表性的就是Fragmentation了,后来Jetpack Navigation的诞生也标志着Google从官方立场对SPA架构的肯定。

Navigation的出现并没有加速Fragment对Activity的全面取代,一个重要原因是其过于依赖配置(NavGraph),丧失了Activity的灵活性。这一点上Fragmentation做的不错,有接近Activity的使用体验,可惜其不支持Kotlin,且早已停止维护,无法使用近年来在AndroidX中引入的各种新特性。

是否有一个工具,既具备Fragmentation那样灵活性,又能像Navigation那样兼容AndroidX中的新功能呢? Fragivity正是在这个背景下诞生的:github.com/vitaviva/fr…

Fragivity : Use Fragment like Activity


顾名思义,Fragivity希望让Fragment具备Activity一样的使用体验,从而在各种场景中能真正取而代之:

  • 生命周期与Activity行为一致
  • 支持多种LaunchMode
  • 支持OnBackPressed事件处理、支持SwipeBack
  • 支持Transition、SharedElement等转场动画
  • 支持以Dialog样式显示
  • 支持Deep Links

Fragivity底层基于Navigation实现,同时兼具Fragmentation的灵活性,无需配置NavGraph即可实现画面跳转。简单对比一下三者的区别:

FragmentationNavigationFragivity
自由跳转yesno (依赖NavGraph)yes
Launch Mode3种2种3种
支持Deep Linksnoyes(依赖NavGraph)yes(使用注解)
kotlin友好noyesyes
生命周期与Activity不一致(add方式)与Activity不一致(replace方式)与Activity一致
Fragment间通信startFragmentForResultviewmodelviewmodel、callback、resultapi等多种方式
过场动画View AnimationTransition AnimationTransition Animation
Swipe Backyes(依赖基类)noyes (无需基类)
支持Dialog显示noyesyes
OnBackPressed拦截yes (依赖基类)yes(无需基类)yes(无需基类)

通过对比可以发现,比起前两者Fragivity在多个维度上与Activity的行为更加一致。


1. 基本使用


Fragivity的接入成本很低。

1.1 gradle依赖

implementation 'com.github.fragivity:core:$latest_version'

1.2 声明NavHostFragment

像Navigation一样,Fragivity需要NavHostFragment作为Parent,然后在ChildFragment之间实现页面跳转。

我们在xml中声明NavHostFragment

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:fitsSystemWindows="true">

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

1.3 加载首页

通常我们需要定义一个MainActivity作为入口,同样,这里通过loadRoot加载一个初始的Fragment:

class MainActivity : AppCompatActivity() {

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

        val navHostFragment = supportFragmentManager
            .findFragmentById(R.id.nav_host) as NavHostFragment

        navHostFragment.loadRoot(HomeFragment::class)

    }
}

1.4 页面跳转

接下来便可以在Fragment之间进行跳转了

//跳转到目标Fragment
navigator.push(DestinationFragment::class)

//携带参数跳转
val bundle = bundleOf(KEY_ARGUMENT to "some args")
navigator.push(DestinationFragment::class, bundle)

//可以通过尾lambda设置参数,好处是还可以进行其他配置
navigator.push(DestinationFragment::class) {
	arguments = bundle //设置参数
    launchMade = ... //更多其他设置
    
}

1.5 页面返回

通过pop方法可以返回上一页面

//返回上一页面
navigator.pop()

//返回到指定页面
navigator.popTo(HomeFramgent::class)

1.6 转场动画

基于Navigation的能力,在画面跳转时可以设置Transition动画

navigator.push(UserProfile::class, bundle) { //this:NavOptions
    //配置动画
    enterAnim = R.anim.enter_anim
    exitAnim = R.anim.exit_anim
    popEnterAnim = R.anim.enter_anim
    popExitAnim = R.anim.exit_anim
}

借助FragmentNavigatorExtras还可以设置SharedElement,实现更优雅地动画效果

//跳转时,对imageView设置SharedElement
navigator.push(UserProfile::class, bundle) { //this:NavOptions
                
    enterAnim = R.anim.enter_anim
    exitAnim = R.anim.exit_anim
    popEnterAnim = R.anim.enter_anim
    popExitAnim = R.anim.exit_anim
    
    //配置共享元素
    sharedElements = sharedElementsOf(imageView to "iv_id")
       
}
class UserProfile : Fragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //目标Fragment中设置共享元素动画
        sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move)
    }

}

2. 无需配置实现页面跳转


Navigation需要配置NavGraph才能实现页面间跳转,例如:

<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"
    app:startDestination="@+id/first">

    <fragment
        android:id="@+id/fragment_first"
        android:name=".FirstFagment"
        android:label="@string/tag_first">
        <action
            android:id="@+id/action_to_second"
            app:destination="@id/fragment_second"/>
    </fragment>
    <fragment
        android:id="@+id/fragment_second"
        android:name=".SecondFragment"
        android:label="@string/tag_second"/>
</navigation>

每个<navigation/>对应一个NavGraph对象,<fragment/>会对应到NavGraph中的各个DestinationNavController持有NavGraph通过控制Destination之间的跳转。

依赖配置的页面跳转,无法做到像Activity那样灵活。Fragivity通过动态构建NavGraph,无需配置即可实现跳转:

2.1 动态创建Graph

加载首页时,动态创建Graph

fun NavHostFragment.loadRoot(root: KClass<out Fragment>) {

    navController.apply {
        //添加Navigator
        navigatorProvider.addNavigator(
            FragivityNavigator(
                context,
                childFragmentManager,
                id
            )
        )
        
        //创建Graph
        graph = createGraph(startDestination = startDestId) {
            val startDestId = root.hashCode()
            //添加startDestination
            destination(
                FragmentNavigatorDestinationBuilder(
                    provider[FragivityNavigator::class],
                    startDestId,
                    root
                ))
        }
    }
}

FragivityNavigator负责处理页面跳转的逻辑,后文会单独介绍。Graph创建后添加startDestination用来加载首页。

2.2 动态添加Destination

除startDestination以外,每当跳转到新页面,都需要为Graph动态添加此Destination:

fun NavHost.push(
    clazz: KClass<out Fragment>,
    args: Bundle? = null,
    extras: Navigator.Extras? = null,
    optionsBuilder: NavOptions.() -> Unit = {}
) = with(navController) {
    // 动态创建Destination
    val node = putFragment(clazz)
    // 调用NavController的navigate方法进行跳转
    navigate(
        node.id, args,
        convertNavOptions(clazz, NavOptions().apply(optionsBuilder)),
        extras
    )
}

// 创建并添加Destination
private fun NavController.putFragment(clazz: KClass<out Fragment>): FragmentNavigator.Destination {
    val destId = clazz.hashCode()
    lateinit var destination: FragmentNavigator.Destination
    if (graph.findNode(destId) == null) {
        destination = (FragmentNavigatorDestinationBuilder(
            navigatorProvider[FragivityNavigator::class],
            destId,
            clazz
        )).build()
        graph.plusAssign(destination)// 添加进Graph
    } else {
        destination = graph.findNode(destId) as FragmentNavigator.Destination
    }
    return destination
}

创建Destination后,通过NavController的navigate方法跳转到此Destination。


3. BackStack及生命周期


如J神所说,Fragment无法很好替代Activity的原因之一是在BackStack管理上的差异,这会影响到生命周期的不同。

设想以下场景: A页面 > (启动)> B页面 >(返回)> A页面

我们知道添加Fragment一般有两种方式:addreplace。无论哪种方式其在画面跳转时的生命周期与Activity都不相同:

页面B的启动方式从B返回时的生命周期
ActivityActivityB:onPasue -> onStop -> onDestroy
ActivityA:onStart -> onResume
Fragment(add )FragmentB : onPause -> onStop -> onDestroy
FragmentA : no change
Fragment(replace)FragmentB: onPause -> onStop -> onDestroy
FragmentA: onCreateView -> onStart -> onResume

如果希望在画面跳转时Fragment的生命周期与Activity行为一致,则至少需要达成以下三个目标:

  • 目标1:回退时,FragmentB不重新onCreateView (add方式满足)
  • 目标2:回退时,FragmentB会触发onStart -> onResume (replace方式满足)
  • 目标3:后台的Fragment不跟随父生命周期发生变化 (replace方式满足)

无论Navigation还是Fragmentation都不能同时满足上面三条。

3.1 重写FragmentNavigator

NavController通过FragmentNavigator实现具体的跳转逻辑,FragmentNavigator是Navigator的派生类,专门负责FragmentNavigator.Destination类型的跳转。

navigate()实现了Fragment跳转的具体逻辑,其核心代码如下

@Navigator.Name("fragment")
public class FragmentNavigator extends Navigator<FragmentNavigator.Destination> {

    @Override
    public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
            @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {

        String className = destination.getClassName();
        
        //实例化Fragment
        final Fragment frag = instantiateFragment(mContext, mFragmentManager,
        className, args);
        frag.setArguments(args);
        
        final FragmentTransaction ft = mFragmentManager.beginTransaction();
        ft.replace(mContainerId, frag); // replace方式添加Fragment
        ft.setPrimaryNavigationFragment(frag);
  
        //事务压栈
        ft.addToBackStack(generateBackStackName(mBackStack.size() + 1, destId));
            
        ft.setReorderingAllowed(true);
        ft.commit();
        
    }

}

FragmentNavigator通过replace进行Fragment跳转,前面分析我们知道这在回退时会重新onCreateView,不符合预期。我们实现子类FragivityNavigator,重写navigate()方法,将replace改为add,避免重新onCreateView,达成“目标1”

public class FragivityNavigator extends FragmentNavigator {
    @Override
    public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
            @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {

        final Fragment frag = instantiateFragment(mContext, mFragmentManager,
        className, args);
        //ft.replace(mContainerId, frag); // replace改为add
        ft.add(mContainerId, frag, generateBackStackName(mBackStack.size(), destination.getId()));
        
    }
}

3.2 添加OnBackStackChangedListener

在合适的时机为FragmentManger添加OnBackStackChangedListener,当监听到backstack变化时,手动触发生命周期回调,达成“目标2”

private final FragmentManager.OnBackStackChangedListener mOnBackStackChangedListener =
        new FragmentManager.OnBackStackChangedListener() {

            @Override
            public void onBackStackChanged() {
                if (mIsPendingAddToBackStackOperation) {
                    mIsPendingAddToBackStackOperation = !isBackStackEqual();

                    if (mFragmentManager.getFragments().size() > 1) {
                        // 切到后台时的生命周期
                        Fragment fragment = mFragmentManager.getFragments().get(mFragmentManager.getFragments().size() - 2);
                        if (fragment instanceof ReportFragment) {
                            fragment.performPause();
                            fragment.performStop();
                            ((ReportFragment) fragment).setShow(false);
                        }
                    }
                } else if (mIsPendingPopBackStackOperation) {
                    mIsPendingPopBackStackOperation = !isBackStackEqual();
                    // 回到前台时的生命周期
                    Fragment fragment = mFragmentManager.getPrimaryNavigationFragment();
                    if (fragment instanceof ReportFragment) {
                        ((ReportFragment) fragment).setShow(true);
                        fragment.performStart();
                        fragment.performResume();
                    }
                }
            }
        };

3.3 ReportFragment代理

为了达成“目标3”, 在实例化Fragment时,为其创建ReportFragment作为代理。所谓代理其实是通过ParentFragment对内进行生命周期的分发和控制:

//ReportFragment
internal class ReportFragment : Fragment() {

    internal lateinit var className: String
    private val _real: Class<out Fragment> by lazy {
        Class.forName(className) as Class<out Fragment>
    }
    private val _realFragment by lazy {  _real.newInstance() }

    override fun onAttach(context: Context) {
        super.onAttach(context)
        //将目标Framgent作为child进行管理
        mChildFragmentManager.beginTransaction().apply {
            _realFragment.arguments = arguments
            add(R.id.container, _realFragment)
            commitNow()
        }
    }

}
//ReportFragmentManager
internal class ReportFragmentManager : FragmentManager() {
    //isShow:在后台时,不响应生命周期分发
    internal var isShow = true
    public override fun dispatchResume() {
        if (isShow) super.dispatchResume()
    }

    //...
}

4. 支持Launch Modes


Fragivity支持三种Launch Modes:StandardSingleTopSingleTask

启动方式非常简单:

navigator.push(LaunchModeFragment::class, bundle) { //this: NavOptions
    launchMode = LaunchMode.STANDARD // 默认可省略
    //or LaunchMode.SINGLE_TOP, LaunchMode.SINGLE_TASK
}

这里着重介绍一下SingleTop的实现。Navigation也支持SingleTop,但是在Navigator中完成的,由于我们重写了Navigator(replace改为add),因此对SingleTop的实现也要做相应调整:

@Override
public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args,
            @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) {
    
    final Fragment preFrag = mFragmentManager.getPrimaryNavigationFragment();
    
    //当以singleTop启动时
    if (isSingleTopReplacement) {
            if (mBackStack.size() > 1) {
                ft.remove(preFrag);// 删除旧实例
                
                //更新FragmentTransaction中的实例信息
                frag.mTag = generateBackStackName(mBackStack.size() - 1, destination.getId());
                if (mFragmentManager.mBackStack.size() > 0) {
                    List<FragmentTransaction.Op> ops =
                            mFragmentManager.mBackStack.get(mFragmentManager.mBackStack.size() - 1).mOps;
                    for (FragmentTransaction.Op op : ops) {
                        if (op.mCmd == OP_ADD && op.mFragment == preFrag) {
                            op.mFragment = frag;
                        }
                    }
                }
            }
        } 
}

S​ingleTop要求当栈顶类型和目标类型相同时只能存在一个实例,所以需要删除旧实例避免重复添加。同时为了保证BackStack回退时的事务行为正常,需要将添加旧实例的事务中的相关信息更新为新实例。


5. Fragment间通信


Fragivity支持androidx.fragment的所有通信方式,例如使用ViewModel,或者使用ResultApi(Fragment 版本高于1.3.0-beta02)等。除此之外,Fragivity提供了更简单的基于Callback的通信方式:

//SourceFragment
val cb = { it : Boolean -> 
    //...
}
navigator.push {
    DestinationFragment(cb)
}

//Destination
class DestinationFragment(val cb:(Boolean) -> Unit) {...}

以前Fragment如果必须使用无参的构造函数,否则打包时会出错。感谢AndroidX带来的进步,目前已经取消了此限制,允许自定义带参数的构造函数。因此我们可以通过lambda动态创建Fragment并将callback作为构造参数传入。

inline fun <reified T : Fragment> NavHost.push(
    noinline optionsBuilder: NavOptions.() -> Unit = {},
    noinline block: () -> T
) {
    //...
    push​(T::class, optionsBuilder)
}

如上,其内部仍然是使用Fragment的Class作为参数进行跳转,只是借助kotlin的reified特性,获取了泛型的Class信息而已。


6. 支持Deep Links


Activity可以通过URI隐式启动,为了覆盖此类使用场景,需要为Fragment提供Deep Links支持。Navigation在NavGraph中为Destination配置URI信息;Fragivity虽然没有NavGraph,但可以通过注解配置URI。基本思想类似于ARouter的路由原理:

  1. 在编译期通过kapt解析注解,获取URI信息,并与Fragment相关联
  2. 在Activity的入口处拦截Intent,解析URI并跳转到相关联的Fragment

6.1 添加kapt依赖

kapt 'com.github.fragivity:processor:$latest_version'

6.2 配置URI

定义Fragment时,使用@DeepLink配置URI

const val URI = "myapp://fragitiy.github.com/"

@DeepLink(uri = URI)
class DeepLinkFragment : AbsBaseFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_deep_link, container, false)
    }
}

6.3 处理Intent

在MainActivity入口处,处理Intent中的URI

//MainActivity#onCreate
override fun onCreate(savedInstanceState: Bundle?) {
     super.onCreate(savedInstanceState)
     setContentView(R.layout.activity_main)

     val navHostFragment = supportFragmentManager
            .findFragmentById(R.id.nav_host) as NavHostFragment

     navHostFragment.handleDeepLink(intent)

}

handleDeepLink内部最终会调用NavController的相关方法对URI进行解析:

//NavController
public void navigate(@NonNull Uri deepLink) {
    navigate(new NavDeepLinkRequest(deepLink, null, null));
}

之后,我们就可以从APP外部通过URI的方式跳转到目标Fragment了:

val intent = Intent(Intent.ACTION_VIEW, Uri.parse("myapp://fragitiy.github.com/"))
startActivity(intent)

7. OnBackPressed事件拦截


Fragment没有Activity的OnBackPressed方法,Fragmentation通过继承的方式增加了onBackPressedSupport方法,但这会引入新的基类,对业务代码的侵入性较高。

Fragivity基于androidx.activityOnBackPressedDispatcher,以更加无侵的方式拦截back键事件。OnBackPressedDispatcher通过责任链模式保证了back事件消费的顺序,同时感知Lifecycle,在适当的时机自动注销,避免泄露。 参考:developer.android.com/guide/navig…

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    requireActivity().onBackPressedDispatcher.addCallback( this,
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                // 拦截back键事件
            }
        })
}

back键返回与pop()返回

Fragivity提供pop方法,通过代码实现返回,其内部最终会调用Navigator#popBackStack。为了保证回退逻辑统一,我们希望back键回退时也由popBackStack统一处理。Navigation通过NavHostFragment进行了实现:

//NavHostFragment#onCreate
public void onCreate(@Nullable Bundle savedInstanceState) {
        /​/...
     mNavController = new NavHostController(context);
        mNavController.setOnBackPressedDispatcher(requireActivity().getOnBackPressedDispatcher());
        //...
        
}
//NavController#setOnBackPressedDispatcher
void setOnBackPressedDispatcher(@NonNull OnBackPressedDispatcher dispatcher) {
    if (mLifecycleOwner == null) {
        throw new IllegalStateException("You must call setLifecycleOwner() before calling "
                + "setOnBackPressedDispatcher()");
    }
    // Remove the callback from any previous dispatcher
    mOnBackPressedCallback.remove();
    // Then add it to the new dispatcher
    dispatcher.addCallback(mLifecycleOwner, mOnBackPressedCallback);
}
//NavController#mOnBackPressedCallback
private final OnBackPressedCallback mOnBackPressedCallback =
        new OnBackPressedCallback(false) {
    @Override
    public void handleOnBackPressed() {
        popBackStack(); // 最终回调Navigator#popBackStack
    }
};

8. SwipeBack


Navigation没有提供滑动返回的能力,我们从Fragmentation中找到解决方案:onCreateView的时候,将SwipeLayout作为Container容器

使用方式非常简单:

class SwipeBackFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_swipe_back, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        swipeBackLayout.setEnableGesture(true) //一句话开启SwipeBack
    }

}

借助ReportFragment代理,避免了额外基类的引入。swipeBackLayout是扩展属性,实际获取的是parentFragment(ReportFragment)的实例

val Fragment.swipeBackLayout
    get() = (parentFragment as ReportFragment).swipeBackLayout

ReportFragment中的处理非常简单,将SwipeLayout作为Container即可

internal class ReportFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        swipeBackLayout =
            SwipeBackLayout(requireContext()).apply {
                attachToFragment(
                    this@ReportFragment,
                    inflater.inflate(R.layout.report_layout, container, false)
                        .apply { appendBackground() } // add a default background color to make it opaque

                )
                setEnableGesture(false) //default false
            }
        return swipeBackLayout
    }

为了避免滑动过程中的背景穿透,调用applyBackgroud()为Fragment添加与当前主题相同的默认背景色

private fun View.appendBackground() {
    val a: TypedArray =
        requireActivity().theme.obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground))
    val background = a.getResourceId(0, 0)
    a.recycle()
    setBackgroundResource(background)
}

9. Show Dialog


Activity通过设置Theme可以以Dialog样式启动,使用DialogFragment同样可以实现Dialog样式的Fragment。Navigation对DialogFragment已经做了支持,Fragivity只要调用相关方法即可:

9.1 定义DialogFragment

class DialogFragment : DialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_dialog, container, false)
    }
}

9.2 显示Dialog

navigator.showDialog(DialogFragment::class)

DialogFramgent也需要在Graph上动态添加Destination,只是与普通的Fragment有所区别,其配套的Navigator类型是DialogFragmentNavigator

//创建Destination
val destination = DialogFragmentNavigatorDestinationBuilder(
       navigatorProvider[DialogFragmentNavigator::class],
       destId, clazz ).apply {
            label = clazz.qualifiedName
       }.build()

//添加到Graph      
graph.plusAssign(destination)

最后


Fragivity在核心逻辑上力求最大程度复用Navigation的能力,并保持与最新版本同步,这有利于保证框架的先进性和稳定性。同时Fragivity致力于打造与Activity相近的使用体验,以帮助开发者更低成本地转向单Activity架构。

工程源码中有本文介绍的各种API的demo,欢迎大家下载体验,提issue,觉得好用别忘了start github.com/vitaviva/fr…