小松的技术博客

六和敬

若今生迷局深陷,射影含沙。便许你来世袖手天下,一幕繁华。 你可愿转身落座,掌间朱砂,共我温酒煮茶。

谈谈 Android 的手势返回

前言

手势返回对用户而言是一个很便捷的操作,苹果原生支持,而 Android 到如今都没有考虑过这件事,所以只能有 App 开发者自己来完成,不过这也给了开发者创造的空间。最近在繁忙的业务开发之余,将 QMUIDemo 中的 fragment 管理基础类提取出来作为一个新的库,然后添加了手势返回的功能,目前已经完成最初版本,有兴趣的可以试试,在 build.gragle 中引入:

implementation "com.qmuiteam:arch:0.0.1"  

然后使用 QMUIFragmentActivityQMUIFragment 来作为 base 类搭建 UI,怎么使用可以参考 QMUI_Android 项目,本文会介绍其实现原理和几个控制接口。

Activity 的手势返回

目前开源的手势返回实现基本上都是针对 Activity 的,例如经典的实现:SwipeBackLayout, 之所以经典,是因为之后的实现基本上都使用的它提供的 View(SwipeBackLayout)。实现 Activity 手势返回的原理也很简单,就是在拖拽开始时把 Activity 改为透明的,这样就可以看到背后的 Activity 了,然而系统并没有提供接口来将 Activity 改为透明的,所以只能通过反射的方式来实现。当然,将 Activity 改为透明的,是有性能消耗的,并且可能引发其它坑点,所以也有其它方案的,例如 and_swipeback。对于 SwipeBackLayout 的使用和如何利用反射将 Activity 改为透明,这里推荐一篇博文 Android 平台滑动返回库对比

单 Activity 多 Fragment 的手势返回。

个人推崇单 Activity 多 Fragment 的 UI 架构:轻量级,更灵活,不用每次添加新界面就去改 AndroidManifest,等等。

目前业界也有针对 Fragment 的手势返回实现,不过前提是 Fragment 一个一个的 add 到 视图上的,这里其实不是很优雅,如果你的导航很深,那么你的视图就会同时存在很多Fragment, 应该会越来越容易出现卡顿的情况。QMUIFragment 采用 replace 的方式,这样视图上就会只存在一个Fragment,保证性能,可以看一下 QMUIFragmentActivity.startFragment 方法:

public void startFragment(QMUIFragment fragment) {  
    QMUIFragment.TransitionConfig transitionConfig = fragment.onFetchTransitionConfig();
    String tagName = fragment.getClass().getSimpleName();
    getSupportFragmentManager()
            .beginTransaction()
            .setCustomAnimations(transitionConfig.enter, transitionConfig.exit, transitionConfig.popenter, transitionConfig.popout)
            .replace(getContextViewId(), fragment, tagName)
            .addToBackStack(tagName)
            .commit();
}

采用 replace 方法实现 Fragment 的跳转,带来的代价就是手势返回非常不好实现。如果不清楚 FragmentManager 和 BackStackRecord 的运作机制,基本上很难实现这个功能。这也是我迟迟才添加上这个功能的原因,前期花费了大量的时间去理顺 FragmentManager 的实现逻辑。

首先我们要知道 addToBackStack 具体是做的什么,可能从字面意思上理解,是将 Fragment 添加到 BackStack 里。 其实不是的,其添加的是操作过程(Op)。比如说 replace 操作, 它是两个操作:一个 remove 和 一个 add,那么 BackStackRcord 就会记录这两个操作, 在 popBackStack 时根据所记录的操作执行逆向的操作。 所以实现手势返回的一个关键点就可以确定下来, 修改 BackStackRcord 里记录的操作。

先来看看手势返回触发的操作:

public void onEdgeTouch(int edgeFlag) {  
    Log.i(TAG, "SwipeListener:onEdgeTouch: edgeFlag = " + edgeFlag);
    FragmentManager fragmentManager = getFragmentManager();
    if (fragmentManager == null) {
        return;
    }
    int backstackCount = fragmentManager.getBackStackEntryCount();
    // 如果 backstackCount > 1, 则手势返回后依然是Fragment
    if (backstackCount > 1) {
        try {
            // 后去最后一个 BackStackRcord, BackStackRcord 是 BackStackEntry 的唯一实现类
            FragmentManager.BackStackEntry backStackEntry = fragmentManager.getBackStackEntryAt(backstackCount - 1);
            // 通过反射获取此次操作记录: 一般是两个:remove 前一个fragment 和 add 后一个操作
            Field opsField = backStackEntry.getClass().getDeclaredField("mOps");
            opsField.setAccessible(true);
            Object opsObj = opsField.get(backStackEntry);
            if (opsObj instanceof List<?>) {
                List<?> ops = (List<?>) opsObj;
                for (Object op : ops) {
                    // 遍历所有操作,通过 cmd 确定操作类型
                    Field cmdField = op.getClass().getDeclaredField("cmd");
                    cmdField.setAccessible(true);
                    int cmd = (int) cmdField.get(op);
                    if (cmd == 3) {
                        // 如果 cmd == 3, 则是 remove 操作,那么将其进入动画置为0.这样手势返回就不会触发前一个 fragment 的进入动画了。
                        Field popEnterAnimField = op.getClass().getDeclaredField("popEnterAnim");
                        popEnterAnimField.setAccessible(true);
                        popEnterAnimField.set(op, 0);
                        // 通过反射 fragment 字段可以获取之前被 remove 的 fragment, 也就是前一个 fragment
                        Field fragmentField = op.getClass().getDeclaredField("fragment");
                        fragmentField.setAccessible(true);
                        Object fragmentObject = fragmentField.get(op);
                        if (fragmentObject instanceof QMUIFragment) {
                            QMUIFragment fragment = (QMUIFragment) fragmentObject;
                            // 将前一个 fragment 管理的 View 添加到视图最下层,因此手势返回时就可以看到背后的 view
                            ViewGroup container = getBaseFragmentActivity().getFragmentContainer();
                            // 触发前一个 fragment 的 onCreateView(3参数),得到 fragment 所管理的 view。
                            fragment.isCreateForSwipeBack = true;
                            View baseView = fragment.onCreateView(LayoutInflater.from(getContext()), container, null);
                            fragment.isCreateForSwipeBack = false;
                            if (baseView != null) {
                                // 添加 tag, 标示是手势返回过程中用到的 View
                                baseView.setTag(R.id.qmui_arch_swipe_layout_in_back, SWIPE_BACK_VIEW);
                                // 将它添加到视图最下层
                                container.addView(baseView, 0);
                                // 模仿微信的手势返回,提供一个init offset,可实现视差滚动
                                int offset = Math.abs(backViewInitOffset());
                                if (edgeFlag == EDGE_BOTTOM) {
                                    ViewCompat.offsetTopAndBottom(baseView, offset);
                                } else if (edgeFlag == EDGE_RIGHT) {
                                    ViewCompat.offsetLeftAndRight(baseView, offset);
                                } else {
                                    ViewCompat.offsetLeftAndRight(baseView, -1 * offset);
                                }
                            }
                        }
                    }
                }
            }


        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    } else {
        // 如果已经是第一个 fragment, 那么就就回归到 Activity 的手势返回,将其 Activity 改为透明的
        if (getActivity() != null) {
            getActivity().getWindow().getDecorView().setBackgroundColor(0);
            Utils.convertActivityToTranslucent(getActivity());
        }
    }
}

主要的核心就是去掉前一个 fragment 的进入动画,将其管理的 view 添加到视图下层。为了模仿微信的视差效果,我也提供了一个方法 backInitOffset(), 子类重写,可以得到完美模仿视差滚动,当然如果 activity, 就没有支持到了。

在拖拽过程中,基本上就是更新背后 view 的位置,没有太多的内容。然后就是拖拽完成。 分为两种情况,一种是放弃返回,一种是执行返回。如果放弃返回,则删除背后的View,如果执行返回,则需要将当前 fragment 的退出动画置为0,然后执行 popbackstack。 具体代码为:

public void onScrollStateChange(int state, float scrollPercent) {  
    ViewGroup container = getBaseFragmentActivity().getFragmentContainer();
    int childCount = container.getChildCount();
    if (state == SwipeBackLayout.STATE_IDLE) {
        if (scrollPercent <= 0.0F) {
            // 放弃反回,根据 tag 移除 view
            for (int i = childCount - 1; i >= 0; i--) {
                View view = container.getChildAt(i);
                Object tag = view.getTag(R.id.qmui_arch_swipe_layout_in_back);
                if (tag != null && SWIPE_BACK_VIEW.equals(tag)) {
                    container.removeView(view);
                }
            }
        } else if (scrollPercent >= 1.0F) {
           // 执行返回, 已经要根据 tag 移除 view, 还原正常的返回流程
            for (int i = childCount - 1; i >= 0; i--) {
                View view = container.getChildAt(i);
                Object tag = view.getTag(R.id.qmui_arch_swipe_layout_in_back);
                if (tag != null && SWIPE_BACK_VIEW.equals(tag)) {
                    container.removeView(view);
                }
            }
            FragmentManager fragmentManager = getFragmentManager();
            if (fragmentManager == null) {
                return;
            }
            int backstackCount = fragmentManager.getBackStackEntryCount();
            if (backstackCount > 0) {
                try {
                    FragmentManager.BackStackEntry backStackEntry = fragmentManager.getBackStackEntryAt(backstackCount - 1);

                    Field opsField = backStackEntry.getClass().getDeclaredField("mOps");
                    opsField.setAccessible(true);
                    Object opsObj = opsField.get(backStackEntry);
                    if (opsObj instanceof List<?>) {
                        List<?> ops = (List<?>) opsObj;
                        for (Object op : ops) {
                            Field cmdField = op.getClass().getDeclaredField("cmd");
                            cmdField.setAccessible(true);
                            int cmd = (int) cmdField.get(op);
                            if (cmd == 1) {
                                // 如果 cmd == 1, 则说明之前的操作是 add, 也就是添加当前 fragment 的操作, 我们需要去除其 remove 动画
                                Field popEnterAnimField = op.getClass().getDeclaredField("popExitAnim");
                                popEnterAnimField.setAccessible(true);
                                popEnterAnimField.set(op, 0);
                            }
                        }
                    }
                } catch (NoSuchFieldException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
            popBackStack();
        }
    }
}

这样整个手势返回的流程就通了。还有存在一个问题。 前一个 fragment 的 onCreateView(3参数)会执行多次。 手势返回会触发一次,popBackStack又会触发一次,所以我们需要对 Fragment 创建的 View 做 cache。但这里并不能简简单单的用一个成员变量保存它。 需要考虑一下几种情况:

  1. View 正在动画过程中,有些时候,我们会进入一个界面,然后在动画还没结束时就快速返回,这样会触发 View 的移除动画还没结束就添加动画,这里的问题具体可看 这里

  2. android support 包升级到 27 以后, FragmentManager 支持了 transition。 不过 transition 和动画同时使用,又会掉进 view 不能成功移除的坑, 我给 google 提了个 bug单,期待官方可以处理下。

针对这两点,我的做法是:

  1. 通过反射 fragment.getAnimatingAway(),判断是否是在动画过程中,如果是,则抛弃重新创建View, 后期看看能不能寻找到更好的方式
  2. 如果掉进 view 不能成功移除的坑,会有一个现象:view.getParent != null && view.getParent.indexOfChild(view) == -1。 因此。如果满足这种条件,那就通过反射强制将 mParent 置为 null。 具体代码:
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {  
    SwipeBackLayout swipeBackLayout;
    if (mCacheView == null) {
        swipeBackLayout = newSwipeBackLayout();
        mCacheView = swipeBackLayout;
    } else if (isCreateForSwipeBack) {
        // in swipe back, must not in animation
        swipeBackLayout = mCacheView;
    } else {
        boolean isInRemoving = false;
        try {
            Method method = Fragment.class.getDeclaredMethod("getAnimatingAway");
            method.setAccessible(true);
            Object object = method.invoke(this);
            if (object != null) {
                isInRemoving = true;
            }
        } catch (NoSuchMethodException e) {
            isInRemoving = true;
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            isInRemoving = true;
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            isInRemoving = true;
            e.printStackTrace();
        }
        if (isInRemoving) {
            swipeBackLayout = newSwipeBackLayout();
            mCacheView = swipeBackLayout;
        } else {
            swipeBackLayout = mCacheView;
        }
    }


    if (!isCreateForSwipeBack) {
        mBaseView = swipeBackLayout.getContentView();
        swipeBackLayout.setTag(R.id.qmui_arch_swipe_layout_in_back, null);
    }

    ViewCompat.setTranslationZ(swipeBackLayout, mBackStackIndex);

    swipeBackLayout.setFitsSystemWindows(false);

    if (getActivity() != null) {
        QMUIViewHelper.requestApplyInsets(getActivity().getWindow());
    }

    if (swipeBackLayout.getParent() != null) {
        ViewGroup viewGroup = (ViewGroup) swipeBackLayout.getParent();
        if (viewGroup.indexOfChild(swipeBackLayout) > -1) {
            viewGroup.removeView(swipeBackLayout);
        } else {
            // see https://issuetracker.google.com/issues/71879409
            try {
                Field parentField = View.class.getDeclaredField("mParent");
                parentField.setAccessible(true);
                parentField.set(swipeBackLayout, null);
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }
    }

    return swipeBackLayout;
}

最后 QMUIFragment 提供 canDragBack, 控制当前 fragment 能否手势返回。

目前这个方案个人能想到的最好版本。后期可能会通过精读源码,有跟多的改进。目前这个方案主要还是存在一个不足: 大量的运用反射,如果 support 包更新,改动了某些字段,可能会造成手势返回不能正常工作。

←微信← →支付宝 →