DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 11
STEP 01 / CODE / LOGIC

先说结论

虚拟列表只创建“可见数量 + 缓冲数量”的节点,滚动时改变节点绑定的数据索引和位置,而不是为每条数据创建一个 UI 对象。节点数量与总数据量解耦。

总数据:10,000 条
视口可见:8 条
上下缓冲:共 4 条
实际节点:12 个
滚动到 firstVisibleIndex=500
节点绑定范围:约 498 到 509

先说结论

虚拟列表只创建“可见数量 + 缓冲数量”的节点,滚动时改变节点绑定的数据索引和位置,而不是为每条数据创建一个 UI 对象。节点数量与总数据量解耦。

10,000 条数据只用 12 个节点

总数据:10,000 条
视口可见:8 条
上下缓冲:共 4 条
实际节点:12 个
滚动到 firstVisibleIndex=500
节点绑定范围:约 498 到 509

继续滚动到 501 时,最上方节点可以移动到底部并改绑新数据。若旧节点正在异步加载头像,改绑前必须取消或验证数据版本,否则第 498 条的头像可能晚到并覆盖第 510 条。

普通列表常按数据量创建节点。数据从几十条增长到几千条时,节点创建、布局、绑定和渲染都会随总量增长。虚拟列表换一个问题:

> 当前视口真正能看到几条?只创建这些节点,再让它们不断扮演不同的数据行。

本文的所有数量均为教学场景参数。节点复用关注“固定槽位改绑哪条数据”;若要进一步理解节点创建与回收策略,可配合阅读对象池

1. 三个必须分开的量

  • itemCount:数据总量,例如 10,000。
  • visibleCapacity:视口需要的节点槽位数,例如可见 6 行,再加 2 个缓冲槽位,共 8。
  • firstVisibleIndex:当前滚动位置对应的第一条可见数据索引。

节点数接近可视容量,而不是数据总量:

普通列表节点数 = itemCount
虚拟列表节点数 ≈ ceil(viewport / itemStep) + buffer

2. 初始化只创建固定槽位

布局根据视口大小计算容量。列表一次创建固定数量的节点,保存 slots[] 与对应的矩形节点。之后滚动不再创建新节点。

capacity = layout.GetVisibleCapacity(viewport.Size);

for (var slot = 0; slot < capacity; slot++)
    slots.Add(factory.CreateNode());

数据变化时,布局重建所有数据项的 offset/size 索引,并把总长度交给滚动器。滚动器的 Content 很长,但其中真实存在的节点很少。

3. 找到第一个可见索引

固定高度网格可以直接用除法:

firstRow = floor(scrollPosition / rowStep)
firstIndex = firstRow × columns

可变高度线性列表预先保存每项的 offset 与 size,再用二分查找第一个 bottom >= scrollPosition 的条目,复杂度为 O(log N)

快速跳转必须先把目标位置钳制到 [0, contentLength - viewportSize]。目标索引也要钳制到 [0, itemCount - 1]

4. 槽位到数据的映射

每次刷新都从 firstVisibleIndex 开始重新绑定:

for (var slot = 0; slot < slots.Count; slot++) {
    var dataIndex = firstVisibleIndex + slot;

    if (dataIndex >= itemCount) {
        CancelAsync(slots[slot]);
        slots[slot].Visible = false;
        continue;
    }

    slots[slot].Visible = true;
    slots[slot].SetDataIndex(dataIndex);
    RenewAsyncToken(slots[slot]);
    Bind(slots[slot], adapter.GetItem(dataIndex));
}

节点身份不变,数据身份变化。例如 slot 0 可以先显示数据 0,滚动后显示 37。调试时应同时展示 slotId 与 dataIndex,否则很容易把节点复用误解成数据对象复用。

5. 异步内容为什么必须取消

节点绑定数据 12 后开始加载图片;用户快速滚动,节点立刻改绑数据 80。若数据 12 的旧请求稍后完成,它可能把旧图片写到正在显示数据 80 的节点上。

一种常用机制是每次绑定前:

  1. 取消并释放旧令牌;
  2. 创建新令牌;
  3. 异步请求持有新令牌;
  4. 完成时确认令牌未取消,且节点的 dataIndex 仍匹配。
slot.RequestToken?.Cancel();
slot.RequestToken?.Dispose();
slot.RequestToken = new CancellationTokenSource();

var expectedIndex = slot.DataIndex;
var image = await LoadImage(data.ImageKey, slot.RequestToken.Token);

if (slot.DataIndex == expectedIndex)
    slot.SetImage(image);

只有“生成令牌”还不够,异步 Item 的实现必须把令牌传给请求并在完成前检查。没有消费点,就不能宣称旧结果一定被挡住。

6. 数据变化与滚动更新

适配器的数据变化事件触发:

  1. layout.Rebuild(itemCount)
  2. 更新 ContentLength;
  3. 按当前 ScrollPosition 刷新槽位。

滚动事件只刷新槽位映射。动画跳转在驱动滚动位置时持续发出变化事件,因此同一套 Refresh 逻辑可以处理拖拽、惯性和程序化跳转。

7. Dispose

完整清理至少包括:

  • 取消滚动监听;
  • 取消数据变化监听;
  • 取消并释放每个槽位尚未完成的异步请求;
  • 销毁固定节点;
  • 清空 adapter、layout、bind 与 interaction 引用;
  • 滚动器停止动画并移除滚动回调。

如果实现只销毁节点却没有显式取消 Item 令牌,那么“销毁会安全阻止旧异步结果”仍是未解决项,不能依赖猜测。

8. 复杂度与代价

假设总数据量为 N,可视槽位为 V:

  • 节点内存:普通列表 O(N),虚拟列表 O(V)
  • 固定高度首项计算:O(1)
  • 可变高度首项查找:预建 offsets 后 O(log N)
  • 一次滚动刷新与绑定:O(V)
  • 可变高度布局重建:O(N)
  • 快速滚动会频繁取消和重启异步内容,适合再配合资源缓存。

虚拟列表没有消除数据量,只把昂贵的节点与渲染成本限制在视口附近。它的代价是节点状态必须完全可重绑,异步结果必须具备版本或取消保护。

9. 容易失效的边界

  • 可视容量依据默认高度估算,而真实条目高度差异极大,缓冲槽位可能不足;
  • 一次大跳转会让所有槽位换绑,旧异步结果必须全部失效;
  • 数据缩短后,超出 Count 的槽位要取消请求并隐藏;
  • 重复滚动事件可能让相同 dataIndex 也更新令牌并重新绑定,需要结合实际性能决定是否增加“索引未变则跳过”的优化;
  • 没有真实初始化调用点、异步 Item 消费点或专项测试时,只能确认机制代码存在,不能确认它已被具体页面正确使用。

最后记住

总数据量不等于 UI 节点量。
节点只覆盖可见窗口和少量缓冲。
滚动改变节点位置与数据索引,不反复创建对象。
每次改绑都要使旧异步结果和旧事件订阅失效。