Flutter ListView 是如何管理 item 的?

3,372 阅读2分钟

在这篇文章 Flutter ListView 是如何滚动的?中引出了这个问题,那么今天就来具体分析下 item 到底是如何复用的。

下边两张动图都是代码写的,突然觉得画图软件没那么好用了。会敲代码,我想画什么就画什么,想怎么画就怎么画!

1. SliverConstraints 说明

说下重点。scrollOffset 为滑动偏移量。

2. 来看看 performLayout 方法吧

performLayout 方法将近300,虽然有很多注释,可是看起来也是十分头疼!逐行往下看,你可以试试,😩😩😩。

2.1 ListView 初始化布局过程

先上图吧,我觉得先上图,比先上代码说明直观易懂。

很简单,那我们结合代码具体分析下这个过程。

首先,我们要先插入,接着布局第一个 child。

class RenderSliverList extends RenderSliverMultiBoxAdaptor {
  ...
  // Make sure we have at least one child to start from.
  if (firstChild == null) {  
    if (!addInitialChild()) {
      ...  // 在判断条件里插入的 firstChild
    }
  }  ...
  assert(earliestUsefulChild == firstChild);
  // Make sure we've laid out at least one child.
  if (leadingChildWithLayout == null) {  
    earliestUsefulChild!.layout(childConstraints, parentUsesSize: true);
    ...
  }
  ...
}

下边这块代码负责插入 child 。代码好长,不想插代码了。。。

吐槽掘金插入代码块的组件,不能识别代码自己换行!!!

bool inLayoutRange = true;
RenderBox? child = earliestUsefulChild;
int index = indexOf(child!);
double endScrollOffset = childScrollOffset(child)! + paintExtentOf(child);
bool advance() { // returns true if we advanced, false if we have no more children  
// This function is used in two different places below, to avoid code duplication.  
  assert(child != null);  
  if (child == trailingChildWithLayout)    
    inLayoutRange = false;  
  child = childAfter(child!);  
  if (child == null)    
    inLayoutRange = false;  
  index += 1;  
  if (!inLayoutRange) {    
    if (child == null || indexOf(child!) != index) {      
      // We are missing a child. Insert it (and lay it out) if possible.      
      child = insertAndLayoutChild(childConstraints,        
        after: trailingChildWithLayout,        
        parentUsesSize: true,      
      );      
      if (child == null) {        
        // We have run out of children.        
        return false;      
      }    
    } else {
      // Lay out the child.      
      child!.layout(childConstraints, parentUsesSize: true);
    }    
    trailingChildWithLayout = child;  
  }  
  assert(child != null);  
  final SliverMultiBoxAdaptorParentData childParentData = child!.parentData as SliverMultiBoxAdaptorParentData;  
  childParentData.layoutOffset = endScrollOffset;  
  assert(childParentData.index == index);  
  endScrollOffset = childScrollOffset(child!)! + paintExtentOf(child!);  
  return true;
}

关键点来了,下面的判断决定了是否继续插入 child。

while (endScrollOffset < targetEndScrollOffset) {  
  if (!advance()) {    
    reachedEnd = true;    
    break;  
  }
}

这里有个问题,cacheExtent 是什么,为什么是 160?

cacheExtent 是视图缓存区,160 是我随便写的。😏😏😏

好吧,cacheExtent 是有系统默认值的。

abstract class RenderAbstractViewport extends RenderObject {
  ...
  static const double defaultCacheExtent = 250.0;
}

250,你开心咯😛😛😛

初始化完了,该滚了。。。

等等,判断条件不说说?不慌,下边更精彩。

2.2 ListView 上滑解析

先来看一张图,一图胜千言。这么好的图,我都不想讲代码了。。。

属性都在图里,用代码简单描述下关系。

fakeScrollOffset;  // 视图滑出屏幕的偏移
// 注意 cacheOrigin 是负数,最小为 -250
// scrollOffset 为视图滑出包括缓冲区在内的偏移,前 250 个像素单位为 0
scrollOffset = fakeScrollOffset + cacheOrigin;  
// 主轴方向视图尺寸,上下缓冲区
fullCacheExtent = mainAxisExtent + 2 * _caculatedCacheExtent;
// 剩余可布局范围
remainCacheExtent = (mainAxisExtent + _caculatedCacheExtent + offset.pixels).clamp(0, fullCacheExtent);
// 可以理解为整个视图的尺寸
targetEndScrollOffset = scrollOffset + remainCacheExtent;
// 注意这里不是线性递增的!最后一个 child 的偏移 + child 的绘制范围
endScrollOffset = childScrollOffset(lastChild) + paintExtentOf(lastChild);

还有谁!(能比我讲的更清楚)

2.3 ListView 上滑,底部如何变化?

不想讲。

2.4 ListView 上滑,头部如何变化?

不想讲。

2.5 ListView 该下滑了

不想讲。

2.6 performLayout 方法简单说明

在“敲”上边那个动图时,我感觉我实现了一遍 performLayout。。。

只能说是简化版。下面具体看看 performLayout 剩余代码吧。

这块代码回收顶部 child。

if (childScrollOffset(firstChild!) == null) {  
  int leadingChildrenWithoutLayoutOffset = 0;  
  while (childScrollOffset(earliestUsefulChild!) == null) {    
    earliestUsefulChild = childAfter(firstChild!);    
    leadingChildrenWithoutLayoutOffset += 1;  
  }  
  // We should be able to destroy children with null layout offset safely, 
  // because they are likely outside of viewport  
  collectGarbage(leadingChildrenWithoutLayoutOffset, 0);  
  assert(firstChild != null);
}

这块代码主要决定下滑过程中是否需要在头部添加 child。以及下滑一些其它复杂情况的处理。

earliestUsefulChild = firstChild;
for (double earliestScrollOffset = childScrollOffset(earliestUsefulChild!)!;   
  earliestScrollOffset > scrollOffset;    
  earliestScrollOffset = childScrollOffset(earliestUsefulChild)!) {  
  // We have to add children before the earliestUsefulChild.  
  earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);  
  if (earliestUsefulChild == null) {    
    final SliverMultiBoxAdaptorParentData childParentData = firstChild!.parentData as SliverMultiBoxAdaptorParentData;    
    childParentData.layoutOffset = 0.0;    
    if (scrollOffset == 0.0) {      
      // insertAndLayoutLeadingChild only lays out the children before      
      // firstChild. In this case, nothing has been laid out. We have      
      // to lay out firstChild manually.      
      firstChild!.layout(childConstraints, parentUsesSize: true);      
      earliestUsefulChild = firstChild;      
      leadingChildWithLayout = earliestUsefulChild;      
      trailingChildWithLayout ??= earliestUsefulChild;      
      break;    
      } else {      
      // We ran out of children before reaching the scroll offset.      
      // We must inform our parent that this sliver cannot fulfill     
      // its contract and that we need a scroll offset correction.      
      geometry = SliverGeometry(        
        scrollOffsetCorrection: -scrollOffset,      
      );      
      return;    
    }  
  }  
  final double firstChildScrollOffset = earliestScrollOffset - paintExtentOf(firstChild!);  
  // firstChildScrollOffset may contain double precision error  
  if (firstChildScrollOffset < -precisionErrorTolerance) {    
    // Let's assume there is no child before the first child. We will    
    // correct it on the next layout if it is not.    
    geometry = SliverGeometry(      
    scrollOffsetCorrection: -firstChildScrollOffset,    
    );    
    final SliverMultiBoxAdaptorParentData childParentData = firstChild!.parentData as SliverMultiBoxAdaptorParentData;   
    childParentData.layoutOffset = 0.0;    
    return;  
  }  
  final SliverMultiBoxAdaptorParentData childParentData = earliestUsefulChild.parentData as SliverMultiBoxAdaptorParentData;  
  childParentData.layoutOffset = firstChildScrollOffset;  
  assert(earliestUsefulChild == firstChild);  
  leadingChildWithLayout = earliestUsefulChild;  
  trailingChildWithLayout ??= earliestUsefulChild;
}

滑动到顶部的一些判断处理。

if (scrollOffset < precisionErrorTolerance) {  
// We iterate from the firstChild in case the leading child has a 0 paint  
// extent.  
  while (indexOf(firstChild!) > 0) {    
    final double earliestScrollOffset = childScrollOffset(firstChild!)!;    
    // We correct one child at a time. If there are more children before    
    // the earliestUsefulChild, we will correct it once the scroll offset    
    // reaches zero again.    
    earliestUsefulChild = insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);    
    assert(earliestUsefulChild != null);    
    final double firstChildScrollOffset = earliestScrollOffset - paintExtentOf(firstChild!);    
    final SliverMultiBoxAdaptorParentData childParentData = firstChild!.parentData as SliverMultiBoxAdaptorParentData;    
    childParentData.layoutOffset = 0.0;    
    // We only need to correct if the leading child actually has a    
    // paint extent.    
    if (firstChildScrollOffset < -precisionErrorTolerance) {      
      geometry = SliverGeometry(        
        scrollOffsetCorrection: -firstChildScrollOffset,      
      );      
      return;    
    }  
  }
}

其余代码基本只剩底部 child 回收,和计算 geometry 了。

3. 最后

不要执着陷入代码细节。知其意,忘其形,无招胜有招。