面试官:RecyclerView布局动画原理了解吗?

语言: CN / TW / HK

前言

「温馨提示:文章有点长,建议关注微信公众号“字节小站”收藏阅读」

本文主要通过以下几个方面来讲解RecyclerView的布局和动画原理:

  1. 布局放置:RecyclerView#dispatchLayout()
  2. 预布局阶段:RecyclerView#dispatchLayoutStep1()
  3. 布局阶段:RecyclerView#dispatchLayoutStep2()
  4. 开启动画阶段:RecyclerView#dispatchLayoutStep3()

背景知识

RecyclerView的Adapter有几个notify相关的方法:

  • notifyDataSetChanged()
  • notifyItemChanged(int)
  • notifyItemInserted(int)
  • notifyItemRemoved(int)
  • notifyItemRangeChanged(int, int)
  • notifyItemRangeInserted(int, int)
  • notifyItemRangeRemoved(int, int)
  • notifyItemMoved(int, int)

notifyDataSetChanged()与其他方法的区别:

  1. 会导致整个列表刷新,其它几个方法则不会;
  2. 不会触发RecyclerView的动画机制,其它几个方法则会触发各种不同类型的动画。

1. 布局放置

1.1 核心方法

RecyclerView#dispatchLayout()

1.2 作用

  1. 将View放置到合适的位置
  2. 记录布局阶段View的信息
  3. 处理动画

RecyclerView的布局我们可以分成三个阶段,也可以精细分成五个阶段。

1.2.1 三个阶段

1.2.1.1 预布局阶段

当需要做动画时,预布局阶段才会工作,否则没有实际意义,它对应dispatchLayoutStep1方法。动画有开始状态和结束状态,预布局完成后的RecyclerView是动画的开始状态。

1.2.1.2 布局阶段

无论是否需要做动画,布局阶段都会工作,它对应dispatchLayoutStep2方法。布局完成后的状态是用户最终看到的状态,也是动画的结束状态。

1.2.1.3 布局后阶段

布局完成后,需要执行动画操作,它对应的是dispatchLayoutStep3方法。当动画完成后,还会进行View回收操作。

1.2.2 五个阶段

1.2.2.1 预布局前

在dispatchLayoutStep1方法调用onLayoutChildren方法之前。它会保存当前RecyclerView上所有子View的信息到ViewInfoStore中,FLAG增加FLAG_PRE。表示View在预布局前就显示在RecyclerView上。

1.2.2.2 预布局中

在dispatchLayoutStep1方法调用onLayoutChildren方法时。它会根据算法,重新布置RecyclerView的子View,该阶段可能会添加新的子View。该阶段能够确定哪些View最终是不会展示给用户看的,FLAG增加FLAG_DISAPPEARED(例如:removed的View)。

1.2.2.3 预布局后

在dispatchLayoutStep1方法调用onLayoutChildren方法之后,将预布局完成后的子View与预布局前的子View对比,将新增的View的FLAG增加FLAG_APPEAR(调用notifyItemRemoved后,新填充的View)。

1.2.2.4 布局中

在dispatchLayoutStep2方法调用onLayoutChildren方法时。该阶段会把被挤出屏幕的View的FLAG增加FLAG_DISAPPEARED。

1.2.2.5 布局后

在dispatchLayoutStep3方法中。会将最终的子View的FLAG增加FLAG_POST。

1.2.3 动画类型

1.2.3.1 PERSISTENT

预布局前和布局后都存在的View所做的动画,位置有可能发生变化了,也有可能没有发生变化。

1.2.3.2 REMOVED

在布局前对用户可见,布局后不可见,而且数据已经从数据源中删除掉了。

1.2.3.3 ADDED

新增数据到数据源中,并且在布局后对用户可见。

1.2.3.4 DISAPPEARING

数据一直都存在于数据源中,但是布局后从可见变成不可见状态(例如因为其它View插入操作,导致被挤出屏幕外了)。

1.2.3.5 APPEARING

数据一直都存在于数据源中,但是布局后从不可见变成可见状态(例如因为其它View被删除,导致补位到屏幕内了)。

1.3 源码解析

1.3.1 RecyclerView#dispatchLayout()

  1. dispatchLayoutStep1()执行预布局,记录ViewHolder位置信息;
  2. dispatchLayoutStep2()执行布局,用户最终看到的效果;
  3. dispatchLayoutStep3()执行动画操作。

2. 预布局阶段

2.1 核心方法

  1. RecyclerView#dispatchLayoutStep1()

  2. RecyclerView#processAdapterUpdatesAndSetAnimationFlags()

  3. LinearLayoutManager#onLayoutChildren()

  4. LinearLayoutManager#updateAnchorInfoForLayout()

2.2 作用

  1. 处理Adapter变化
  2. 决定该执行哪种类型动画
  3. 保存当前RecyclerView上的子View的信息
  4. 如果需要执行动画,进行预布局

2.3 源码解析

2.3.1 RecyclerView#dispatchLayoutStep1()

  1. 判断是否需要开启动画功能
  2. 如果开启动画,将当前屏幕上的Item相关信息保存起来供后续动画使用
  3. 如果开启动画,调用mLayout.onLayoutChildren方法预布局
  4. 预布局后,与第二步保存的信息对比,将新出现的Item信息保存到Appeared中

2.3.2 RecyclerView#processAdapterUpdatesAndSetAnimationFlags()

作用:判断是否需要开启动画

2.3.3 LinearLayoutManager#onLayoutChildren()

以垂直方向的RecyclerView为例子,我们填充RecyclerView的方向有两种,从上往下填充和从下往上填充。开始填充的位置不是固定的,可以从RecyclerView的任意位置处开始填充。

  1. 寻找填充的锚点(最终调用findReferenceChild方法);
  2. 移除屏幕上的Views(最终调用detachAndScrapAttachedViews方法);
  3. 从锚点处从上往下填充(调用fill和layoutChunk方法);
  4. 从锚点处从下往上填充(调用fill和layoutChunk方法);
  5. 如果还有多余的空间,继续填充(调用fill和layoutChunk方法);
  6. 布局完成后有可能产生GAP,需要修复GAP;
  7. dispatchLayoutStep2阶段调用layoutForPredictiveAnimation将scrapList中多余的ViewHolder填充(调用fill和layoutChunk方法)。

2.3.3.1 寻找填充的锚点

  1. 优先返回全部在屏幕内,未标记removed的View;
  2. 次优先级返回不可见的View;
  3. 最低优先级返回删掉的view。

2.3.3.2 移除屏幕上的Views

  1. 调用notifyItemChanged(position),position对应的ViewHolder会放入到mChangedScrap缓存中;
  2. 否则会放入到mAttachedScrap缓存中

2.3.3.3 ~ 2.3.3.5 填充

调用LinearLayoutManager#fill()和LinearLayoutManager#layoutChunk()

  1. 从缓存中获取View或者创建View
  2. 如果是step1预布局阶段,调用addView(),将标记为removed的view放入到DISAPPEARED动画列表中
  3. 如果是step2布局阶段,调用addDisappearingView(),将被挤出屏幕的view放入到DISAPPEARED动画列表中
  4. 如果是removed的或者changed,不会记录消耗的填充量

2.3.3.6 修复GAP

通过mOrientationHelper.offsetChildren(gap)直接填补GAP


2.3.3.7 layoutForPredictiveAnimation

为了做动画,增加额外的Item

  1. 不需要做动画,或者是预布局直接返回
  2. 从mAttachedScrap中遍历到非removed的ViewHolder,但是返回的结果可能包含removed ViewHolder
  3. 如果遍历找到了非Removed ViewHolder,填充View

3. 布局阶段

3.1 核心方法

  1. RecyclerView#dispatchLayoutStep2()
  2. LinearLayoutManager#layoutChunk()
  3. LinearLayoutManager#addDisappearingView()
  4. ViewInfoStore#addToDisappearedInLayout()

3.2 作用

  1. 根据数据源中的数据进行布局,真正展示给用户看的最终界面
  2. 如果开启动画,将被挤出屏幕的View的保存到消失动画列表中

3.3 源码解析

3.3.1 RecyclerView#dispatchLayoutStep2()

  1. 将预布局模式改为false
  2. 布局填充View

3.3.2 LinearLayoutManager#layoutChunk()

布局阶段将被挤出屏幕的View放入到DISAPPEARED动画列表中

3.3.3 LinearLayoutManager#addDisappearingView()

把Removed的View或被挤出屏幕的View添加到Disappearing动画列表

3.3.4 ViewInfoStore#addToDisappearedInLayout()

加入到Disappeared动画列表

4. 触发动画阶段

4.1 核心方法

  1. RecyclerView#dispatchLayoutStep3()
  2. ViewInfoStore#addToPostLayout()
  3. ViewInfoStore#process()
  4. ItemAnimator#animateAppearance()

4.2 作用

  1. 清理工作
  2. 保存布局后的view的信息
  3. 触发动画
  4. 动画执行完回收工作

4.3 源码解析

4.3.1 RecyclerView#dispatchLayoutStep3()

  1. 将当前屏幕上的View信息记录到postLayout动画列表中
  2. 执行动画
  3. 清理操作
  4. 布局完成回调

4.3.2 ViewInfoStore#addToPostLayout()

View信息记录到postLayout动画列表中

4.3.3 ViewInfoStore#process()

作用:执行动画

工作流程,按优先级执行

  1. 调用unuse() 将view回收掉
  2. 执行消失动画
  • 2.1 预布局中不可见调用unuse()
  • 2.2 调用processDisappeared()
  1. 调用processPersistent()执行move或者change动画
  2. 执行remove动画
  3. 执行insert动画

4.3.4 ViewInfoStore$InfoRecord

作用:定义动画类型

  • FLAG_DISAPPEARED:消失动画,包含move和remove动画
  • FLAG_APPEAR:出现动画,包含move和insert动画
  • FLAG_PRE:预布局前已经显示在RecyclerView上
  • FLAG_POST:布局后显示在RecyclerView上
  • FLAG_APPEAR_AND_DISAPPEAR:先做出现动画,再做消失动画,无意义
  • FLAG_PRE_AND_POST:预布局前和布局后一直显示在RecyclerView上
  • FLAG_APPEAR_PRE_AND_POST:在FLAG_PRE_AND_POST基础上做出现动画

4.3.5 ViewInfoStore$ProccessCallback

作用:定义四种处理动画的接口

  • processDisappeared 处理消失动画
  • processAppeared 处理出现动画
  • processPersistent 处理一直存在动画,包含move和change动画
  • unused 不需要处理动画,执行回收

4.3.6 接口实现

4.3.7 ProccessCallback#processAppeared

兵分两路

  1. 调用ItemAnimator#animateAppearance()
  2. 调用RecyclerView#postAnimationRunner()

4.3.8 一路兵:ItemAnimator#animateAppearance()

4.3.8.1 SimpleItemAnimator#animateAppearance
  1. 该方法返回true表示需要做动画
  2. 否则不需要做动画
  3. 如果预布局前View已经存在而且位置发生改变,处理MOVE动画
  4. 否则,处理ADD动画
4.3.8.2 DefaultItemAnimator.animateMove
  1. 该方法并没有真正执行动画
  2. 将MoveInfo保存到mPendingMoves中,以便RecyclerView#postAnimationRunner()使用
  3. 判断是否有必要执行MOVE动画
  4. 回到preLayout的位置
4.3.8.3 DefaultItemAnimator.animateAdd

先调用setAlpha(0),以便做淡入动画


4.3.9 二路兵:RecyclerView#postAnimationRunner()

4.3.9.1 RecyclerView#postAnimationRunner

最终调用到ItemAnimator.runPendingAnimations

4.3.9.2 DefaultItemAnimator.runPendingAnimations
  1. 首先执行Remove动画
  2. 然后同时执行Move和Change动画
  3. 最后执行Add动画

动画的总时长为removeDuration + Math.max(moveDuration, changeDuration) + addDuration

4.3.10 RecyclerView$ItemAnimatorRestoreListener

作用:动画结束后执行回收操作

  1. 动画执行完毕,removeAnimatingView
  2. 调用Recycler.recycleViewHolderInternal执行回收操作

5. 场景篇

5.1 notifyItemRemoved场景

5.1.1 场景描述

  1. 调用notifyItemRemoved()
  2. Adapter数据有100条,屏幕上有Item1~Item6 6个View,删除Item1和Item2

5.1.2 布局过程

  1. 将Item1 Item2对应的ViewHolder设置为REMOVE状态
  2. 将所有的Item对应的ViewHolder的mPreLayoutPosition字段赋值为当前的position

5.1.2.1 dispatchLayoutStep1阶段

  1. 寻找填充的锚点,寻找锚点的逻辑是,从上往下,找到第一个非remove状态的Item。在本Case中,找到Item3

  2. 移除屏幕上的Views,将它们的ViewHolder放入到Recycler的mAttachedScrap缓存中,这个缓存的好处是如果position对应上了,无需重新绑定,直接拿来用。

  3. 从锚点Item3处往下填充,mAttachedScrap只剩下ViewHolder2和ViewHolder1

  4. 从锚点Item3处往上填充Item2 Item1,因为Item2,Imte1已经被remove掉了,它消耗的空间不会被记录,那么到步骤5的时候还可以填充

  5. 还有多余的空间,继续填充,把Item7、Item8填充到屏幕中

  6. 因为当前是预布局,直接返回


5.1.2.2 dispatchLayoutStep2阶段

  1. 寻找填充的锚点,寻找锚点的逻辑是,从上往下,找到第一个非remove状态的Item,找到Item3

  2. 移除屏幕上的Views,将它们的ViewHolder放入到Recycler的mAttachedScrap缓存中

  3. 从锚点Item3处往下填充,填充到Item6为止,就没有足够的距离了,mAttachedScrap只剩下ViewHolder8,ViewHolder7,ViewHolder2,ViewHolder1

  4. 往上填充,虽然此时还有两个View的高度,但是此时,上边没有数据了,此处不填充

  5. 此时还有两个View的高度,继续往下填充

  6. 修复GAP

  1. 当前是布局阶段,但是因为ViewHolder1和ViewHolder2都是被Remove掉的,所以跳过

5.1.2.3 dispatchLayoutStep3阶段

  1. Item1、Item2做消失动画
  2. Item3、Item4~Item8做移动动画
  3. 动画结束后,Item1、Item2会被回收到mCachedViews缓存池中

5.2 notifyItemInserted场景

5.2.1 场景描述

假设在Item1下面插入两条数据AddItem1,AddItem2

5.2.2 布局过程

5.2.2.1 dispatchLayoutStep1阶段

  1. 寻找锚点,找到Item1
  2. 移除屏幕上的Views,放入到mAttachedScrap中
  3. 锚点处从上往下填充
  4. 锚点处从下往上填充,由上图可知,上面没有空间了,不填充
  5. 判断是否还有剩余的空间,如果有在末尾填充,下面没空间了,不填充
  6. 因为当前是预布局阶段,不填充

5.2.2.2 dispatchLayoutStep2阶段

  1. 寻找锚点,找到Item1
  2. 移除屏幕上的Views,放入到mAttachedScrap中
  3. 锚点处从上往下填充,此时将变化后的数据填充到屏幕上,addItem1和addItem2被填充到item1下面
  4. 锚点处从下往上填充,由图可知,没有空间不填充
  5. 判断是否还有剩余的空间,由图可知,没有空间不填充
  6. 当前是layoutStep2阶段,会将mAttachScrap的内容,填充到屏幕末尾,ViewHolder5和ViewHolder6对应的ItemView被填充

5.2.2.3 dispatchLayoutStep3阶段

  1. Item2、Item3~Item6做移动动画
  2. addItem1、addItem2做淡入动画
  3. 动画结束后Item5、Item6被回收到mCachedViews缓存池中

5.3 场景总结

5.3.1 notifyItemRemoved场景

删除场景

5.3.2 notifyItemInserted场景

增加场景

本文分享自微信公众号 - 音视频开发进阶(glumes_blog)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。