ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android NestedScrolling机制全解析:从滚动冲突到自定义嵌套容器

Android NestedScrolling机制全解析:从滚动冲突到自定义嵌套容器 遇到跨界滚动的崩溃现场几乎每个做Android的人都被问过这么一句RecyclerView能不能嵌进ScrollView里。早期答案是别嵌嵌了不是卡顿就是事件被吃掉后来有了NestedScrolling机制这套父子互相抢事件的死局才算真正打开。这篇就想把Android NestedScrolling这套机制从头到尾拆开讲清楚它到底解决什么问题Child和Parent之间靠什么约定协作一次真实的手势滑动从按下到抬起经历过哪些方法调用再带大家手写一个头部折叠联动列表的自定义嵌套滚动容器最后把我在真机上踩过的坑一并列出来。适合那些已经在用CoordinatorLayout和AppBarLayout、但遇到诡异滚动问题只能靠试的开发者也适合想深入理解View体系协作方式的读者。1. 嵌套滚动到底在解决什么问题1.1 从一场最经典的滑动冲突说起先回到没有NestedScrolling的年代。那时候想在页面上实现上半部分是一个可以收缩的头部下半部分是一长串列表最粗暴的写法是直接用ScrollView包一个LinearLayout列表用不带滚动能力的LinearLayout往里填。数据量小看着还行数据一多就开始卡因为整个线性布局全被一次性测量布局了。后来有人换成ScrollView里嵌ListView又踩进另一个坑两者都有滚动能力手势方向稍微偏一点事件就被内层ListView吃掉了外层ScrollView根本滚不动偶尔外层动了内层又完全不听使唤。当时的通用解法是重写ListView的onMeasure把高度算成所有item之和让它永远不滚动彻底失去列表复用。这种方案等于为了让外层能滚牺牲掉了内层的全部滚动优化。问题根源在于Android传统的事件分发模型里ViewGroup和子View对一次触摸事件的争夺是零和博弈onInterceptTouchEvent要么拦截要么不拦没有分我一点的空间。而真正复杂的业务页面需要的是两个滚动容器协同工作比如头部先收起收起完了列表再滚这种明确的先后顺序。1.2 事件分发机制里嵌套滚动的先天缺陷传统分发链路上一次ACTION_DOWN先走ViewGroup的dispatchTouchEvent再决定是分发给子View还是自己处理。如果ViewGroup决定不拦截之后同一序列的ACTION_MOVE默认继续发给子ViewViewGroup中途想抢回事件只能在onInterceptTouchEvent里返回true。这种设计有几道硬伤父View和子View无法在同一个move事件里共同消费一段滚动距离。要么父滚要么子滚想做到先父后子或者父消费一部分、子消费剩余部分没有直接的表达方式。子View滚动到边界后没有接口告诉父View我已经到头了剩下的你接着滚。子View只能自己吞掉多余的距离对外表现为滚动被吃掉了一截。惯性滑动fling阶段的协作更无从谈起。手指松开之后的Animator是子View内部自己起飞的父View既不知道也拦不住。这也是为什么有个经典帖子把这种问题总结为要么掐死内层要么放弃外层。直到API 21引入NestedScrolling机制才把这两个在概念上本来应该协同工作的滚动容器从互相竞争变成了一场有协议的分工。1.3 NestedScrolling的设计目标把竞争变成协作NestedScrolling不是要替代事件分发它是在事件分发之上搭了一层滚动协作协议。它的核心思路是滚动手势发生时先问父View要不要消费父View消费完剩下的才给子View子View消费完如果还有剩再回过头问父View要不要兜底。整个过程发生在一次move事件的时间窗口里父和子通过接口回调交换我消费了多少、还剩多少。这个设计直接回答了上一节的三道硬伤用Pre和Post两个阶段实现了同一事件的多方消费用dispatchNestedScroll把剩余滚动距离向上抛用dispatchNestedPreFling和dispatchNestedFling把惯性阶段也纳入协作范围。外界经常把NestedScrolling简单理解成为了让ScrollView能包RecyclerView其实它的覆盖面广得多。CollapsingToolbarLayout的头部收起、BottomSheetBehavior的拖拽、ViewPager2内部对横竖方向滚动的协调底层全是这一套协议在工作。你平时遇到的很多不知道哪里冒出来的滚动联动其实都是某个组件默默实现了NestedScrolling相关接口。2. 核心角色与接口约定Child和Parent各自的分工2.1 四个角色是谁直接看名字容易懵先给一张角色对照表角色典型实现职责NestedScrollingChildRecyclerView、NestedScrollView、SwipeRefreshLayout里的目标View主动发起嵌套滚动并向父View汇报自己的消费情况NestedScrollingParentNestedScrollView、CoordinatorLayout、你手写的自定义容器响应子View发起的滚动请求决定是否消费滚动距离NestedScrollingChildHelperChild内部持有负责找Parent、维护协作状态、分发事件的默认实现NestedScrollingParentHelperParent内部持有负责记录协作方向、管理接受/停止状态Helper不是可有可无的封装它承担了绝大部分脏活Child要调startNestedScroll时Helper负责沿ViewParent链向上找第一个实现了NestedScrollingParent的父View同时要维护当前正在协作的Parent引用避免一次手势里跟多个Parent建立关系导致事件重复分发。理论上你可以不用Helper自己写一套状态机但实际没人这么干。原因很简单Helper处理了很多边界情况比如嵌套滚动期间Parent被移除、多个嵌套层级同时请求协作、触摸滚动和非触摸滚动两种模式的状态切换自己搞很容易在冷门场景崩一脚。2.2 NestedScrollingChild的六个关键方法Child接口里最重要的是这六个setNestedScrollingEnabled(boolean)总开关。关掉之后Child完全不参与嵌套协作所有滚动距离自己硬吞。startNestedScroll(int axes)向父View发起协作请求。axes一般取ViewCompat.SCROLL_AXIS_VERTICAL或SCROLL_AXIS_HORIZONTAL父View通过onStartNestedScroll决定接不接受。dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow)在Child自己处理滚动之前先把距离抛给父View。consumed数组是输出参数父View消费的量会累加进去。假设dy是10父View在onNestedPreScroll里消费了7那Child自己只能滚3。dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow)Child自己消费完之后把没消费掉的量抛给父View兜底。dispatchNestedPreFling(float velocityX, float velocityY, boolean consumed)把惯性速度先给父View过一道。父View可以在onNestedPreFling里返回true表示自己全权接管。dispatchNestedFling(float velocityX, float velocityY, boolean consumed)Child自己处理不完全部惯性时把剩余fling速度抛给父View。注意dispatchNestedPreScroll里consumed数组的初始值由Child保证为0或者每次重新new一个长度为2的数组父View消费多少就累加多少到对应的下标。0号下标对应x方向1号下标对应y方向。2.3 NestedScrollingParent的八个回调Parent端的核心回调比Child还多几个我按生命周期阶段分组建立阶段onStartNestedScroll(View child, View target, int axes)返回true表示接受这次协作。child是直接子Viewtarget是真正发起滚动的那个View两者可能隔了好几层。这个区分很关键判断时通常用target因为target才是真正滚动的对象。onNestedScrollAccepted(View child, View target, int axes)确认接受之后调用可以在这里初始化状态。onStopNestedScroll(View target)手势结束协作关系断开。预滚动阶段onNestedPreScroll(View target, int dx, int dy, int[] consumed)在target自己滚动前调用。返回true表示消费了部分或全部滚动。后滚动阶段onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed)target自己消费完之后调用。unconsumed是target剩下的量Parent可以在这一层接着滚。惯性阶段onNestedPreFling(View target, float velocityX, float velocityY)预惯性返回true代表Parent接管所有fling。onNestedFling(View target, float velocityX, float velocityY, boolean consumed)target自己fling完之后如果Parent想追加一个自己的fling动画就处理这里。观察这套方法命名能发现一个规律所有带Pre的方法都发生在target自己动手之前的先让父辈挑剩下的才轮到我不带Pre的带Unconsumed参数发生在target动手之后我吃不完的你接着吃。2.4 为什么要有Pre和Post两个阶段刚开始接触这套接口的开发者最容易问直接统一走一个回调不就行了分两段干什么。原因在于业务上存在两种截然不同的联动顺序需求。第一种需求是父View优先典型场景是CollapsingToolbarLayout手指下滑时头部要先把折叠高度收缩完之后的距离才轮到列表滚动。这种必须走onNestedPreScroll父View先把dy消费掉一部分RecyclerView只拿到剩下来的量。如果RecyclerView先滚列表动一下就到底了头部还没收缩体验完全是反的。第二种需求是子View优先典型场景是普通的嵌套翻页列表自身还能滚时所有的dy都该给列表只有当列表已经滚到边界、剩下来的dy无处可去才交回给父View做整页滑动。这种就得靠onNestedScroll里的unconsumed参数实现。还有更微妙的混合场景比如QQ空间的头部下拉效果下拉时先让头部跟着位移一段到达临界值后变成刷新上滑时则列表先滚滚到顶了头部再展开。这种需求里Pre和Post两个回调一个都省不了。没有两阶段分发这些先后逻辑全部得靠Parent在事件分发层自己去抢抢完再手动把距离交给子View代码很快会烂到没法维护。3. 手指滑动的完整链路一次move事件的全过程拆解3.1 startNestedScroll建立协作关系的时机一次完整滑动从Child收到的ACTION_DOWN开始。RecyclerView在自己的onTouchEvent里处理的第一个事件是DOWN。它做的第一件事不是滚动而是调startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)告诉父View我准备开始一场垂直方向的手势你要不要一起玩。这个调用会沿着ViewParent链往上找找到第一个实现了NestedScrollingParent的对象并调它的onStartNestedScroll。如果父View返回trueHelper就把这个父View记为当前协作对象紧接着调onNestedScrollAccepted。之所以放在DOWN而不是等MOVE才建立关系是因为滑动方向在DOWN阶段还不明确而到了第一个MOVE再临时找Parent手势的前几个像素就可能因为缺少协作而出现跳动。提前建立关系Parent可以在后续事件里全部介入。需要注意的是一次触摸序列只会调用一次startNestedScroll不会在每一个move都重新建立。结束有两个触发点ACTION_UP和ACTION_CANCEL时Child会调stopNestedScroll通知Parent协作结束或者Parent自己在onStopNestedScroll里做状态清理。3.2 一次move的三方博弈先给出一条完整链路的调用顺序然后再逐环解释。RecyclerView在onTouchEvent里收到ACTION_MOVE计算本次要滚动的deltaY。Child先调dispatchNestedPreScroll(0, deltaY, consumed, offset)把距离抛给Parent的onNestedPreScroll。Parent在onNestedPreScroll里消费了一部分距离写入consumed[1]。Child计算剩余滚动距离remain deltaY - consumed[1]。Child自己执行滚动逻辑比如layoutManager.scrollBy(0, remain)真正让item滚动的就是这一步。如果remain没有被完全消费Child把剩余部分通过dispatchNestedScroll抛给Parent的onNestedScrollParent在这里接着滚。整个过程可能在一个move事件里反复出现多次比如RecyclerView的fling过程中非触摸产生的滚动也会走同样的协议只是调用的是带type参数的接口版本。这套流程里的关键认知是无论Parent还是Child在onNestedPreScroll和onNestedScroll里返回true还是false都不影响事件分发本身。它只是告知我是否消费了滚动距离而是否把事件继续发给对方完全由事件分发机制天然保证。这就是嵌套滚动容易上手成习惯后产生误解的地方——很多人以为返回false会被踢出协作其实不是甚至返回true也不代表事件被拦截。3.3 offsetInWindow到底是什么鬼dispatchNestedPreScroll和dispatchNestedScroll的最后一个参数int[] offsetInWindow都是输出参数里面装两个值x方向的窗口偏移和y方向的窗口偏移。很多人第一次看到这个参数直接懵了明明滚动距离都算清楚了这个偏移到底拿来干嘛。想明白它关键在于理解滚动和位置变化的区别。如果一个Parent在onNestedPreScroll里消费了距离它通常会让自己的内容或者某个header发生位置移动。这个移动不会改变Child在自身坐标系里的位置但是会导致Child整个View在屏幕窗口里的实际位置发生变化。举例外层容器消费了100px把自己的header向上顶了100px。对RecyclerView来说它自己没有滚但整个控件在窗口里上移了100px。如果触摸事件坐标是基于窗口的而RecyclerView内部计算点击、拖拽的坐标是基于自身坐标系的两者就会差出这100px的错位。不修正的话用户会感到我按住的item在手指下滑动时位置对不上。解决方式就是offsetInWindowChild调用dispatchNestedPreScroll得到这个偏移量之后会把它应用到触摸事件坐标修正上。这也是为什么在多层嵌套滚动场景里父容器处理完预滚动后子View需要调整event的offset再继续后续逻辑。这个数组在Helper内部已经有默认处理但如果你自己实现跨层级的嵌套滚动就得手动把偏移量持续往上传递。3.4 Fling惯性冲刺怎么分配手指松开后的惯性阶段很多人以为跟NestedScrolling无关因为事件都停了。实际上惯性滚动是RecyclerView内部的OverScroller在驱动每一帧产生的位移仍然会走一遍嵌套滚动协议。RecyclerView在接收到ACTION_UP、计算出初速度之后会调dispatchNestedPreFling。Parent的onNestedPreFling有机会表示这次惯性我自己全包了如果返回trueRecyclerView就不会再启动自己的fling动画而是由Parent自己起一个OverScroller或者Animator。如果Parent在onNestedPreFling里返回falseRecyclerView会继续启动自己的fling同时调dispatchNestedFling把速度透传给Parent。Parent的onNestedFling里如果返回true也可以自己追加一个动画两个动画并行运行。CoordinatorLayout的AppBarLayout抬手收起、BottomSheet的自动吸附动画很多都是靠这一环实现的。需要注意的是惯性阶段的协作是非触摸滚动模式。接口上对应的是NestedScrollingChild2里的TYPE_NON_TOUCH以及NestedScrollingChild3里的完整新方法。如果只实现了老的接口很多系统组件仍然能工作因为RecyclerView内部有兼容处理但做一些精细化的惯性衔接时会缺信息这也是为什么新项目建议直接实现NestedScrollingChild3那套。4. 实战手写一个可折叠Header联动RecyclerView4.1 业务需求与设计方案直接拿一个真实业务场景个人主页顶部是品牌展示区高度从200dp收起后变成80dp下面跟着一个RecyclerView。交互要求分三段、顺序要完全符合直觉手指下滑内容向下移动dy 0时头部先收起收起完成之后列表才开始滚动。手指上滑内容向上移动dy 0时列表先滚回顶部列表回到顶部之后头部再展开。惯性滑动时也遵循同样的先后顺序。这个需求就是经典的父View优先消费 子View到达边界后父View兜底正好把Pre和Post两个阶段都用上。实现方案有两个方案A用现成的CoordinatorLayout AppBarLayout CollapsingToolbarLayout配置好Behavior几行XML搞定。这个方案当然推荐给生产环境但要注意它背后的信息量巨大出问题很难查。方案B自己实现一个NestedScrollingParent容器手动控制头部高度。代码量多一些但能把整套机制看得通透。下面的实战走方案B代码用Java写方便直接对照接口。4.2 自定义NestedHeaderLayout的骨架先写布局结构。NestedHeaderLayout继承FrameLayout内部有一个header区域和一个RecyclerView。RecyclerView作为Child实现NestedScrollingChild正好拿来测试。com.example.widget.NestedHeaderLayout android:layout_widthmatch_parent android:layout_heightmatch_parent com.example.widget.CollapsingHeader android:idid/header android:layout_widthmatch_parent android:layout_height200dp android:backgroundcolor/primary / androidx.recyclerview.widget.RecyclerView android:idid/recycler android:layout_widthmatch_parent android:layout_heightmatch_parent android:layout_marginTop200dp / /com.example.widget.NestedHeaderLayout注意RecyclerView的layout_marginTop设成了200dp让它的初始位置从头部底部开始。头部高度变化时需要同步调整这个margin或者直接在onLayout里重新计算子View的位置。容器自身实现NestedScrollingParent内部用一个NestedScrollingParentHelper管理状态public class NestedHeaderLayout extends FrameLayout implements NestedScrollingParent { private static final int HEADER_EXPANDED dp2px(200); private static final int HEADER_COLLAPSED dp2px(80); private View header; private RecyclerView recyclerView; private NestedScrollingParentHelper helper; private int currentHeaderHeight HEADER_EXPANDED; public NestedHeaderLayout(Context context, AttributeSet attrs) { super(context, attrs); helper new NestedScrollingParentHelper(this); } Override protected void onFinishInflate() { super.onFinishInflate(); header findViewById(R.id.header); recyclerView findViewById(R.id.recycler); } Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); // 让header和recycler都按当前头部高度参与测量 ViewGroup.LayoutParams headerLp header.getLayoutParams(); headerLp.height currentHeaderHeight; header.setLayoutParams(headerLp); ViewGroup.LayoutParams recyclerLp recyclerView.getLayoutParams(); ((ViewGroup.MarginLayoutParams) recyclerLp).topMargin currentHeaderHeight; recyclerView.setLayoutParams(recyclerLp); } }后面的小节都在这个类里补。为了不占篇幅先默认你已经处理了dp2px之类的工具方法。4.3 onNestedPreScroll头部先折叠的逻辑先实现建立协作关系的两个方法Override public boolean onStartNestedScroll(View child, View target, int axes) { return (axes ViewCompat.SCROLL_AXIS_VERTICAL) ! 0 target recyclerView; } Override public void onNestedScrollAccepted(View child, View target, int axes) { helper.onNestedScrollAccepted(child, target, axes); }关键是onNestedPreScroll。这里要处理两种情况下滑dy 0只要头部还没完全收起就优先把距离给头部。头部的折叠速度可以做成跟手指1:1也可以加个阻尼系数比如0.5看产品需求。这里用1:1。上滑dy 0只有当RecyclerView已经滚到顶部时才开始展开头部。判断条件用recyclerView.canScrollVertically(-1)它返回false代表列表已经不能向下滚动了也就是处于顶部。Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { if (dy 0) { // 向下滑优先折叠头部 if (currentHeaderHeight HEADER_COLLAPSED) { int delta Math.min(dy, currentHeaderHeight - HEADER_COLLAPSED); currentHeaderHeight - delta; consumed[1] delta; requestLayout(); } } else if (dy 0) { // 向上滑列表到顶之后头部再展开 if (currentHeaderHeight HEADER_EXPANDED !recyclerView.canScrollVertically(-1)) { int delta Math.min(-dy, HEADER_EXPANDED - currentHeaderHeight); currentHeaderHeight delta; consumed[1] -delta; requestLayout(); } } }这一段写完之后前两个交互规则已经生效下滑头部先收上滑列表先滚。但这里有个细节要小心consumed[1]必须赋值成实际消费掉的dy。onNestedPreScroll里的dy如果为10currentHeaderHeight只差5就到了折叠高度consumed[1]只能填5不然RecyclerView会凭空少滚5px手感就是列表被莫名吃了距离。4.4 onNestedScroll溢出距离的兜底头部已经折叠到最小值时用户继续下滑此刻的滚动距离已经被RecyclerView自己消费了。问题来了RecyclerView滚到顶部之后继续下滑的dy怎么办如果没有人接管这一段delta就丢了用户会感觉列表被粘住。这种情况就到onNestedScroll的用武之地。RecyclerView把剩余距离通过dispatchNestedScroll传上来我们在onNestedScroll里判断dyUnconsumed是不是大于0如果是就手动滚动外层容器或者把整个页面往上推Override public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { // 列表滚到底后剩余距离交给父容器滚动内容 if (dyUnconsumed 0) { int remain dyUnconsumed; // 这里可以滚外层的ScrollView或者让当前页面整体移动 scrollBy(0, remain); } }需要注意这里的scrollBy是让NestedHeaderLayout整体内容发生滚动并不是改变头部高度。实际项目中这个剩余距离通常被CoordinatorLayout用来做整体页面位移。关于偏移量的一个经验是如果你发现子View位置出现抖动优先检查onNestedScroll里有没有正确处理offsetInWindow相关的修正。还有一个很容易忽略的点onNestedScroll方法列表里的dxConsumed和dyConsumed是target自身消费掉的距离不是剩余距离。老版本接口里第3、4个参数名字容易使人误判写代码时一定看准参数名。4.5 惯性滑动的联动处理如果只实现Pre和Post滚动真机测试很快会发现一个怪象手指慢慢滑时头部折叠很顺畅一旦快速甩一下松手列表自己飞出去头却停在原地不动或者头部瞬间弹回。原因就是fling没有接入。RecyclerView在惯性阶段产生的每一帧位移依然会走dispatchNestedPreScroll和dispatchNestedScroll的标准通道但它能不能启动fling取决于onNestedPreFling。默认情况下Parent接口的默认实现对这个方法是走Component回调的如果我们不处理RecyclerView会正常fling但头部的联动动画不会跟上。处理方案是接管PreFling让头部和列表的速度保持一致Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { if (velocityY 0) { // 快速下滑先把头部收完的动画跑一次 startHeaderCollapseAnimation(); return true; // 父View接管这次fling } return false; // 上滑的fling让RecyclerView自己跑 } Override public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { return true; }这里有个设计取舍如果return true表示父View全权接管flingRecyclerView就不自己滚了。你可以利用这个机制在头部还没收起时让整个惯性动画完全由头部完成等头部到位再手动触发剩下的距离。在写的时候遇到头部动画完成后列表要接着滚这种连续衔接需求可以用Child的fling接口再次发起一次或者用ValueAnimator把速度按比例分配给两个容器。4.6 为什么不建议生产环境直接手写这段代码跑通之后能感受到NestedScrolling的完整协作流程但我要泼一盆冷水生产环境里除非页面极其简单且性能要求极端否则不建议自己维护NestedScrollingParent。原因有三条。第一边界条件太多。上面例子只处理了垂直方向横向、双指缩放、碰撞检测、item移除动画、嵌套多级滚动这些场景一套手写代码很难覆盖完全。CoordinatoryLayout用一个Behavior模型把所有滚动策略收口了你只需要关注自己在哪个阶段做什么。第二可维护性问题。手写的NestedScrollingParent相当于自己实现了一套微型Behavior代码量看着不大但半年后回来改需求时新同事看到onNestedPreScroll和onNestedScroll里塞满判断很容易改崩。换成CoordinatorLayout AppBarLayout CollapsingToolbarLayout行为逻辑外置到LayoutParams的Behavior里结构清晰得多。第三帮大家建立个认知手写这套练习最大的价值是排查Bug。真遇到CoordinatorLayout的联动异常你得能判断出它是在哪个回调里出了问题是onNestedPreScroll没有拿到预期dy还是onNestedScroll的unconsumed没传上来。底层机制通了上层配置问题才能一眼定位。5. 真机踩坑与调优经验嵌套滚动最容易翻车的几个场景5.1 consumed数组复用导致的漂移这个坑我在社区里见过好几次自己也踩过一次。场景是自定义Child时为了省内存复用了同一个consumed数组int[] consumed new int[2]; // move事件里循环调用 recyclerView.dispatchNestedPreScroll(dx, dy, consumed, null); // 下一个move又直接传同一个consumed问题在于dispatchNestedPreScroll内部不会清空consumed如果方法里没有每次清零上一次move的消费量会保留下来导致Parent以为本次已经消费了之前那么多距离。最终表现就是滚动手感忽快忽慢甚至往下拉的时候列表根本不动。规范做法是每次调用前都new一个新数组或者手动Arrays.fill(consumed, 0)。虽然垃圾回收压力略有增加但滚动性能几乎无感正确性优先。5.2 漏掉onNestedScrollAccepted和onStopNestedScroll很多人写完onStartNestedScroll就直接进入业务逻辑这两个方法不重写以为协作关系不成立。实际上onNestedScrollAccepted是Parent确认参与协作后做初始化的合适位置比如记录是否已经消费过某个方向的滚动、重置头部动画状态。漏掉它虽然不一定崩但会丢失状态。onStopNestedScroll更关键。惯性滚动可能在子View滚动到边界后仍在继续如果Parent不在onStopNestedScroll里停掉自己的动画会出现手指松了头部还在自己抖的现象。有一个通用技巧任何你在onStartNestedScroll里启动的动画或监听都应该在onStopNestedScroll里有一个对称的停止逻辑。生命周期不对称是这类Bug最常见的根因。5.3 嵌套层级深了以后offsetInWindow传给谁项目里如果嵌套滚动层级超过两层比如外层CoordinatorLayout里又塞了一个自定义NestedScrollingParent容器容器里再放RecyclerViewoffsetInWindow的传递就特别容易出错。这种情况下自定义容器的onNestedPreScroll里如果消费了距离要主动调用View#offsetTopAndBottom来修正子View的位置而不是只改layoutParams。因为只有真正修改了子View在窗口中的偏移量后续的dispatchNestedScroll的offsetInWindow计算才能对齐。你可以在Chrome DevTools风格的Layout Inspector里观察或者用Log打印event.getRawY和事件里的坐标一旦发现手指下按位置跟item高亮位置偏差超过一个手掌基本就是offset没传。对大部分应用来说解决这个坑的最好办法是不要自己实现多层自定义嵌套容器尽量复用CoordinatorLayout。它内部对手势偏移的处理是经过千锤百炼的。5.4 RecyclerView嵌套进NestedScrollView的卡顿这可能是被问得最多的经典问题。RecyclerView放进NestedScrollView之后列表明显掉帧尤其是图片列表一滑就卡成PPT。网上的解法林林总总有让RecyclerView设置nestedScrollingEnabledfalse的有让RecyclerView高度wrap_content的。先说结论把RecyclerView的nestedScrollingEnabled设成false是标准解法。背后的原理是当RecyclerView作为NestedScrollView的Child时两者都有滚动能力。NestedScrollView会在onNestedPreScroll阶段把大部分滚动距离先消费掉RecyclerView只能拿到少部分距离于是它每次都只滚一点点还没机会触发自身的回收复用机制。更糟的是两个滚动容器在Pre和Post两个阶段来回协商每帧都要做多次回调开销成倍增加。关掉nestedScrollingEnabled后RecyclerView变成了一个不参与嵌套协作的普通子View所有滚动都由外层NestedScrollView接管。列表依然复用View只是滚动计算简单了性能自然上去。要注意这样设置之后RecyclerView的fling动画不会自己触发惯性完全由外层容器驱动。如果你遇到的是关掉之后惯性没了的问题那大概率是因为外层容器不是NestedScrollView而是普通ScrollView。普通ScrollView不支持嵌套滚动协议自然也没有fling联动这种场景还是老老实实换NestedScrollView。5.5 调试手段日志、LayoutInspector、systraceNestedScrolling的排查难在方法调用链太散不知道哪一环出了问题。我常用的调试手段有三种按成本从低到高排列。第一是插日志。在onStartNestedScroll、onNestedPreScroll、onNestedScroll、onStopNestedScroll、onNestedPreFling里各打一条Log带着dy和consumed的值跑一遍页面就能看到完整的调用链。这一步能快速判断是Parent没接收到回调还是收到了但消费距离不对。第二是Layout Inspector。Android Studio自带的Layout Inspector能看到每个View的布局参数配合动态修改头部高度直观观察是头部高度没变化还是margin没跟上。第三是systrace。如果怀疑性能问题抓一段滚动过程的systrace能看到每一帧里RecyclerView和NestedScrollView各占了多少时间。之前碰到一个慢滑流畅、快滑掉帧的问题systrace里显示快速滑动时RecyclerView的onLayout占了20多毫秒定位到是item布局里有大量重复计算跟NestedScrolling本身没多少关系。还有一个亲手写代码时很好用的技巧重写容器的dispatchTouchEvent在里头打印每个事件的坐标和action。有些诡异的触摸坐标问题从这里能看到比scroll回调更原始的信息。5.6 新旧接口差异NestedScrollingChild2/3与ViewCompat最后提醒一个版本兼容问题。NestedScrollingChild和NestedScrollingParent是API 21引入的NestedScrollingChild2和NestedScrollingParent2在API 26加入主要增加了type参数区分触摸滚动和非触摸滚动。NestedScrollingChild3在API 28加入把dispatchNestedScroll方法的参数改成更完整的版本新增对轴方向的独立判断。好消息是大部分场景不需要直接操作这些新方法。RecyclerView内部已经实现了Child3接口NestedScrollView实现了Parent2你再往上加一层自己的Parent时大部分回调签名还是老一套。但如果你的自定义Parent需要区分当前这帧位移是手指拖的还是惯性动画产生的就需要重写带type参数的onNestedScroll并判断type ViewCompat.TYPE_NON_TOUCH这在做惯性时头部阻尼不同这种交互时特别有用。当你写的是通用库代码需要在低版本上也能跑时推荐优先使用ViewCompat里的静态方法比如ViewCompat.startNestedScroll(View, int)和ViewCompat.dispatchNestedPreScroll(View, int, int, int[], int[])它内部会处理不同版本间的接口适配不用自己写版本分支。嵌套滚动这套机制写到这里核心的调用链和实战示例都过了一遍。我在实际项目里的体会是NestedScrolling真正让人豁然开朗的时刻不是它帮你实现了某个炫酷交互而是某天AppBarLayout联动出了诡异Bug你知道该去查Behavior的onNestedPreScroll返回了什么而不是在那里到处删布局代码碰运气。如果还想往下深入建议自己再做一个横向嵌套滚动的例子把双轴都过一遍对这套机制的理解就能真正形成闭环。
返回列表