前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >踩坑记 | Flutter升级影响了NestedScrollView?

踩坑记 | Flutter升级影响了NestedScrollView?

原创
作者头像
Holiday
修改2020-08-10 10:19:40
9240
修改2020-08-10 10:19:40
举报
文章被收录于专栏:哈利迪ei哈利迪ei

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

不对啊,我这个页面是原生的啊,井水不犯河水的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依赖,

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

第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:

代码语言:txt
复制
class NestedScrollView extends FrameLayout implements
    NestedScrollingParent2,NestedScrollingChild2, ScrollingView{}

1.1.0:

代码语言:txt
复制
class NestedScrollView extends FrameLayout implements 
    NestedScrollingParent3,NestedScrollingChild3, ScrollingView {}

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

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

代码语言:txt
复制
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,本来就是高风险的事情(传递依赖),鬼知道哪天又被升了更高的版本?所以。。没错,哈迪把锅甩了,甩得理直气壮!

降级有无潜在风险

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

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

lifecycle:

fragment:

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

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

NestedScrollView

简析

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

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

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

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

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

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

代码语言:txt
复制
<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:

代码语言:txt
复制
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:

代码语言:txt
复制
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);
    }
}

运行如下:

大致流程:

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

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

代码语言:txt
复制
//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,

代码语言:txt
复制
//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,

代码语言:txt
复制
//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,

代码语言:txt
复制
//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,

代码语言:txt
复制
//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,

代码语言:txt
复制
//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接口又加了一个方法,

代码语言:txt
复制
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未消费的距离,

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

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

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

代码语言:txt
复制
//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了。

参考资料


原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Flutter 1.17有何魔力
  • 降级有无潜在风险
  • NestedScrollView
    • 简析
      • v3变更内容
      • 尾声
      • 参考资料
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档