踩坑记 | 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:

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

1.1.0:

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

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

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

1
2
3
4
5
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//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,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//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,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//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,

1
2
3
4
5
6
7
8
//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,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//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,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//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接口又加了一个方法,

1
2
3
4
5
6
7
8
9
10
11
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,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//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了。

参考资料