当前位置: 移动技术网 > 移动技术>移动开发>Android > 讲一个 Android 嵌套滑动踩坑的真实经历

讲一个 Android 嵌套滑动踩坑的真实经历

2020年07月08日  | 移动技术网移动技术  | 我要评论
/ 今日科技快讯 /昨日,易车网发布一份公告,声明公司董事会已收到一份初步的非约束性私有化提议。这份私有化收购要约来自由腾讯及Hammer Capital(黑马资...


640?wx_fmt=jpeg


/   今日科技快讯   /


昨日,易车网发布一份公告,声明公司董事会已收到一份初步的非约束性私有化提议。这份私有化收购要约来自由腾讯及Hammer Capital(黑马资本)组成的买方团体,提议以每股ADS(美国存托股票)16美元的现金价格收购尚未持有的所有股份。其中,腾讯目前拥有易车网约7.81%的股份,Hammer Capital未持有任何股份。声明发出后,截至9月13日美东时间收盘,易车网股价上涨8.73%,报每股14.95美元。


/   作者简介   /


愉快的中秋假期结束啦,新的一周重新开始,很高兴又跟大家见面!


本篇文章来自琼珶和予的投稿,分享了他踩坑嵌套滑动的经历,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。


琼珶和予的博客地址:

https://juejin.im/user/5d5b50f66fb9a06b155dbfb7


/   前言   /


本来认为自己对嵌套滑动的理解和应用还是不错的,但是最近做了一个跟手动画的需求,使用嵌套滑动发现了这里有了很多的坑,本文来根据自身的踩坑经历和经验来总结使用嵌套滑动的注意项。


本文不会介绍嵌套滑动的基本使用,不了解的同学可以参考我的文章:Android 源码分析 - 嵌套滑动机制的实现原理。


Android 源码分析 - 嵌套滑动机制的实现原理

https://www.jianshu.com/p/cb3779d36118


同时,本文嵌套滑动皆以RecyclerView为例。


/   正文   /


1. 不要在onInterceptTouchEvent方法里面拦截事件


如果你有一个ViewGroup作为RecyclerView的父布局,这个ViewGroup主要来处理一些嵌套滑动的逻辑,比如说使用系统的SwipeRefreshLayout来做下拉刷新。如果这个ViewGroup不可能有父布局处理嵌套滑动,那么是否重写onInterceptTouchEvent可以自身需求来定,比如说SwipeRefreshLayout就重写了。


但是如果你的业务场景可能还会有ViewGroup来处理嵌套滑动,作为关系链中间的View千万不要重写onInterceptTouchEvent。


可能有对此有疑惑,现在我以一个具体的场景来解释具体的原因,假设有如下一个场景:


640?wx_fmt=png


整个事件传递的流程是:首先由RecyclerView产生嵌套滑动的事件,然后提交给SwipeRefreshLayout尝试着处理, SwipeRefreshLayout收到事件之后,发现还有父View可能会处理,然后在提交给ViewGroup,ViewGroup根据自身条件选择消费一定的距离,然后又返回给SwipeRefreshLayout,SwipeRefreshLayout在根据自身条件选择消费,最后RecyclerView在消费。整个事件传递和消费的流程如下:


640?wx_fmt=png


这里存在一种特殊情况,如果中间的SwipeRefreshLayout重写了onInterceptTouchEvent方法,导致事件不能传递到RecyclerView,从而导致了嵌套滑动的机制不能触发。有人可能有人疑问: SwipeRefreshLayout自己想拦截事件,并且处理事件,这难道有问题吗?


针对这个问题,我想说的是,正常情况下是没有问题的,但是如果ViewGroup必须跟手变化,只有ViewGroup跟手变化到最终态才能让 SwipeRefreshLayout下拉或者RecyclerView滑动,这种情况下,不走嵌套滑动的逻辑根本没法实现。


可能有人会提出相应的解决方法:我重写ViewGroup的onInterceptTouchEvent方法来拦截事件,然后消费事件不行吗?针对于这种解决方法,我想问的是,如果一次滑动产生10px的有效距离,而ViewGroup只能消费其中的5px,剩下的5px怎么办呢?根据情况传递到子View中去或者不消费?首先不消费是肯定不行的,否则就会显得滑动不灵敏,其次如果传递到子View中去,这也太麻烦了嘛。


像这种情况,我们最好的解决方法就是所有的滑动走嵌套滑动的逻辑,因为嵌套滑动本身自己支持消费部分距离的功能,而不用我们去特殊处理。


解释了在什么情况下不要重写onInterceptTouchEvent方法之后,我们现在来解释一下系统的SwipeRefreshLayout为什么要重写onInterceptTouchEvent。


  1. Google爸爸默认为SwipeRefreshLayout已经嵌套滑动关系链上最后一个View了,SwipeRefreshLayout不可能再有父View处理嵌套滑动。

  2. 重写onInterceptTouchEvent可以为SwipeRefreshLayout增加一个新特性--就是不用依赖子View就可以实现下拉刷新。也是说,我们在xml布局中直接添加一个SwipeRefreshLayout,不用给它添加子View就能下拉刷新。这也是嵌套滑动的弊端,必须得有一个View来产生嵌套滑动。


针对于上面两个原因,还是不能说服我坚持的观点--在嵌套滑动链上的View不用重写onInterceptTouchEvent方法。为什么呢?

上面的第二个问题,我们还是可以避免:既然是链上最底端的View,可以完全自己产生嵌套滑动事件,然后尝试着传递到父View,然后自己在消费,而不用去拦截事件。这样的话,整个关系链都不会破坏。所以我对系统的SwipeRefreshLayout的设计抱有迟疑态度。


2. 不要私自在dispatchTouchEvent的ACTION_CANCEL时机或者ACTION_UP时机调用stopNestedScroll方法


在解释具体原因,我们来看一下NestedScrollingChildHelper的startNestedScroll方法和stopNestedScroll方法。

stopNestedScroll方法比较简单,我们先来看看

public void stopNestedScroll(@NestedScrollType int type) {
    ViewParent parent = getNestedScrollingParentForType(type);
    if (parent != null) {
        ViewParentCompat.onStopNestedScroll(parent, mView, type);
        setNestedScrollingParentForType(type, null);
    }  
}


stopNestedScroll表示的意思,当前type的嵌套滑动结束了,这里主要做的是将对应的ViewParent跟重置为null。

这里为什么需要强调type呢?通常来说,在正常的滑动中,stopNestedScroll只会被调用一次,但是别忘了还有fling滑动,所以type分为两种:

1. TYPE_TOUCH,表示正常滑动,然后手指松开。
2. TYPE_NON_TOUCH,表示手指松开之后还在滑动。


所以在RecyclerView中,一次带fling操作的滑动stopNestedScroll方法会被调用两次,一次是ACTION_UP和ACTION_CANCEL调用一次,此时type为TYPE_TOUCH,一次是fling完毕,此时type为TYPE_NON_TOUCH。

那么将ViewParent重置为null有什么意义呢?这个就得从startNestedScroll方法得到答案。

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
    if (hasNestedScrollingParent(type)) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                setNestedScrollingParentForType(type, p);
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
         }
    }
    return false;
}


startNestedScroll先从缓存判断是否有View可以处理,然而就是因为这个缓存会导致一个问题。

以上面的场景,SwipeRefreshLayout私自在ACTION_UP和ACTION_CANCEL调用了stopNestedScroll方法,切断了它与父View的关系链,但是没有切断它与RecyclerView的关系链,导致后面再有事件来的话,只能传递到SwipeRefreshLayout中去,而再也不能传递到SwipeRefreshLayout的父View上去。

有人说,这没事啊,RecyclerView也会在ACTION_UP和ACTION_CANCEL切断关系啊。但是有没有考虑到一种情况--就是ACTION_UP和ACTION_CANCEL事件不能传递到RecylcerView当中。

有很多场景都存在这种情况,比如说我们长按RecyclerView的ItemView然后弹出一个Dialog或者浮层,然后松开,这些都有可能导致事件不能传递到RecyclerView中去。


我们一旦在ACTION_UP和ACTION_CANCEL时切断SwipeRefreshLayout与父View的关系,但是没有切断RecyclerView与SwipeRefreshLayout的关系,整个关系链就变成这样了:

640?wx_fmt=png


事件传递就变成了这样:

640?wx_fmt=png


从而会导致一种bug,在Dialog或者浮层View消失之后第一次滑动中,ViewGroup不能收到事件,第二次滑动能正常收到。这是为什么呢?因为第一次滑动之后,RecyclerView会调用stopNestedScroll方法;而第二次滑动会重新建立关系,本次关系链就是正常的。


所以,我们千万不要在ACTION_CANCEL或者ACTION_UP时调用stopNestedScroll方法。研究过RecyclerView源码的同学应该都知道,RecyclerView却调用了,这是为什么呢?

这是因为,在整个嵌套滑动关系链中,RecyclerView只可能是最底层的View,也就是只能产生嵌套滑动,不可能作为关系中间的一员。这一点,我们可以从RecyclerView继承的接口加以证明,RecyclerView只实现了NestedScrollingChild接口,而没有实现NestedScrollingParent接口。


所以,我们得出一个结论,如下:


一旦一个View实现了NestedScrollingParent接口,不能在ACTION_CANCEL或者ACTION_UP时调用stopNestedScroll方法。说到底就是,谁是startNestedScroll的源头,谁才有资格调用stopNestedScroll。


同时,有人可能会问,如果我们的工程已经这么干了,并且不能修改,或者修改的成本比较大怎么办呢?也是有解决方法的,在这个关系链中,凡是实现了NestedScrollingParent接口的View必须在ACTION_CANCEL或者ACTION_UP时调用stopNestedScroll方法。

这种方法会强制RecyclerView在调用startNestedScroll方法时,不走缓存,而是重新建立关系链。有一个小小的弊端,就是fling开始的时候调用startNestedScroll方法时本可以使用缓存的,但是使用此方法之后,会重新建立关系链,性能有所损耗(当然这个性能微乎其微,几乎可以不计?)。

但是这种方法还有一个比较严重的缺点,就是从此以后fling事件,不能传递到ViewGroup。这是为什么呢?我们从源码找一下答案:

首先,RecyclerView是在fling之后切断Type为TYPE_TOUCH的链:

    case MotionEvent.ACTION_UP: {
        mVelocityTracker.addMovement(vtev);
        eventAddedToVelocityTracker = true;
        mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
        final float xvel = canScrollHorizontally
                ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
        final float yvel = canScrollVertically
                ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
        if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
           setScrollState(SCROLL_STATE_IDLE);
        }
        resetTouch();
        }
        break;
//----------------------------------------------------------------------------
    private void resetTouch() {
        if (mVelocityTracker != null) {
            mVelocityTracker.clear();
        }
        stopNestedScroll(TYPE_TOUCH);
        releaseGlows();
    }


其次通过在fling方法里面,我们都是通过TYPE_TOUCH的传递链传递事件的:

    public boolean fling(int velocityX, int velocityY) {
        // ·······
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);
            // ······
        }
        return false;
    }


因为我们在dispathcTouchEvent方法里面就把传递链给中断了,这个中断肯定在fling之前执行,进而导致fling事件只能传递到SwipeRefreshLayou,而不能传递到ViewGroup(Ps:我们假设·SwipeRefreshLayou在dispathcTouchEvent方法里面就把传递链给中断)。这就是fling事件传递不过来的根的原因。所以,为了避免各种错误,我们千万不要在私自的调用stopNestedScroll方法。

3. 慎重重写onStartNestedScroll方法


我们都知道onStartNestedScroll方法是用来标识当前ViewGroup是消费嵌套滑动的事件,但是你们不知道这里面也有坑。这里我以一个例子来解释其中奥妙,同时还会介绍RecyclerView的一个巨坑。

我相信大家都做过RecyclerView加载更多的功能,如图:

640?wx_fmt=gif


大家可能直接看这张图有点懵逼,我来解释一下:很多时候,我们使用RecyclerView来实现加载更多的功能,当加载完成之后,就让RecyclerView停在那里不再动,可是一旦我们给RecyclerView套上了一个ViewGroup之后,用来处理嵌套滑动,就会出现这种情况:

640?wx_fmt=gif


我来解释一下上图中的情况:我们还在加载完成之后,RecyclerView还在继续fling。这种情况是不能容忍的,怎么来解决呢?这就需要正确的重写onStartNestedScroll方法,最简单和正确的方法是我们在重写onStartNestedScroll方法时,必须对type进行判断,代码如下:

  @Override
  public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ViewCompat.ScrollAxis int axes, @ViewCompat.NestedScrollType int type) {
    return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 && type == ViewCompat.TYPE_TOUCH;
  }


我们在onStartNestedScroll方法对type进行了判断,这也是我们重写onStartNestedScroll方法时非常容易忽视的点。

问题倒是解决了,可是大家肯定好奇为什么会出现这种情况,同时为什么加了type的判断就能解决呢?

首先,我先来解释一下为什么会这种情况,其实答案是非常的简单,在加载完成过程中,ViewFlinger还在继续fling,当数据回来时,此时fling事件还未完成,新数据加载到RecyclerView中去,ViewFlinger发现此时已经有空间可以滑动了,那么就会继续滑动。我自己觉得这是RecyclerView挖的一个坑。

其次,我们来看一下,为什么加上type判断就能解决问题呢?我们从RecyclerView的fling方法寻找答案:


    public boolean fling(int velocityX, int velocityY) {
        // ······
        // 1. 分发fling
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);
            // ······

            if (canScroll) {
                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontal) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertical) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                // 2. 建立type为TYPE_NON_TOUCH的传递链
                startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);

                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                mViewFlinger.fling(velocityX, velocityY);
                return true;
            }
        }
        return false;
    }


在fling方法里面,做了比较重要两件事:


  1. 分发fling事件。如果我们在处理嵌套滑动,很少会自己处理fling事件,所以dispatchNestedPreFling方法通常返回为false,从而进入了if的判断语句中。

  2. 通过startNestedScroll方法建立type为TYPE_NON_TOUCH的嵌套滑动传递链。由于,我们在上层View中没有对type进行判断,所以最终的传递链中会有我们的ViewGroup。

然后,我们再来看看ViewFlingerrun方法的一段代码:


if (!dispatchNestedScroll(hresult, vresult, overscrollX, overscrollY, null, TYPE_NON_TOUCH)
                    && (overscrollX != 0 || overscrollY != 0)) {
    final int vel = (int) scroller.getCurrVelocity();

    int velX = 0;
    if (overscrollX != x) {
        velX = overscrollX < 0 ? -vel : overscrollX > 0 ? vel : 0;
    }

    int velY = 0;
    if (overscrollY != y) {
        velY = overscrollY < 0 ? -vel : overscrollY > 0 ? vel : 0;
    }

    if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
        absorbGlows(velX, velY);
    }
    if ((velX != 0 || overscrollX == x || scroller.getFinalX() == 0)
            && (velY != 0 || overscrollY == y || scroller.getFinalY() == 0)) {
        scroller.abortAnimation();
    }
}


这段代码中的作用就是,当fling的速度为0时或者滑动的距离为0时,会通过abortAnimation来中断后面的fling。因为我们在startNestedScroll成功的建立传递链,所以在这里dispatchNestedScroll肯定为true,所以永远走不到这段逻辑,最终就会导致上面出现的那个问题。

而我们在我们ViewGroup的onStartNestedScroll方法对type加上了判断,在建立的传递链中不会有我们的ViewGroup,所以dispatchNestedScroll方法就会返回为false,在滑不动时,自然就会中断未完成的fling。最终我们证实了上面的解决方法为什么是正确的,而不是通过一种hack方式来实现。

到此,我就对此坑的分析就结束了。综上所述,我们在重写onStartNestedScroll方法一定要小心,一定要考虑到type为TYPE_NON_TOUCH的情况。


/   总结   /


最后,我在此说几句,嵌套滑动是爸爸给我们的好东西,但是我也们不能乱用,否则出了问题真的是太难找到根本原因了,血的教训啊!!!??

推荐阅读:


欢迎关注我的公众号
学习技术或投稿


640.png?


长按上图,识别图中二维码即可关注


本文地址:https://blog.csdn.net/c10WTiybQ1Ye3/article/details/100907599

如您对本文有疑问或者有任何想说的,请 点击进行留言回复,万千网友为您解惑!

相关文章:

验证码:
移动技术网