专栏首页哈利迪ei踩坑记 | Flutter升级影响了NestedScrollView?
原创

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

嗨,我是哈利迪~最近有个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:

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

降级有无潜在风险

首先阿里的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:

<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);
    }
}

运行如下:

大致流程:

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

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未消费的距离,

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

可见,老的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了。

参考资料


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

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

我来说两句

0 条评论
登录 后参与评论

相关文章

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

    嗨,我是哈利迪~最近有个bug排查了好几天,就是有个老页面因业务复杂度,使用了NestedScrollView+tab+多Fragment的结构(各Fragme...

    Holiday
  • Android | Tangram动态页面之路(四)vlayout原理

    本系列文章主要介绍天猫团队开源的Tangram框架的使用心得和原理,由于Tangram底层基于vlayout,所以也会简单讲解,该系列将按以下大纲进行介绍:

    Holiday
  • Android | okhttp细枝篇

    嗨,我是哈利迪~《看完不忘系列》之okhttp(树干篇)一文对okhttp的请求流程做了初步介绍,本文将对他的一些实现细节和相关网络知识进行补充。

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

    嗨,我是哈利迪~最近有个bug排查了好几天,就是有个老页面因业务复杂度,使用了NestedScrollView+tab+多Fragment的结构(各Fragme...

    Holiday
  • LeetCode31|打印从1到最大的n位数

    这道题算是api的使用方式了,数据的计算,其实自己也没有什么好说的了,但是由于文章的字数必需要达到300字,所有有些时候就只好在这里唠会嗑了,因为文章的原创对于...

    后端Coder
  • LeetCode 1390. 四因数

    来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/four-divisors 著作权归领扣网络所有。商...

    Michael阿明
  • 不相交集类应用:迷宫生成

    1 #include <iostream> 2 #include <vector> 3 #include <cstdlib> 4 #includ...

    用户1154259
  • HDU 3018 Ant Trip(欧拉回路)

    #include <bits/stdc++.h> using namespace std; const int N=100005; int f[N]; ...

    用户2965768
  • 2018 团队设计天梯赛题解---华山论剑组

    2018 年度的团队设计天梯赛前几天结束了。但是成绩真的是惨不忍睹。。。毕竟是团队的比赛,如果团队平均水平不高的话,单凭一个人,分再高也很难拉起来(当然,一个人...

    指点
  • 算法-编程的灵魂--八皇后

    对于我们程序员来说,算法是编程的灵魂,算法的好坏与否,也决定了你代码的健壮性。 ----至此,祝愿各位五一节快乐,玩的开心! 下面,看看下面的经典算法,经典的算...

    赵腰静

扫码关注云+社区

领取腾讯云代金券