美文网首页
踩坑记 | Flutter升级影响了NestedScrollVi

踩坑记 | Flutter升级影响了NestedScrollVi

作者: 哈利迪ei | 来源:发表于2020-08-07 20:30 被阅读0次

    嗨,我是哈利迪~最近有个bug排查了好几天,就是有个老页面因业务复杂度,使用了NestedScrollView+tab+多Fragment的结构(各Fragment里有RecyclerView,即存在嵌套滑动),在新的班车中,出现了偶现的滑不动问题。在业务相关组件里排查了很久都没思路,哈迪便开始了万能的组件排除法,即在几十个变更组件里用二分法分批排查(没错就是这么骚),最后定位到一个Flutter组件,只要把它回退就没问题了。。

    image

    不对啊,我这个页面是原生的啊,井水不犯河水的Flutter,还能影响到我的页面?找了组里的老哥一起看,才发现,竟然是Flutter升级1.17引起的!

    本文约3300字,阅读大约9分钟。如个别大图模糊,可前往个人站点阅读。

    Flutter 1.17有何魔力

    Flutter1.17算是一个里程碑版本,做了很多性能、功能、工具上的优化,详见Flutter 1.17 | 2020 首个稳定版发布,里边有这么一段话:

    如果您的目标平台是 Android,您会注意到,现在创建新的 Flutter 项目时只提供 AndroidX 选项。AndroidX 库提供了被称为 Android Jetpack 的高级 Android 功能。在上一个版本中,我们不再支持原先的 Android Support Library,转而将 AndroidX 作为所有新项目的默认选项。在 Flutter 1.17 中,flutter create 命令只有 --androidx 这一个选项。虽然现有的不使用 AndroidX 的 Flutter 应用依然可以编译,但是时候迁移至 AndroidX 了

    官方没有提到androidx版本,我们把Flutter升到1.17后,在壳工程Sync一下,发现External Libraries里有两个core依赖,

    image

    ./gradlew app:dependencies,看下Flutter组件的依赖树:

    image

    第1个是java类的jar包,后面3个jar包则用来依赖各个CPU架构的so库。

    从第1个jar包可以看出,就是传递依赖的锅!他把比较新的androidx.fragment、lifecycle和annotation给拉过来了,导致androidx.core也从1.0.0变成了1.1.0,查阅core版本发布,在1.1.0的变更里有一行:

    添加了嵌套滚动改进;请参阅 NestedScrollingChild3NestedScrollingParent3

    果然对NestedScrollView进行了改动,看一下这个类:

    1.0.0:

    class NestedScrollView extends FrameLayout implements
        NestedScrollingParent2,NestedScrollingChild2, ScrollingView{}
    

    1.1.0:

    class NestedScrollView extends FrameLayout implements 
        NestedScrollingParent3,NestedScrollingChild3, ScrollingView {}
    

    可见,有两个接口从v2变成了v3,NestedScrollView类本身的实现也有一些改动。

    传递依赖怎么解决,exclude一下就行了,(困扰了好几天的bug这么简单就修好了?)

    compile('xxx') {
        exclude(group: 'androidx.fragment')
        exclude(group: 'androidx.lifecycle')
        exclude(group: 'androidx.annotation')
    }
    

    那这里就有一个问题了,Flutter1.17(的flutter_embedding_release-1.0.0-$hash这个jar包)到底有没有用到AndroidX1.1.0版本的新代码?这样强行降级使用1.0.0有啥潜在风险?这个待会讨论。

    又或者,为啥不去改业务代码,真正的修掉bug?首先嵌套滑动场景可能不止一处业务在用,我的页面修了,其他地方可能还有没发现的bug呢~其次,单纯为了升Flutter而接受更新的AndroidX,本来就是高风险的事情(传递依赖),鬼知道哪天又被升了更高的版本?所以。。没错,哈迪把锅甩了,甩得理直气壮!

    image

    降级有无潜在风险

    首先阿里的flutter_boost用的AndroidX也是1.0.0,所以不用关心,那我们重点看到flutter_embedding_release-1.0.0-$hash这个jar包,用jadx-gui反编译一下,搜androidx,

    image

    可见fragment、lifecycle、annotation确实有被用上,annotation我们不用关心,关注另外两个,

    lifecycle:

    image

    fragment:

    image

    先看下lifecycle变更,看起来就是弃用了一些东西和加了点ViewModel的功能,那降到1.0.0没啥影响;

    再看到fragment变更,改动了FragmentFactory、ViewModel 的 Kotlin 属性委托、最大生命周期、FragmentActivity LayoutId 构造函数等,哈迪在jadx-gui里大致搜了一下,也没用上这些新东西,所以目前看下来,androidx强行降级使用1.0.0是安全的(如果有足够人力投入并验证,升上去当然更好)。

    NestedScrollView

    简析

    那么接下来我们来看看1.1.0里NestedScrollView都改了写啥,先来捋下NestedScrollView的继承关系:

    image

    先分析1.0.0版本,然后再来看1.1.0的改动点。NestedScrollView继承FrameLayout,实现了NestedScrollingParent2、NestedScrollingChild2、ScrollingView接口,持有NestedScrollingParentHelper和NestedScrollingChildHelper两个辅助类来处理逻辑。

    直接看源码容易掉头发,还是先简单使用感受一下。

    image

    代码仅供演示,非必要情况下并不推荐NestedScrollView和RecyclerView的嵌套。

    相比NestedScrollView,RecyclerView只实现了NestedScrollingChild2,在嵌套滑动体系里只能作为子布局存在,所以下面以RecyclerView为子,NestedScrollView为父,

    布局很简单,就一个header和RecyclerView:

    <MyNestedScrollView 
          android:id="@+id/nsv_out">
    
        <LinearLayout
               android:orientation="vertical">
    
            <ImageView
                  android:id="@+id/iv_header"
                  android:src="@mipmap/ic_launcher" />
    
            <MyRecyclerView
                  android:id="@+id/rv_list"
                  android:layout_width="match_parent"
                  android:layout_height="600dp" />
    
        </LinearLayout>
    
    </MyNestedScrollView>
    

    给RecyclerView指定了高度,确保能正常复用。先加些日志观察下嵌套滑动机制,MyNestedScrollView:

    class MyNestedScrollView extends NestedScrollView {
        @Override
        public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) {
            //上滑,如果getScrollY不足header高度,就先滑自己,隐藏header
            boolean hideHeader = dy > 0 && getScrollY() < mHeaderHeight;
            //下滑,如果RV已经滑到顶部,就滑自己,展示header
            boolean showHeader = dy < 0 && getScrollY() > 0 && !target.canScrollVertically(-1);
            if (hideHeader || showHeader) {
                scrollBy(0, dy);
                //告诉rv,我已经消费调了dy距离
                consumed[1] = dy;
                HLog.e("嵌套滑动", mName + " :header可见,我先滑,待会给你滑");
            }
        }
    
        @Override
        public void onNestedScroll(View target, int dxConsumed, int dyConsumed, 
                                   int dxUnconsumed, int dyUnconsumed, int type) {
            if (0 != dyUnconsumed) {
                HLog.e("嵌套滑动", mName + " :你还有 " + dyUnconsumed + " 没消费啊,我也不需要咯");
            }
            super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type);
        }
    }
    

    MyRecyclerView:

    class MyRecyclerView extends RecyclerView {
        @Override
        public boolean startNestedScroll(int axes, int type) {
            HLog.e("嵌套滑动", mName + " :你要不要滑动");
            return super.startNestedScroll(axes, type);
        }
    
        @Override
        public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow, int type) {
            if (0 != consumed[1]) {
                mNsvConsume += consumed[1];
                HLog.e("嵌套滑动", mName + " :好的,我看着你滑,看你滑了多少 consumed = " + mNsvConsume);
            }
            return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
        }
    
        @Override
        public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
                                            int[] offsetInWindow, int type) {
            HLog.e("嵌套滑动", mName + " :那我滑动咯,我消费了 " + dyConsumed+" , 还有 "+dyUnconsumed+" 没消费,你看下需不需要");
            return super.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, type);
        }
    
        @Override
        public void stopNestedScroll(int type) {
            HLog.e("嵌套滑动", mName + " :本次滑动结束");
            super.stopNestedScroll(type);
        }
    }
    

    运行如下:

    image

    大致流程:

    image

    大家都知道,事件分发存在中断问题,嵌套滑动机制则可以解决,下面我们分析下源码。

    RecyclerView作为起点,从日志里看到,startNestedScroll会被调两次,一次是在onInterceptTouchEvent,一次是在onTouchEvent,(如果产生了惯性,fling也会调startNestedScroll,先忽略),以下MyRecyclerView简称rv,MyNestedScrollView简称nsv,

    //RecyclerView.java
    boolean onInterceptTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_DOWN: //down事件
                //纵轴、触摸中(非惯性)
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
                break;
        }
        return mScrollState == SCROLL_STATE_DRAGGING;
    }
    
    boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                //纵轴、触摸中
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            } break;
        }
        return true;
    }
    
    boolean startNestedScroll(int axes, int type) {
        //会回调nsv的onNestedScrollAccepted
        return getScrollingChildHelper().startNestedScroll(axes, type);
    }
    

    跟进startNestedScroll,

    //NestedScrollingChildHelper.java
    boolean startNestedScroll(int axes, int type) {
        if (isNestedScrollingEnabled()) { //支持嵌套滑动
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) { //向上找到nsv
                //回调onStartNestedScroll,看是否支持嵌套滑动
                //return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
                //nsv支持纵向滑动,返回true
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    //回调nsv的onNestedScrollAccepted
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }
    

    接着看dispatchNestedPreScroll和onNestedPreScroll,

    //RecyclerView.java
    boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_MOVE: { //move事件
                //分发预处理
                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
                }
            } break;
        }
        return true;
    }
    
    boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,int type) {
        return getScrollingChildHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow,type);
    }
    

    跟进dispatchNestedPreScroll,

    //NestedScrollingChildHelper.java
    boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed,
                                    int[] offsetInWindow, int type) {
        //找到nsv
        ViewParent parent = getNestedScrollingParentForType(type);
        //会回调nsv的onNestedPreScroll,同时rv作为target传入
        ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
    }
    

    然后看dispatchNestedScroll,

    //RecyclerView.java
    boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_MOVE: { //move事件
                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    //进行一些计算...
                    //调scrollByInternal
                    if (scrollByInternal(
                        canScrollHorizontally ? dx : 0,
                        canScrollVertically ? dy : 0,
                        vtev)) {
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                }
            } break;
        }
        return true;
    }
    
    boolean scrollByInternal(int x, int y, MotionEvent ev) {
        //进行一些计算...
        //调用dispatchNestedScroll分发,会回调nsv的onNestedScroll
        if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, 
                                 unconsumedY, mScrollOffset,TYPE_TOUCH)) {
        }
    }
    

    最后再看下stopNestedScroll,

    //RecyclerView.java
    boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_UP: { //up事件
                resetTouch();
            } break;
        }
        return true;
    }
    
    void resetTouch() {
        //最终回调nsv的onStopNestedScroll
        stopNestedScroll(TYPE_TOUCH);
    }
    

    好了,梳理一下思路,

    1. rv在onTouch的down事件,开启了嵌套滑动,startNestedScroll,先调父view的onStartNestedScroll看他是否支持嵌套滑动,一层层往上找到了nsv,回调nsv的onNestedScrollAccepted
    2. rv在onTouch的move事件,开始分发预处理,dispatchNestedPreScroll,回调nsv的onNestedPreScroll
    3. rv在onTouch的move事件,开始分发滑动,dispatchNestedScroll,回调nsv的onNestedScroll
    4. rv在onTouch的up事件,结束分发,stopNestedScroll,回调nsv的onStopNestedScroll

    可见,rv作为儿子,是主动方。同时,引入了unConsumed值可以向彼此传递剩余距离,rv未消费完的距离,还可以交给nsv继续消费。

    v3变更内容

    1.1.0中NestedScrollView实现的接口从v2变成了v3,v3接口又加了一个方法,

    interface NestedScrollingChild3 extends NestedScrollingChild2 {
        //扩展v2的1个方法,但是最后面多了个参数,int[] consumed
        void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
                                  int[] offsetInWindow, int type,int[] consumed);
    }
    
    interface NestedScrollingParent3 extends NestedScrollingParent2 {
        //扩展v2的1个方法,但是最后面多了个参数,int[] consumed
        void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
                            int dyUnconsumed, int type, int[] consumed);
    }
    

    然后我们再跑一次刚刚写的demo,不过我们这次将手指从上往下滑(下拉),让rv产生未消费距离,

    AndroidX1.0.0日志:nsv能正常收到rv未消费的距离,

    image

    AndroidX1.1.0日志:nsv没有收到rv未消费的距离(回调没被执行)

    image

    可见,老的dispatchNestedScroll还是能正常调用,但是老的onNestedScroll却没被正常回调了,难道是被换成了新加的方法?下面让我们一起解开谜团~

    dispatchNestedScroll为啥能被正常调用?前面分析过的,他是在RecyclerView里被调的,当然没受影响。跟进dispatchNestedScroll,

    //NestedScrollingChildHelper.java
    boolean dispatchNestedScrollInternal(int dxConsumed, int dyConsumed,
                                         int dxUnconsumed, int dyUnconsumed,int[] offsetInWindow,
                                         int type, int[] consumed) {
        //兼容处理类
        ViewParentCompat.onNestedScroll(parent, mView,dxConsumed, dyConsumed, 
                                        dxUnconsumed, dyUnconsumed, type, consumed);
    }
    
    //ViewParentCompat.java
    static void onNestedScroll(ViewParent parent, View target, int dxConsumed,
                               int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type,
                               int[] consumed) {
        if (parent instanceof NestedScrollingParent3) {
            //回调v3
            ((NestedScrollingParent3) parent).onNestedScroll(xxx);
        } else {
            if (parent instanceof NestedScrollingParent2) {
                //回调v2
                ((NestedScrollingParent2) parent).onNestedScroll(xxx);
            } else if (type == ViewCompat.TYPE_TOUCH) {
                //v2以下,只支持触摸TYPE_TOUCH,不支持惯性TYPE_NON_TOUCH
                if (Build.VERSION.SDK_INT >= 21) {
                    //5.0开始,ViewParent接口加了onNestedScroll方法
                    parent.onNestedScroll(xxx);
                } else if (parent instanceof NestedScrollingParent) {
                    //5.0以下,回调v1
                    ((NestedScrollingParent) parent).onNestedScroll(xxx);
                }
            }
        }
    }
    

    SDK21开始支持了嵌套滑动,在View和ViewGroup里直接加了nest相关方法,但为了向前兼容,在android.support.v4兼容包中提供了两个接口NestedScrollingChild和NestedScrollingParent,即要实现嵌套滑动,既可以使用SDK21的View,也可以自己实现那两个接口。我们看回AndroidX,以xxxParent接口为例(xxxChild类似),

    1. NestedScrollingParent:定义了一些nest方法
    2. NestedScrollingParent2:扩展了这些nest方法,都加上了type参数,表示是触摸滑动还是惯性滑动fling
    3. NestedScrollingParent3:扩展了1个nest方法onNestedScroll,加上了1个参数int[] consumed

    谷歌做了很好的兼容处理,但由于我写的demo是继承自NestedScrollView的,NestedScrollView随着AndroidX的升级,实现的接口自动变成了v3,在回调onNestedScroll时命中了v3条件,走了最多参数的回调onNestedScroll(老的回调没走),所以demo代码就翻车了(哈迪实际遇到的问题不是这个,demo仅做演示)。

    尾声

    就,总结两个心得吧,

    1. 注意传递依赖带来的问题。阻断依赖可能造成类丢失,但编译期能及时发现(如果有人用反射去调一个野生类,是不是就发现不了了);而不阻断呢,又可能引入一些高版本的库,导致无法预测的问题。

    2. 即便文档很完善、做了很好的兼容,任何升级,都需要充分验证稳定性。

    好了,我要继续去修bug了。

    image

    参考资料


    image

    相关文章

      网友评论

          本文标题:踩坑记 | Flutter升级影响了NestedScrollVi

          本文链接:https://www.haomeiwen.com/subject/xluqdktx.html