把章节变成可单步观察的过程
先说结论
虚拟列表只创建“可见数量 + 缓冲数量”的节点,滚动时改变节点绑定的数据索引和位置,而不是为每条数据创建一个 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 的节点上。
一种常用机制是每次绑定前:
- 取消并释放旧令牌;
- 创建新令牌;
- 异步请求持有新令牌;
- 完成时确认令牌未取消,且节点的 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. 数据变化与滚动更新
适配器的数据变化事件触发:
layout.Rebuild(itemCount);- 更新 ContentLength;
- 按当前 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 节点量。
节点只覆盖可见窗口和少量缓冲。
滚动改变节点位置与数据索引,不反复创建对象。
每次改绑都要使旧异步结果和旧事件订阅失效。