嗨,我是哈利迪~最近有个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
的变更里有一行:
添加了嵌套滚动改进;请参阅 NestedScrollingChild3 和 [NestedScrollingParent3](https://developer.android.google.cn/reference/androidx/core/view/NestedScrollingParent3?hl=zh_cn)。
果然对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) {
boolean hideHeader = dy > 0 && getScrollY() < mHeaderHeight;
boolean showHeader = dy < 0 && getScrollY() > 0 && !target.canScrollVertically(-1);
if (hideHeader || showHeader) {
scrollBy(0, 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,
boolean onInterceptTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_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) {
return getScrollingChildHelper().startNestedScroll(axes, type);
}
跟进startNestedScroll,
boolean startNestedScroll(int axes, int type) {
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;
}
接着看dispatchNestedPreScroll和onNestedPreScroll,
boolean onTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_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,
boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed,
int[] offsetInWindow, int type) {
ViewParent parent = getNestedScrollingParentForType(type);
ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
}
然后看dispatchNestedScroll,
boolean onTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_MOVE: {
if (mScrollState == SCROLL_STATE_DRAGGING) {
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
}
} break;
}
return true;
}
boolean scrollByInternal(int x, int y, MotionEvent ev) {
if (dispatchNestedScroll(consumedX, consumedY, unconsumedX,
unconsumedY, mScrollOffset,TYPE_TOUCH)) {
}
}
最后再看下stopNestedScroll,
boolean onTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_UP: {
resetTouch();
} break;
}
return true;
}
void resetTouch() {
stopNestedScroll(TYPE_TOUCH);
}
好了,梳理一下思路,
rv在onTouch的down事件,开启了嵌套滑动,startNestedScroll,先调父view的onStartNestedScroll看他是否支持嵌套滑动,一层层往上找到了nsv,回调nsv的onNestedScrollAccepted
rv在onTouch的move事件,开始分发预处理,dispatchNestedPreScroll,回调nsv的onNestedPreScroll
rv在onTouch的move事件,开始分发滑动,dispatchNestedScroll,回调nsv的onNestedScroll
rv在onTouch的up事件,结束分发,stopNestedScroll,回调nsv的onStopNestedScroll
可见,rv作为儿子,是主动方。同时,引入了unConsumed值可以向彼此传递剩余距离,rv未消费完的距离,还可以交给nsv继续消费。
v3变更内容
1.1.0
中NestedScrollView实现的接口从v2变成了v3,v3接口又加了一个方法,
interface NestedScrollingChild3 extends NestedScrollingChild2 {
void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
int[] offsetInWindow, int type,int[] consumed);
}
interface NestedScrollingParent3 extends NestedScrollingParent2 {
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,
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);
}
static void onNestedScroll(ViewParent parent, View target, int dxConsumed,
int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type,
int[] consumed) {
if (parent instanceof NestedScrollingParent3) {
((NestedScrollingParent3) parent).onNestedScroll(xxx);
} else {
if (parent instanceof NestedScrollingParent2) {
((NestedScrollingParent2) parent).onNestedScroll(xxx);
} else if (type == ViewCompat.TYPE_TOUCH) {
if (Build.VERSION.SDK_INT >= 21) {
parent.onNestedScroll(xxx);
} else if (parent instanceof NestedScrollingParent) {
((NestedScrollingParent) parent).onNestedScroll(xxx);
}
}
}
}
SDK21开始支持了嵌套滑动,在View和ViewGroup里直接加了nest相关方法,但为了向前兼容,在android.support.v4兼容包中提供了两个接口NestedScrollingChild和NestedScrollingParent,即要实现嵌套滑动,既可以使用SDK21的View,也可以自己实现那两个接口。我们看回AndroidX,以xxxParent接口为例(xxxChild类似),
NestedScrollingParent:定义了一些nest方法
NestedScrollingParent2:扩展了这些nest方法,都加上了type参数,表示是触摸滑动
还是惯性滑动
fling
NestedScrollingParent3:扩展了1个nest方法onNestedScroll,加上了1个参数int[] consumed
谷歌做了很好的兼容处理,但由于我写的demo是继承自NestedScrollView的,NestedScrollView随着AndroidX的升级,实现的接口自动变成了v3,在回调onNestedScroll时命中了v3条件,走了最多参数的回调onNestedScroll(老的回调没走),所以demo代码就翻车了(哈迪实际遇到的问题不是这个,demo仅做演示)。
尾声
就,总结两个心得吧,
注意传递依赖
带来的问题。阻断依赖可能造成类丢失,但编译期能及时发现(如果有人用反射去调一个野生类,是不是就发现不了了);而不阻断呢,又可能引入一些高版本的库,导致无法预测的问题。
即便文档很完善、做了很好的兼容,任何升级,都需要充分验证稳定性。
好了,我要继续去修bug了。
参考资料
评论