欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

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

程序员文章站 2022-06-24 12:38:06
/ 今日科技快讯 /昨日,易车网发布一份公告,声明公司董事会已收到一份初步的非约束性私有化提议。这份私有化收购要约来*腾讯及Hammer Capital(黑马资......


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


/   今日科技快讯   /


昨日,易车网发布一份公告,声明公司董事会已收到一份初步的非约束性私有化提议。这份私有化收购要约来*腾讯及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。


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


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


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


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


这里存在一种特殊情况,如果中间的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的关系,整个关系链就变成这样了:

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


事件传递就变成了这样:

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


从而会导致一种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加载更多的功能,如图:

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


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

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


我来解释一下上图中的情况:我们还在加载完成之后,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的情况。


/   总结   /


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

推荐阅读:


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


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


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


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