
1. 项目概述为什么我们需要一个无限循环的 ListView在 Unity 的 UI 开发里但凡做过列表功能比如排行榜、背包、聊天记录都绕不开一个核心问题数据量大了怎么办直接实例化几百上千个 UI 元素Cell性能立刻就会崩掉尤其是在移动端卡顿、内存飙升是家常便饭。所以我们引入了“复用”机制只创建一屏能显示的 Cell 数量随着滚动把移出屏幕的 Cell 拿回来重新填充数据后放到即将进入屏幕的位置。这解决了性能问题但带来了新的体验问题滚动到列表尽头时会有一个“撞墙”般的停顿感无法丝滑地继续浏览。“首尾无限循环的 ListView”要解决的正是这个体验痛点。想象一个音乐播放器的循环播放列表或者一个可以无限滚动的商品橱窗用户希望滚动到末尾时能无缝地衔接回开头形成一个闭合的、无休止的浏览环。这不仅仅是“复用”更是“复用”逻辑的极致运用。它要求我们在处理 Cell 的位置、索引和数据绑定时引入一个“虚拟循环”的概念。我接手过不少需要这种效果的商业项目从早期的自己造轮子到处踩坑到后来总结出一套稳定高效的实现方案这个过程里积累的实战经验正是这篇分享的核心。2. 核心原理拆解无限循环的“障眼法”无限循环听起来很酷但其核心原理更像一个精心设计的“视觉魔术”。关键在于理解两个核心概念虚拟数据列表与物理 Cell 池的映射关系以及循环索引的计算。2.1 虚拟列表与物理池的分离首先我们必须将“数据”和“显示”彻底分离。虚拟数据列表 (Data List)这是一个逻辑上的列表包含了所有要展示的数据项。它的长度可能是 1000甚至 10000。物理 Cell 池 (Cell Pool)这是我们实际创建和管理的 UI 游戏对象GameObject集合。它的数量很少通常只比一屏能容纳的 Cell 数量多 2-3 个作为缓冲。无限循环的魔法就在于我们让这有限的几个物理 Cell通过不断变换其承载的数据和位置给用户营造出一种在浏览一个无限长列表的错觉。2.2 循环索引的数学魔术这是实现无限循环最精妙的部分。我们如何让第 N 条数据和第 NM 条数据M为数据总量在视觉上表现为相邻关键在于对数据索引Index进行“取模”运算。假设我们总共有totalCount条数据。视觉索引 (Visual Index)用户直观看到的、从 0 开始连续排列的索引。数据索引 (Data Index)对应虚拟数据列表中真实数据的索引。当我们滚动时我们维护一个visualStartIndex表示当前视口Viewport最左侧或最上方显示的数据所对应的视觉索引。那么对于视口内的第 i 个物理 Cell其视觉索引是visualIndex visualStartIndex i。其对应的真实数据索引是dataIndex (visualStartIndex i) % totalCount。这个%取模运算就是循环的根源。当visualIndex等于totalCount时dataIndex又回到了 0。这样一来无论视觉索引如何线性增长数据索引永远在[0, totalCount-1]这个范围内循环。2.3 位置的循环映射仅有数据循环还不够Cell 的位置也必须循环。以水平列表为例每个 Cell 的宽度是cellWidth。一个非循环列表第 i 个 Cell 的 X 坐标是posX i * cellWidth。在一个循环列表中我们需要根据视觉索引来计算一个“循环位置”。但直接使用visualIndex * cellWidth会导致坐标无限增大或减小最终导致浮点数精度问题或变换组件数值溢出。因此我们需要一个“归一化”的位置。常见的策略是让所有物理 Cell 的位置都围绕着一个“基准位置”来排列。我们计算每个 Cell 相对于当前visualStartIndex的“偏移索引”然后用这个偏移索引来计算相对位置。同时当某个 Cell 的偏移量超过一定阈值比如滚出视口超过一个列表长度的距离我们就将其位置“搬运”到列表的另一端并更新其承载的数据。这个过程对用户而言是无感的因为 Cell 总是在进入视口前就已经被重新放置和填充好了。3. 实现方案选型与架构设计理解了原理我们来看看具体怎么实现。市面上有几种常见的路子各有优劣。3.1 方案对比自制轮子 vs 利用现有组件方案优点缺点适用场景基于 ScrollRect 自制布局灵活性极高可完全自定义循环逻辑、动画、交互。性能优化可控到极致。实现复杂度高需要处理大量边界情况如拖动、惯性、弹性。开发周期长。对列表有极端定制化需求如复杂交错布局、特殊滚动效果的项目。基于 Unity UI Extensions / Third-party Assets开发速度快通常经过验证稳定性较好。社区可能有现成解决方案。灵活性受限于插件功能定制修改可能侵入插件代码升级有风险。可能存在性能或功能瓶颈。快速原型开发或项目需求与插件功能高度匹配时。基于 NRatel/Unity-ListView 等开源实现代码可见、可控能深入理解原理并针对性优化。通常比商业插件更轻量。需要一定的理解和集成成本可能需要根据项目 UI 框架如MVC、MVP进行适配。大多数中型及以上项目需要在性能、灵活性和开发效率间取得平衡。从我多年的经验来看对于需要投入生产的项目基于一个优秀的开源实现进行二次开发是性价比最高的选择。NRatel 的这个 Unity-ListView 就是一个非常好的起点。它清晰地实现了我们上面讨论的核心原理代码结构也比较清晰。我们接下来的实操也将以理解和扩展这个仓库的代码为基础。3.2 核心类职责划分一个健壮的无限循环 ListView 架构通常包含以下几个核心部分LoopListView2这是大脑。负责管理整个循环逻辑包括视口计算、滚动事件监听、Cell 的回收与复用分发、循环索引的计算。它持有物理 Cell 池。LoopListViewItem2这是物理 Cell 的包装器。每个可视的 Cell 都是一个LoopListViewItem2对象它持有真正的GameObjectmGo、RectTransformmRectTransform以及当前绑定的数据索引mItemIndex。ItemPrefabConfData预制体配置数据。告诉列表哪种类型的 Cell 对应哪个预制体。这是支持多类型 Cell 的基础。自定义Cell 脚本这是血肉。你需要为每一种 Cell 预制体编写一个脚本继承自MonoBehaviour。这个脚本需要提供一个类似SetData(object data, int index)的方法用于接收LoopListView2分发过来的数据和索引并更新 UI 显示。这种架构实现了关注点分离列表管理器只关心布局和调度具体的 UI 表现和数据处理由每个 Cell 自己负责。4. 关键代码实现与难点剖析这里我们深入到代码层面看看几个最关键的实现片段和容易踩坑的地方。4.1 初始化与预制体池首先初始化列表并设置预制体池。NRatel的实现中LoopListView2.InitListView方法是入口。// 假设我们有一个 LoopListView2 组件挂在 ScrollRect 的 Content 上 public LoopListView2 m_ListView; public GameObject m_ItemPrefab; // 你的 Cell 预制体 void Start() { // totalCount 设置一个很大的数比如 10000来模拟“无限” // 实际上由于取模运算我们只需要数据源的真实数量 int totalItemCount GetDataListCount(); // 第三个参数是初始化时列表的起始索引视觉索引。设为0表示从第一条数据开始显示。 m_ListView.InitListView(totalItemCount, OnGetItemByIndex, m_ItemPrefab); } // 这是核心回调函数列表需要显示或复用某个索引的 Cell 时会调用这个方法 LoopListViewItem2 OnGetItemByIndex(LoopListView2 listView, int itemIndex) { // itemIndex 是数据索引已经过取模计算后的真实索引 if (itemIndex 0 || itemIndex totalItemCount) { // 索引非法通常发生在初始化或极端滚动时返回 null 即可列表会处理 return null; } // 1. 向列表请求一个可用的 Item从回收池获取或新建 LoopListViewItem2 item listView.NewListViewItem(ItemPrefabName); GameObject itemObj item.mGo; // 2. 获取或添加我们自定义的 Cell 脚本 YourItemCell itemScript itemObj.GetComponentYourItemCell(); if (itemScript null) { itemScript itemObj.AddComponentYourItemCell(); } // 3. 根据 itemIndex 从数据源获取数据 ItemData data GetDataByIndex(itemIndex); // 4. 将数据设置到 Cell 上 itemScript.SetData(data, itemIndex); // 5. 返回这个 Item return item; }注意NewListViewItem方法的参数是预制体的名字这个名字需要在ItemPrefabConfData中配置或者在InitListView时传入的预制体上通过某个组件指定。确保这个名字唯一且能正确映射到预制体否则会获取失败。4.2 循环滚动的核心UpdateAllShownItemPos() 方法在LoopListView2的Update()或LateUpdate()中会调用UpdateAllShownItemPos()来更新所有已显示 Cell 的位置。这个方法的核心逻辑如下概念代码private void UpdateAllShownItemPos() { // 1. 获取当前 ScrollRect 的滚动位置归一化的值或距离 float offset CalculateListViewOffset(); // 2. 根据 offset 计算新的 visualStartIndex int newStartIndex CalculateNewStartIndex(offset); // 3. 如果 visualStartIndex 发生了变化 if (mCurFrameItemStartIndex ! newStartIndex) { // 4. 计算索引变化量 delta int deltaIndex newStartIndex - mCurFrameItemStartIndex; // 5. 根据 deltaIndex 的正负和大小决定是“回收头部补充尾部”还是“回收尾部补充头部” if (deltaIndex 0) { // 向右滚动水平列表视觉起始索引变大 // 将移出视口左侧的 Cell 回收到池中 RecycleHeadItem(deltaIndex); // 在视口右侧补充新的 Cell FillTailItem(deltaIndex); } else if (deltaIndex 0) { // 向左滚动视觉起始索引变小 RecycleTailItem(-deltaIndex); FillHeadItem(-deltaIndex); } // 6. 更新当前记录的起始索引 mCurFrameItemStartIndex newStartIndex; } // 7. 更新所有当前显示 Cell 的精确位置基于 visualStartIndex 和每个 Cell 的视觉索引 UpdateAllShownItemPosWithOffset(); }CalculateNewStartIndex和UpdateAllShownItemPosWithOffset这两个函数是实现循环位置映射的关键。它们需要根据cellWidth、viewportWidth和滚动偏移量精确计算出每个 Cell 应该出现的“局部位置”确保在连续滚动时从尾部跳回头部的 Cell 其位置变化是连续的没有跳跃感。4.3 支持不同高度的 Cell垂直列表对于垂直列表如果每个 Cell 高度固定实现相对简单。但如果 Cell 高度不同即瀑布流或聊天记录复杂度会飙升。核心挑战在于无法再通过简单的索引乘以固定高度来计算位置。解决方案是引入一个“累加高度表”mItemPosArray。这个数组记录了每个数据索引对应的 Cell 的起始 Y 坐标或累计高度。在OnGetItemByIndex中我们不仅需要设置数据还需要告知列表这个 Cell 的实际高度。LoopListViewItem2 OnGetItemByIndex(LoopListView2 listView, int itemIndex) { // ... 获取 item 和 data ... itemScript.SetData(data, itemIndex); // 关键步骤在设置数据后Cell 脚本需要能报告自己的实际高度 float itemHeight itemScript.GetItemHeight(); // 或者通过 ContentSizeFitter 自动计算后获取 // 将这个高度设置回 LoopListViewItem2 item.mItemSize itemHeight; // 假设 mItemSize 用于存储高度 item.mPadding 0f; // 如果有间隔的话 return item; }列表在UpdateAllShownItemPos时需要查询这个mItemPosArray来定位每个 Cell。当某个 Cell 的高度因数据变化而改变时需要从该索引开始重新计算后面所有 Cell 的累计位置并刷新显示。这是一个性能敏感点可能需要做局部更新优化。5. 性能优化实战要点无限循环列表的性能必须精益求精任何一点浪费在移动端都会被放大。5.1 避免在滚动过程中进行昂贵操作数据加载OnGetItemByIndex回调函数会被频繁调用。绝对不要在这个函数里进行同步的、耗时的操作比如从磁盘读取图片、复杂的网络请求、庞大的即时计算。解决方案采用异步加载和占位符策略。SetData时立即设置文本等轻量数据对于图片先设置一个默认的占位图。启动一个异步任务如UnityWebRequest加载图片或从内存缓存解码。图片加载完成后检查这个 Cell 当前是否还显示相同的itemIndex因为复用可能发生如果是则应用图片如果不是则丢弃这次加载结果。5.2 对象池的深度管理NRatel 的列表内部已经管理了LoopListViewItem2的对象池。但我们自定义的 Cell 脚本里可能还有子对象需要池化比如图标、特效等。不要在OnGetItemByIndex中频繁Instantiate/Destroy即使是对 Cell 内部的复杂子项也应建立独立的对象池。示例一个角色头像 Cell可能包含装备图标、等级边框等动态元素。应该在项目初始化时就创建好这些元素的池子在SetData时从池中取用和归还。5.3 减少 Canvas 重建UGUI 的 Canvas 重建是性能杀手。无限滚动会频繁改变 Cell 的位置和显示内容极易触发重建。将动态变化的元素放在子 Canvas 中如果 Cell 结构复杂可以考虑为每个 Cell 设置一个独立的、OverrideSorting的子 Canvas。这样单个 Cell 内部的变化不会引起整个大列表 Canvas 的重建。谨慎使用ContentSizeFitter和LayoutGroup这两个组件非常方便但会迫使 Unity 在布局变化时进行额外的计算可能每帧都触发。对于高度不固定的 Cell如果必须用可以考虑在数据设置完成后手动调用LayoutRebuilder.ForceRebuildLayoutImmediate然后缓存下高度避免重复计算。5.4 使用 Jobs System 和 Burst Compiler 进行运算对于超大型列表如万级以上条目即使只渲染几十个 Cell计算每个 Cell 的循环位置和索引也是一项繁重的 CPU 任务。如果项目允许Unity 版本支持且团队有技术能力可以尝试将UpdateAllShownItemPosWithOffset中的位置计算逻辑移植到C# Job System中并利用Burst Compiler进行编译优化可以极大提升计算效率保证滚动流畅度。6. 常见问题排查与修复实录在实际开发中你几乎一定会遇到下面这些问题。6.1 Cell 闪烁或跳动现象快速滚动时Cell 会短暂显示错误的数据或位置发生跳变。原因这是复用逻辑的经典问题。根本原因是数据设置与位置更新的时序竞争。当 Cell A 被滚出屏幕回收到池中紧接着又被用于填充即将进入屏幕的新位置 B。如果为位置 B 设置新数据的操作SetData慢于列表系统更新该 Cell 位置的操作那么在这个时间差内Cell 会显示着旧数据 A但已经被移动到了新位置 B 附近造成视觉上的“闪烁”然后数据才更新。解决方案确保数据设置是立即的、同步的SetData方法里只做最简单的赋值操作所有耗时操作异步化。在OnGetItemByIndex中先设置数据再返回 Item确保列表在拿到 Item 时其数据已经是正确的。检查复用标识在自定义 Cell 脚本中维护一个currentIndex。当异步加载的资源如图片回来时严格比较资源对应的索引与 Cell 当前的currentIndex只有匹配时才应用。6.2 滚动到边界时卡顿或回弹现象滚动到列表“尽头”视觉上的时感觉有明显的阻力或回弹无法实现真正的“无限”丝滑。原因很可能你使用的ScrollRect自身的Movement Type是Elastic弹性并且Content的大小没有正确设置。解决方案将ScrollRect的Movement Type设置为Clamped或Unrestricted。Clamped会在边界停住但配合我们的循环逻辑实际上没有边界Unrestricted则完全不管制。通常用Clamped更安全。更重要的是确保LoopListView2组件所在的Content的RectTransform的尺寸sizeDelta被正确计算。对于循环列表Content的尺寸应该被设置为刚好能容纳所有物理 Cell 的排列而不是虚拟数据的总长。如果Content尺寸计算错误ScrollRect会认为可滚动区域很小从而产生奇怪的滚动行为。在 NRatel 的实现中ListView会动态调整Content的尺寸你需要确保这块逻辑正常工作。6.3 点击事件错乱现象点击某个 Cell触发的事件却是另一个 Cell 的。原因UI 点击事件依赖于GraphicRaycaster和EventSystem。当 Cell 被复用时它的RectTransform位置发生了剧变但EventSystem在同一帧内可能还没有更新到最新的位置信息。解决方案为每个 Cell 的按钮事件传递唯一标识不要在按钮事件回调里直接依赖gameObject或transform来查找数据而是应该在设置数据时将itemIndex或一个唯一ID绑定到按钮的监听事件上。button.onClick.RemoveAllListeners(); button.onClick.AddListener(() OnItemClicked(itemIndex));考虑使用IPointerClickHandler接口在自定义 Cell 脚本上实现此接口在OnPointerClick方法中处理点击并确保这里使用的索引是当前 Cell 绑定的最新索引。6.4 内存泄漏与池管理现象随着滚动内存持续增长GC垃圾回收频繁触发。原因除了之前提到的对象池管理不当还有一个常见原因是事件监听没有正确移除。解决方案在 Cell 被回收时可以提供一个OnRecycle方法或在SetData开始时清理旧状态务必清除所有 UI 控件上的事件监听。public void OnRecycle() { mButton.onClick.RemoveAllListeners(); // 清除其他事件如 InputField.onValueChanged, Toggle.onValueChanged 等 }检查异步操作如UnityWebRequest的取消。如果 Cell 被回收时其发起的图片加载请求还未完成必须手动中止 (Abort()) 该请求并释放相关资源。7. 高级特性与扩展思路一个基础的无限循环列表满足大部分需求但在复杂项目中我们可能需要更多。7.1 多类型 Cell 支持一个列表里可能有横幅、头像、消息等多种样式的 Cell。这需要在OnGetItemByIndex中根据itemIndex返回不同的预制体。定义 ItemType为每种 Cell 类型定义一个枚举或整数标识。扩展InitListView传入一个ItemPrefabConfData数组而不仅仅是一个预制体。这个数组定义了每种类型对应的预制体。修改回调OnGetItemByIndex需要增加一个out参数来返回当前索引需要的 ItemType或者通过一个GetItemType(int index)的方法来获取。列表管理LoopListView2需要为每种类型的预制体维护独立的对象池。7.2 跳转与定位功能实现类似ScrollToIndex(int index)的功能让列表快速滚动到指定数据项。计算目标位置根据目标数据索引计算出对应的 Content 的归一化位置或 anchoredPosition。平滑滚动不要直接设置content.anchoredPosition这很生硬。应该使用ScrollRect的horizontalNormalizedPosition/verticalNormalizedPosition属性并结合DOTween或Mathf.Lerp进行平滑插值。处理循环跳转时需要决定是走最短路径可能反向滚动还是始终正向滚动。这涉及到对视觉索引的巧妙调整。7.3 与数据层的绑定如 MVC/MVVM在大型项目中我们不会让LoopListView2直接去操作数据源。通常会引入一个中间层比如ListViewModel。ViewModel持有数据列表并实现INotifyPropertyChanged接口。LoopListView2的控制器或一个专门的ListViewController订阅ViewModel的数据变化通知。当数据增删改时ViewModel发出通知控制器收到后调用ListView的RefreshAllShownItem()或SetListItemCount()等方法触发局部或全局刷新。每个Cell脚本也作为一个微型View它监听自身数据模型的变化自动更新 UI。这样实现了数据与 UI 的解耦。实现一个稳定高效的无限循环 ListView就像搭建一个精密的钟表。每一个齿轮Cell都必须在其轨道位置计算上精准运行而发条复用逻辑则驱动着整个系统永不停歇。从理解虚拟与物理的映射关系到处理好每一处性能细节和边界情况这个过程充满了挑战但当你看到那个可以丝滑无限滚动的列表最终呈现在屏幕上时那种成就感是实实在在的。希望这些从实战中摔打出来的经验和代码片段能帮你绕过我当年踩过的那些坑。