Open huanzhiyazi opened 4 years ago
目录
RecyclerView(后简称 RV)是如何判断里面的 items 发生了变化的?又是如何知道发生了哪些变化?是删除了一项,还是增加了一项,还是移动了一项?是在哪个位置删除,哪个位置增加,从哪里移动到哪里呢?
首先,需要明确的是,item 的变化有两种:
我们通常在调用 RV.Adapter.notifyDataSetChanged() 的时候,RV 不会区分是内容变化还是结构变化,而会粗暴地当做两者都发生了变化,从而全部 item 都进行重新绑定和布局,所以性能是最低的。
所以 RV.Adapter 还提供了各种精细化的变化控制以避免不必要的重绘,包括:
这些方法可以通过手动和自动两种方式进行调用:
本篇我们就讲自动调用(或自动化差异分析)的原理,且重点分析结构变化。
首先,在结构变化中,移动操作实际上可以转化为一对删除和插入操作:从 i 移动到 j 等价于将 i 处删除的 item 再插入到位置 j。
其次,我们可以把数据列表当做一个字符串,把列表项当成字符串中的字符,于是 RV 的结构变化问题就转化为求解字符串差异问题:从字符串 A 变成字符串 B 需要经过哪些编辑操作。我们可以自义定列表项之间的等价函数 areItemsTheSame(oldItem, newItem),这对应于字符串差异问题中两个字符是否相等的判断。
areItemsTheSame(oldItem, newItem)
求解字符串差异问题,在时间和空间复杂度上同时具有明显优势的算法,目前来说就是 Myers差异算法,该算法的时间复杂度为 O(ND),优于传统的 O(N²) 算法,在差异度比较小的情况下也不输 O(NlogN) 的改进型算法;更重要的是,其空间复杂度可以优化到线性复杂度的规模,这对于移动设备来讲具有明显的优势。
接下来的内容需要先了解一下 Myers差异算法 的原理,因为接下里的一些新的概念和基础都来自于其算法原理的分析。
RV 的自动化差异分析的入口在 ListAdapter 中,它是 RV.Adapter 的一个子类,如果需要进行自动化差异分析,就可以使用这个适配接口。而核心的差异分析算法实现在 DiffUtil 这个工具类。ListAdapter 实现自动化差异分析的结构图如下:
从图中可以看出,总体上来看,差异分析过程是一个计算耗时型任务,所以将其作为一个异步任务放入线程池中,待 DiffUtil 完成差异分析之后,向主线程返回一个差异分析结果——DiffResult,然后通过分析差异结果将差异变化通知给 ListAdapter,RV 观察到 ListAdapter 中的差异变化之后对视图进行重新布局。
Myers 差异算法的实现在 DiffUtil.calculateDiff() 方法中,采用了 Myers 差异算法的空间优化版本(分治策略)实现:
相关的概念请参考 Myers差异算法。
DiffUtil.calculateDiff() 方法如下,可与 Myers差异算法 中的伪代码实现对照:
public static DiffResult calculateDiff(@NonNull Callback cb, boolean detectMoves) { final int oldSize = cb.getOldListSize(); final int newSize = cb.getNewListSize(); final List<Snake> snakes = new ArrayList<>(); // 该列表用于保存子问题,方便进行广度优先遍历求解子问题,避免用递归导致栈溢出 final List<Range> stack = new ArrayList<>(); // 初始化为原问题 stack.add(new Range(0, oldSize, 0, newSize)); // 正方向:因为 D∈[0,N+M],以 k=0 为对称轴,所以 k∈{-(N+M),-(N+M)+2,...,N+M]}, // 反方向:以 k=∆=N-M为对称轴,所以 k∈{-(N+M)+∆,-(N+M)+∆+2,...,N+M+∆}, // 而数组 V 以 k 为索引,所以给 forwardV 和 backwardV 都分配 2*(N+M+∆) 个空间 final int max = oldSize + newSize + Math.abs(oldSize - newSize); final int[] forward = new int[max * 2]; final int[] backward = new int[max * 2]; // 子问题 range 缓存池 final List<Range> rangePool = new ArrayList<>(); while (!stack.isEmpty()) { // 广度优先遍历每个子问题实现分治策略 final Range range = stack.remove(stack.size() - 1); // 采用贪心策略求解每个子问题的 middle snake final Snake snake = diffPartial(cb, range.oldListStart, range.oldListEnd, range.newListStart, range.newListEnd, forward, backward, max); if (snake != null) { if (snake.size > 0) { // 保存有效 middle snake snakes.add(snake); } // 将 middle snake 的坐标转换为编辑图中的全局坐标 snake.x += range.oldListStart; snake.y += range.newListStart; // 计算左上角子问题 ⌈D/2⌉-path 的边界——右下角坐标 final Range left = rangePool.isEmpty() ? new Range() : rangePool.remove( rangePool.size() - 1); left.oldListStart = range.oldListStart; left.newListStart = range.newListStart; if (snake.reverse) { // middle snake 取自右下角 left.oldListEnd = snake.x; left.newListEnd = snake.y; } else { // middle snake 取自左上角 if (snake.removal) { left.oldListEnd = snake.x - 1; left.newListEnd = snake.y; } else { left.oldListEnd = snake.x; left.newListEnd = snake.y - 1; } } stack.add(left); // 计算右下角子问题 ⌊D/2⌋-path 的边界——左上角坐标 final Range right = range; if (snake.reverse) { // middle snake 取自右下角 if (snake.removal) { right.oldListStart = snake.x + snake.size + 1; right.newListStart = snake.y + snake.size; } else { right.oldListStart = snake.x + snake.size; right.newListStart = snake.y + snake.size + 1; } } else { // middle snake 取自左上角 right.oldListStart = snake.x + snake.size; right.newListStart = snake.y + snake.size; } stack.add(right); } else { // 没有 middle snake 的子问题是一个空问题,无需继续求解,其 Range // 可以缓存,避免多次分配空间 rangePool.add(range); } } // 按坐标排序 middle snakes Collections.sort(snakes, SNAKE_COMPARATOR); // 构造 DiffResult 并返回 return new DiffResult(cb, snakes, forward, backward, detectMoves); }
贪心策略求解子问题的 middle snake 的方法 DiffUtil.diffPartial():
private static Snake diffPartial(Callback cb, int startOld, int endOld, int startNew, int endNew, int[] forward, int[] backward, int kOffset) { final int oldSize = endOld - startOld; final int newSize = endNew - startNew; if (endOld - startOld < 1 || endNew - startNew < 1) { return null; } final int delta = oldSize - newSize; // ∆ ← N−M final int dLimit = (oldSize + newSize + 1) / 2; // 初始化 forwardV[1] = 0((0,-1)→(0,0)) Arrays.fill(forward, kOffset - dLimit - 1, kOffset + dLimit + 1, 0); // 初始化 backwardV[N-M-1] = N((N,M+1)→(N,M)) Arrays.fill(backward, kOffset - dLimit - 1 + delta, kOffset + dLimit + 1 + delta, oldSize); final boolean checkInFwd = delta % 2 != 0; for (int d = 0; d <= dLimit; d++) { for (int k = -d; k <= d; k += 2) { // 从左上角求解 D-k-path,依赖的前向子问题为 (D-1)-(k±1)-path int x; final boolean removal; if (k == -d || (k != d && forward[kOffset + k - 1] < forward[kOffset + k + 1])) { x = forward[kOffset + k + 1]; removal = false; } else { x = forward[kOffset + k - 1] + 1; removal = true; } // set y based on x int y = x - k; // move diagonal as long as items match while (x < oldSize && y < newSize && cb.areItemsTheSame(startOld + x, startNew + y)) { x++; y++; } forward[kOffset + k] = x; if (checkInFwd && k >= delta - d + 1 && k <= delta + d - 1) { // 缩小 k 判断范围 if (forward[kOffset + k] >= backward[kOffset + k]) { // 左上角 d-path 与右下角 (d-1)-path 重叠,计算 middle snake Snake outSnake = new Snake(); outSnake.x = backward[kOffset + k]; outSnake.y = outSnake.x - k; outSnake.size = forward[kOffset + k] - backward[kOffset + k]; outSnake.removal = removal; outSnake.reverse = false; return outSnake; } } } for (int k = -d; k <= d; k += 2) { // 从右下角求解 D-k-path,依赖的前向子问题为 (D-1)-(k±1)-path final int backwardK = k + delta; int x; final boolean removal; if (backwardK == d + delta || (backwardK != -d + delta && backward[kOffset + backwardK - 1] < backward[kOffset + backwardK + 1])) { x = backward[kOffset + backwardK - 1]; removal = false; } else { x = backward[kOffset + backwardK + 1] - 1; removal = true; } // set y based on x int y = x - backwardK; // move diagonal as long as items match while (x > 0 && y > 0 && cb.areItemsTheSame(startOld + x - 1, startNew + y - 1)) { x--; y--; } backward[kOffset + backwardK] = x; if (!checkInFwd && k + delta >= -d && k + delta <= d) { // 缩小 k 判断范围 if (forward[kOffset + backwardK] >= backward[kOffset + backwardK]) { // 右下角 d-path 与左上角 d-path 重叠,计算 middle snake Snake outSnake = new Snake(); outSnake.x = backward[kOffset + backwardK]; outSnake.y = outSnake.x - backwardK; outSnake.size = forward[kOffset + backwardK] - backward[kOffset + backwardK]; outSnake.removal = removal; outSnake.reverse = true; return outSnake; } } } } throw new IllegalStateException("DiffUtil hit an unexpected case while trying to calculate" + " the optimal path. Please make sure your data is not changing during the" + " diff calculation."); }
在第 2 节中已经说明,移动操作实际上可以转化为一对删除和插入操作,所以作为一类通用算法, Myers 算法不需要单独处理移动操作。但是在实际应用中,我们可能需要单独检查这种操作,比如在 RV 中,如果确切知道其中一个 item 的变化是从位置 i 移动到位置 j,那就可以据此对该 item 添加位移动画,做出让人舒适的动效,而不是生硬地变化位置,这对于提升用户体验很有帮助。
移动操作有两种,一种是前移(i→i-∆),一种是后移(i→i+∆):
下图是两种移动判断的示意图:
在 DiffUtil.calculateDiff() 返回时,会构造 DiffResult 对象,其构造过程中会根据需要完成移动判断:
DiffResult(Callback callback, List<Snake> snakes, int[] oldItemStatuses, int[] newItemStatuses, boolean detectMoves) { // 省略... // 检查移动和内容变化 findMatchingItems(); }
private void findMatchingItems() { int posOld = mOldListSize; int posNew = mNewListSize; // 由末端往前进行遍历 for (int i = mSnakes.size() - 1; i >= 0; i--) { final Snake snake = mSnakes.get(i); final int endX = snake.x + snake.size; final int endY = snake.y + snake.size; if (mDetectMoves) { // 是否需要检查移动 // 前移检查 while (posOld > endX) { // 检测到 fromDelete 块,遍历之 // 前序遍历每一个 toInsert 块,检查 fromDelete 中的每一个删除元素是否 // 与 toInsert 中的插入元素相等 findAddition(posOld, posNew, i); posOld--; } // 后移检查 while (posNew > endY) { // 检测到 fromInsert 块,遍历之 // 前序遍历每一个 toDelete 块,检查 fromInsert 中的每一个插入元素是否 // 与 toDelete 中的删除元素相等 findRemoval(posOld, posNew, i); posNew--; } } // 检查内容变化,省略... posOld = snake.x; posNew = snake.y; } }
我们再看一下前移检查 findAddition() 的具体实现:
private void findAddition(int x, int y, int snakeIndex) { // 省略... findMatchingItem(x, y, snakeIndex, false); }
private boolean findMatchingItem(final int x, final int y, final int snakeIndex, final boolean removal) { final int myItemPos; //int curX; int curY; if (removal) { // 后移省略... } else { myItemPos = x - 1; // fromInsert 块中的当前删除项 //curX = x - 1; curY = y; // 第一个 toDelete 块 } // 逆序遍历所有 toDelete 块 for (int i = snakeIndex; i >= 0; i--) { final Snake snake = mSnakes.get(i); final int endX = snake.x + snake.size; final int endY = snake.y + snake.size; if (removal) { // 后移省略... } else { // 遍历当前 toDelete 块 for (int pos = curY - 1; pos >= endY; pos--) { // 检查当前 toDelete 中的当前插入项是否等于 fromInsert 中的当前删除项 if (mCallback.areItemsTheSame(myItemPos, pos)) { // 判断为前移,记录状态并返回 final boolean theSame = mCallback.areContentsTheSame(myItemPos, pos); final int changeFlag = theSame ? FLAG_MOVED_NOT_CHANGED : FLAG_MOVED_CHANGED; mOldItemStatuses[x - 1] = (pos << FLAG_OFFSET) | FLAG_IGNORE; mNewItemStatuses[pos] = ((x - 1) << FLAG_OFFSET) | changeFlag; return true; } } } //curX = snake.x; curY = snake.y; // 当前 toDelete 块中检查到没有前移项,检查下一个 toDelete 块 } return false; }
后移检查是前移检查的对称操作,详细代码可阅读 DiffUtil 源码。
不难判断,移动检查需要额外 O(N²) 的时间复杂度,如果在应用中实际无需进行移动渲染操作,建议关闭移动检查开关,避免额外的耗时。
ListAdapter 完成异步差异分析之后,会通知主线程进行差异变化通知,RV 作为 ListAdapter 的观察者将观察到这个变化并进行重绘。
经过前面构造出差异分析结果 DiffResult 之后,通知变化的过程就比较简单了,其主要流程就是检查其中的 snake 序列和移动状态记录。关键代码如下:
public void dispatchUpdatesTo(@NonNull ListUpdateCallback updateCallback) { final BatchingListUpdateCallback batchingCallback; if (updateCallback instanceof BatchingListUpdateCallback) { batchingCallback = (BatchingListUpdateCallback) updateCallback; } else { batchingCallback = new BatchingListUpdateCallback(updateCallback); updateCallback = batchingCallback; } final List<PostponedUpdate> postponedUpdates = new ArrayList<>(); int posOld = mOldListSize; int posNew = mNewListSize; // 逆序遍历每条 snake for (int snakeIndex = mSnakes.size() - 1; snakeIndex >= 0; snakeIndex--) { final Snake snake = mSnakes.get(snakeIndex); final int snakeSize = snake.size; final int endX = snake.x + snakeSize; final int endY = snake.y + snakeSize; if (endX < posOld) { // 检查到删除 // 通知删除变化,如果需要检查移动,也要通知移动变化 dispatchRemovals(postponedUpdates, batchingCallback, endX, posOld - endX, endX); } if (endY < posNew) { // 检查到插入 // 通知插入变化,如果要检查移动,也要通知移动变化 dispatchAdditions(postponedUpdates, batchingCallback, endX, posNew - endY, endY); } // 遍历 snake 是否有内容变化,如有,通知之 for (int i = snakeSize - 1; i >= 0; i--) { if ((mOldItemStatuses[snake.x + i] & FLAG_MASK) == FLAG_CHANGED) { batchingCallback.onChanged(snake.x + i, 1, mCallback.getChangePayload(snake.x + i, snake.y + i)); } } posOld = snake.x; posNew = snake.y; } batchingCallback.dispatchLastEvent(); }
dispatchRemovals() 和 dispatchAdditions() 的主要任务是检查移动状态并通知移动变化,代码略。
目录
1 解决的问题[Top]
RecyclerView(后简称 RV)是如何判断里面的 items 发生了变化的?又是如何知道发生了哪些变化?是删除了一项,还是增加了一项,还是移动了一项?是在哪个位置删除,哪个位置增加,从哪里移动到哪里呢?
首先,需要明确的是,item 的变化有两种:
我们通常在调用 RV.Adapter.notifyDataSetChanged() 的时候,RV 不会区分是内容变化还是结构变化,而会粗暴地当做两者都发生了变化,从而全部 item 都进行重新绑定和布局,所以性能是最低的。
所以 RV.Adapter 还提供了各种精细化的变化控制以避免不必要的重绘,包括:
这些方法可以通过手动和自动两种方式进行调用:
本篇我们就讲自动调用(或自动化差异分析)的原理,且重点分析结构变化。
2 问题抽象——Myers差异算法[Top]
首先,在结构变化中,移动操作实际上可以转化为一对删除和插入操作:从 i 移动到 j 等价于将 i 处删除的 item 再插入到位置 j。
其次,我们可以把数据列表当做一个字符串,把列表项当成字符串中的字符,于是 RV 的结构变化问题就转化为求解字符串差异问题:从字符串 A 变成字符串 B 需要经过哪些编辑操作。我们可以自义定列表项之间的等价函数
areItemsTheSame(oldItem, newItem)
,这对应于字符串差异问题中两个字符是否相等的判断。求解字符串差异问题,在时间和空间复杂度上同时具有明显优势的算法,目前来说就是 Myers差异算法,该算法的时间复杂度为 O(ND),优于传统的 O(N²) 算法,在差异度比较小的情况下也不输 O(NlogN) 的改进型算法;更重要的是,其空间复杂度可以优化到线性复杂度的规模,这对于移动设备来讲具有明显的优势。
接下来的内容需要先了解一下 Myers差异算法 的原理,因为接下里的一些新的概念和基础都来自于其算法原理的分析。
3 DiffUtil——Myers差异算法的核心实现和扩展[Top]
RV 的自动化差异分析的入口在 ListAdapter 中,它是 RV.Adapter 的一个子类,如果需要进行自动化差异分析,就可以使用这个适配接口。而核心的差异分析算法实现在 DiffUtil 这个工具类。ListAdapter 实现自动化差异分析的结构图如下:
从图中可以看出,总体上来看,差异分析过程是一个计算耗时型任务,所以将其作为一个异步任务放入线程池中,待 DiffUtil 完成差异分析之后,向主线程返回一个差异分析结果——DiffResult,然后通过分析差异结果将差异变化通知给 ListAdapter,RV 观察到 ListAdapter 中的差异变化之后对视图进行重新布局。
3.1 实现分析
Myers 差异算法的实现在 DiffUtil.calculateDiff() 方法中,采用了 Myers 差异算法的空间优化版本(分治策略)实现:
相关的概念请参考 Myers差异算法。
DiffUtil.calculateDiff() 方法如下,可与 Myers差异算法 中的伪代码实现对照:
贪心策略求解子问题的 middle snake 的方法 DiffUtil.diffPartial():
3.2 差异分析扩展——移动判断
在第 2 节中已经说明,移动操作实际上可以转化为一对删除和插入操作,所以作为一类通用算法, Myers 算法不需要单独处理移动操作。但是在实际应用中,我们可能需要单独检查这种操作,比如在 RV 中,如果确切知道其中一个 item 的变化是从位置 i 移动到位置 j,那就可以据此对该 item 添加位移动画,做出让人舒适的动效,而不是生硬地变化位置,这对于提升用户体验很有帮助。
移动操作有两种,一种是前移(i→i-∆),一种是后移(i→i+∆):
下图是两种移动判断的示意图:
在 DiffUtil.calculateDiff() 返回时,会构造 DiffResult 对象,其构造过程中会根据需要完成移动判断:
我们再看一下前移检查 findAddition() 的具体实现:
后移检查是前移检查的对称操作,详细代码可阅读 DiffUtil 源码。
不难判断,移动检查需要额外 O(N²) 的时间复杂度,如果在应用中实际无需进行移动渲染操作,建议关闭移动检查开关,避免额外的耗时。
3.3 通知变化
ListAdapter 完成异步差异分析之后,会通知主线程进行差异变化通知,RV 作为 ListAdapter 的观察者将观察到这个变化并进行重绘。
经过前面构造出差异分析结果 DiffResult 之后,通知变化的过程就比较简单了,其主要流程就是检查其中的 snake 序列和移动状态记录。关键代码如下:
dispatchRemovals() 和 dispatchAdditions() 的主要任务是检查移动状态并通知移动变化,代码略。