
1. 项目概述为什么我们要告别原生的Scroll View在Unity UGUI项目里尤其是那些需要展示大量数据项的应用比如聊天记录、排行榜、背包系统或者商品列表Scroll View组件几乎是每个开发者都会用到的“老朋友”。但这位“老朋友”在数据量稍大时就会暴露出它的“坏脾气”卡顿、内存飙升、加载缓慢。如果你尝试过在一个Scroll Rect里塞进几百甚至上千个UI元素然后快速滑动那种掉帧的体验一定让你印象深刻。这背后的核心问题就是原生Scroll View的“全量渲染”机制——无论你屏幕上能看到几个Item它都会把所有数据项对应的GameObject都实例化出来并挂载在场景树里。这就像为了看一本书的某一页而把整本书都打印出来一样无疑是巨大的性能浪费。因此“无限列表”或“循环列表”的概念应运而生。它的核心思想是“按需渲染”只创建和维护刚好能铺满当前可视区域Viewport的UI元素我们称之为“单元格”或“Item”。当用户滑动列表时动态地复用这些已经创建的Item更新它们的内容和位置从而模拟出一个拥有海量数据的列表。这能极大地减少Draw Call、降低CPU的渲染压力、节约内存是UI性能优化中至关重要的一环。市面上虽然有像“UGUI Super ScrollView”这样的优秀插件但很多时候项目有自己独特的需求、特定的数据结构和交互逻辑或者团队希望拥有完全自主可控的底层组件。这时自研一个高性能的无限列表就成了一个既有挑战性又极具价值的选择。今天我就结合自己多次从零搭建无限列表的实战经验分享其中的核心设计思路、关键实现步骤以及那些让我“掉进坑里”又爬出来的宝贵教训。2. 核心设计思路与架构拆解自研无限列表绝不是简单地在Update里计算位置。一个健壮、高性能的无限列表需要一套清晰、解耦的架构设计。我将其核心拆解为以下几个部分这也是我们自研时需要遵循的“设计蓝图”。2.1 数据驱动与视图分离这是无限列表设计的基石。我们必须将“数据”Data和“视图”View彻底分离。数据层Model负责管理所有列表项的数据源。它通常是一个ListT或数组T是你的数据类ItemData里面包含了每个Item需要显示的所有信息比如ID、名称、图标路径、数量等。数据层不关心UI如何显示。视图层View负责根据数据层的信息创建和更新具体的UI表现。核心是一个“Item预制体”ItemPrefab以及一个用于管理Item实例的“视图池”View Pool。控制器Controller也就是我们的无限列表核心组件。它作为桥梁监听Scroll Rect的滚动事件根据当前滚动位置从数据层获取需要显示的数据范围然后从视图池中取出或回收Item并调用视图层的方法去更新这些Item的内容。这种MVC或MVP模式的好处是数据变化可以独立于UI更新UI复用逻辑也清晰独立便于维护和扩展。2.2 动态布局计算与视口裁剪无限列表的核心算法在于如何根据滚动位置计算出当前哪些数据项应该被显示出来。布局系统首先你需要确定列表的布局方式。是垂直列表、水平列表还是网格Grid每种布局都有其位置计算公式。垂直列表每个Item的Y轴坐标 -(Item高度 间距) * 索引。网格列表需要计算行和列。假设每行有columnCount个Item那么第index个Item的行row Mathf.FloorToInt(index / columnCount)列col index % columnCount。其位置坐标则由(col * (Item宽度水平间距), -row * (Item高度垂直间距))决定。视口Viewport计算这是关键。我们需要知道当前Scroll Rect的可视区域在世界空间或本地空间中的范围。通常我们关心的是顶部或左端的边界值和底部或右端的边界值。索引范围计算基于布局公式和视口范围反向推导出当前应该显示的数据索引的最小值startIndex和最大值endIndex。以垂直列表为例我们已知视口顶部viewportTop和底部viewportBottom的Y坐标在列表内容的本地坐标系下。我们需要找到第一个Y坐标大于viewportBottom的Item的索引因为Unity中UI的Y轴向上为正列表向下滚动时内容向上移动所以视口底部对应内容的上方。这通常可以通过遍历或更高效的算法如二分查找如果你的Item高度固定来完成。同理找到最后一个Y坐标小于viewportTop的Item的索引。最终startIndex到endIndex之间的所有Item就是当前需要显示的。2.3 对象池View Pool的精密管理为了极致性能我们必须复用Item的GameObject避免频繁的Instantiate和Destroy。这就是对象池的用武之地。池的结构通常使用一个QueueRectTransform或StackRectTransform来存储闲置的Item实例。当需要显示一个新Item时先从池里取Dequeue/Pop当一个Item滚动出视口时不是销毁它而是将其放回池中Enqueue/Push并重置其状态如隐藏、清除数据。预热Preheat在列表初始化时根据预估一屏最多能显示的Item数量Mathf.CeilToInt(viewportHeight / (itemHeight spacing)) 2加2作为缓冲提前实例化好对应数量的Item放入池中。这能避免在滚动过程中因突然需要新Item而导致的瞬时卡顿。池的扩容如果数据量巨大且滚动速度极快可能会瞬间需要比预热数量更多的Item。一个好的对象池应该具备动态扩容的能力但扩容逻辑要谨慎避免单帧内创建过多对象。可以设置一个最大池大小限制或者采用异步创建的方式。3. 关键实现步骤与核心代码解析理论说完了我们来看看具体怎么实现。我会以一个最经典的垂直可变高度无限列表为例因为它的挑战最大也最能体现设计思路。3.1 基础组件搭建首先我们创建核心的C#脚本比如叫InfiniteScrollView.cs把它挂载到你的Scroll Rect下的Content节点上。using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class InfiniteScrollView : MonoBehaviour { // 对外接口数据源和Item创建器 public interface IDataSource { int GetItemCount(); float GetItemHeight(int index); void SetItemData(Transform item, int index); } // 核心组件引用 [SerializeField] private ScrollRect _scrollRect; [SerializeField] private RectTransform _viewportRect; [SerializeField] private RectTransform _contentRect; [SerializeField] private GameObject _itemPrefab; // Item的预制体 private IDataSource _dataSource; private RecyclerViewPool _itemPool; private Listfloat _itemPositions; // 缓存每个Item的起始Y坐标 private int _totalItemCount 0; // 当前活跃的Item数据索引, Item实例 private Dictionaryint, RectTransform _activeItems new Dictionaryint, RectTransform(); private int _cachedStartIndex -1; private int _cachedEndIndex -1; void Start() { if (_scrollRect null) _scrollRect GetComponentInParentScrollRect(); if (_viewportRect null) _viewportRect _scrollRect.viewport; if (_contentRect null) _contentRect GetComponentRectTransform(); _itemPool new RecyclerViewPool(_itemPrefab.transform, _contentRect); _itemPositions new Listfloat(); // 监听滚动事件 _scrollRect.onValueChanged.AddListener(OnScrollValueChanged); } public void SetDataSource(IDataSource dataSource) { _dataSource dataSource; InitializeContentSizeAndPositions(); UpdateVisibleItems(true); } }这里定义了一个IDataSource接口这是连接你的业务数据与无限列表的桥梁。外部只需要实现这个接口列表就能工作。3.2 内容尺寸计算与位置缓存对于可变高度列表我们无法直接通过索引乘以固定高度来计算位置。我们需要预先计算或缓存每一个Item的起始位置。private void InitializeContentSizeAndPositions() { if (_dataSource null) return; _totalItemCount _dataSource.GetItemCount(); _itemPositions.Clear(); _activeItems.Clear(); float currentY 0f; for (int i 0; i _totalItemCount; i) { _itemPositions.Add(currentY); // 注意Unity UI的锚点通常在左上角向下滚动时内容向上移动所以Y坐标为负。 // 我们这里存储的是本地坐标系下的起始Y坐标负值或0。 currentY - (_dataSource.GetItemHeight(i) _spacing); // _spacing是预设的间距 } // 设置Content的总高度绝对值 float totalHeight Mathf.Abs(currentY) - _spacing; // 减去最后一个多余的间距 _contentRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, totalHeight); // 将Content的锚点设为Top方便计算 _contentRect.anchorMin new Vector2(0, 1); _contentRect.anchorMax new Vector2(1, 1); _contentRect.pivot new Vector2(0.5f, 1); _contentRect.anchoredPosition Vector2.zero; }这段代码遍历所有数据项累加它们的高度和间距计算出每个Item的顶部Y坐标并缓存起来同时设置了Content的正确高度使得Scrollbar能正确反映滚动比例。3.3 滚动事件处理与可视项更新当用户滑动时我们需要重新计算哪些Item应该显示。private void OnScrollValueChanged(Vector2 normalizedPos) { // 使用一个阈值或延迟避免每帧都更新但为了响应速度这里直接更新 UpdateVisibleItems(false); } private void UpdateVisibleItems(bool forceRefresh) { if (_dataSource null || _totalItemCount 0) return; // 1. 计算当前视口在Content本地空间中的范围 Vector3[] viewportWorldCorners new Vector3[4]; _viewportRect.GetWorldCorners(viewportWorldCorners); Vector3[] contentLocalCorners new Vector3[4]; for (int i 0; i 4; i) { contentLocalCorners[i] _contentRect.InverseTransformPoint(viewportWorldCorners[i]); } // 由于锚点在顶部视口底部对应更大的Y值更小的负值顶部对应更小的Y值更大的负值或正值 float viewportTop contentLocalCorners[1].y; // 左上角Y值最大负得最少或为正 float viewportBottom contentLocalCorners[0].y; // 左下角Y值最小负得最多 // 2. 根据缓存的位置数组查找需要显示的索引范围 // 找到第一个底部位置 viewportBottom 的索引 (因为位置是负的所以是“小于”的比较) // 更准确地说找到第一个 itemPosY itemHeight viewportBottom 的索引不我们换种方式。 // 我们找item的顶部itemPosY在视口底部viewportBottom之上且底部itemPosY - height在视口顶部viewportTop之下的Item。 // 简化遍历位置找到 itemPosY viewportBottom itemPosY viewportTop 的索引范围。 // 但由于位置是负的且视口也在移动我们需要一个更稳健的方法二分查找。 int newStartIndex FindFirstVisibleIndex(viewportBottom); int newEndIndex FindLastVisibleIndex(viewportTop); // 添加缓冲项防止滚动时边缘出现空白 newStartIndex Mathf.Max(0, newStartIndex - _bufferCount); newEndIndex Mathf.Min(_totalItemCount - 1, newEndIndex _bufferCount); if (!forceRefresh newStartIndex _cachedStartIndex newEndIndex _cachedEndIndex) { return; // 可视范围没变无需更新 } // 3. 回收不再显示的Item Listint keysToRemove new Listint(); foreach (var kvp in _activeItems) { if (kvp.Key newStartIndex || kvp.Key newEndIndex) { _itemPool.ReturnItem(kvp.Value); keysToRemove.Add(kvp.Key); } } foreach (int key in keysToRemove) { _activeItems.Remove(key); } // 4. 添加或更新需要显示的Item for (int i newStartIndex; i newEndIndex; i) { if (!_activeItems.ContainsKey(i)) { RectTransform itemRT _itemPool.GetItem(); itemRT.SetParent(_contentRect, false); _activeItems[i] itemRT; } // 更新Item的位置和数据 UpdateItemAt(i, _activeItems[i]); } _cachedStartIndex newStartIndex; _cachedEndIndex newEndIndex; } // 辅助方法使用二分查找提高效率假设位置数组是单调递减的 private int FindFirstVisibleIndex(float viewportBottomY) { // 找到第一个 itemPosY顶部 viewportBottomY 的索引 // 因为itemPosY是递减的0, -100, -200...viewportBottomY是更小的负数比如-250 // 我们需要找到第一个位置值 viewportBottomY 的索引。 int low 0, high _totalItemCount - 1; while (low high) { int mid (low high) / 2; if (_itemPositions[mid] viewportBottomY) { high mid - 1; } else { low mid 1; } } return low _totalItemCount ? low : _totalItemCount - 1; } private int FindLastVisibleIndex(float viewportTopY) { // 找到最后一个 itemPosY顶部 viewportTopY 的索引 int low 0, high _totalItemCount - 1; while (low high) { int mid (low high) / 2; if (_itemPositions[mid] viewportTopY) { low mid 1; } else { high mid - 1; } } return high 0 ? high : 0; } private void UpdateItemAt(int index, RectTransform itemRT) { // 设置位置 float posY _itemPositions[index]; itemRT.anchoredPosition new Vector2(0, posY); // 设置高度如果是可变高度 float itemHeight _dataSource.GetItemHeight(index); itemRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, itemHeight); // 通过接口回调让外部设置这个Item的具体显示内容 _dataSource.SetItemData(itemRT, index); }UpdateVisibleItems是这个组件的“心脏”。它通过视口边界计算出需要显示的索引范围然后通过对象池复用Item并调用数据源接口来更新Item的视觉表现。这里使用了二分查找来快速定位索引范围对于大数据量列表至关重要。3.4 对象池RecyclerViewPool的实现最后我们来看一下对象池的简单实现。public class RecyclerViewPool { private Transform _itemPrefab; private Transform _parent; private QueueRectTransform _pool new QueueRectTransform(); private int _activeCount 0; public RecyclerViewPool(Transform prefab, Transform parent) { _itemPrefab prefab; _parent parent; } public RectTransform GetItem() { RectTransform item; if (_pool.Count 0) { item _pool.Dequeue(); item.gameObject.SetActive(true); } else { GameObject go Object.Instantiate(_itemPrefab.gameObject, _parent); item go.GetComponentRectTransform(); } _activeCount; return item; } public void ReturnItem(RectTransform item) { item.gameObject.SetActive(false); _pool.Enqueue(item); _activeCount--; } // 预热方法 public void Prewarm(int count) { for (int i 0; i count; i) { RectTransform item Object.Instantiate(_itemPrefab.gameObject, _parent).GetComponentRectTransform(); item.gameObject.SetActive(false); _pool.Enqueue(item); } } }这个池子管理着Item实例的生命周期。GetItem负责提供可用的Item优先从池里取池空则创建新的ReturnItem则将不再使用的Item回收并隐藏以备下次使用。Prewarm方法用于初始化时的预热。4. 实战中踩过的“坑”与优化心得纸上得来终觉浅绝知此事要躬行。理论代码写起来清晰但在实际项目中你会遇到各种各样的问题。下面是我总结的几个关键“坑点”和解决方案。4.1 性能“刺客”Canvas重建与合批即使我们完美复用了GameObject如果UI元素频繁改变依然可能引发严重的性能问题。问题UGUI的合批Batching依赖于元素的深度、材质和纹理。如果你在滚动时不断改变Item内部图片Image的sprite或者改变文本TextMeshProUGUI的内容很容易导致该Item所在的Canvas整块进行重建Rebuild如果Canvas下元素很多开销巨大。解决方案纹理图集Sprite Atlas确保所有Item可能用到的图标都在同一个图集里。这样切换Sprite时只要材质相同就不容易打断合批。数据驱动更新优化在SetItemData中不要无脑地给每个UI组件赋值。可以先比较新旧数据是否相同如果相同则跳过更新。这对于文本和图片特别有效。分帧加载对于需要从网络加载的图片不要在滚动过程中同步加载。应该使用异步加载并在加载完成后更新。更激进的做法是在滚动停止或减速后再开始加载当前视口内Item的远程图片。考虑使用CanvasRenderer.cull对于完全不在视口内的Item即使是我们缓冲池外的确保其CanvasRenderer的cull属性为true这能避免Unity对其进行任何渲染相关的计算。4.2 跳跃的Scrollbar与内容尺寸问题在可变高度列表中如果数据源的总高度在初始化后发生动态变化比如某个Item的高度改变了你会发现Scrollbar的滑块大小和位置会“跳动”体验很差。解决方案动态更新内容尺寸和位置缓存。当某个Item的高度需要变化时通知无限列表组件例如调用一个NotifyItemHeightChanged(int index)方法。在该方法内从该索引开始重新计算其后所有Item的缓存位置_itemPositions并更新Content的总高度。同时需要更新当前所有活跃Item_activeItems的位置因为它们的基准坐标可能都变了。这是一个相对耗时的操作对于频繁变化的场景需要谨慎可以考虑将多次更新合并到一帧的末尾执行。4.3 输入事件处理与Item点击问题复用的Item其上面挂载的Button点击事件如何正确对应到数据索引解决方案不要在预制体上直接为Button写死监听。应该在SetItemData中动态为Item下的Button添加或更新点击事件监听器并在回调函数中传入当前的数据索引index。这样无论这个GameObject被复用到哪个数据项上点击事件都能正确响应。// 在SetItemData实现中 Button btn itemTransform.GetComponentButton(); if (btn ! null) { btn.onClick.RemoveAllListeners(); // 关键清除旧的监听 btn.onClick.AddListener(() OnItemClicked(index)); }4.4 内存泄漏与池管理问题对象池中的GameObject会一直存在。如果列表生命周期很长或者Item预制体很复杂可能占用过多内存。另外如果外部持有对某个Item的引用并在该Item被回收后继续访问会导致错误。解决方案实现池的清理机制可以设置一个最大池大小。当回收的Item超过这个数量时直接Destroy掉最老的那些。Item状态重置在ReturnItem时不仅要SetActive(false)最好还能重置Item的内部状态比如清空Image的sprite、重置Text的内容、移除所有事件监听器等确保它下次被取出时是一个“干净”的状态。引用管理确保业务逻辑不长期持有对池内Item的引用。任何对Item的操作都应该基于数据索引index通过无限列表组件来获取当前的Item实例。4.5 与Unity UI系统的兼容性问题你的无限列表Content可能会和其他UGUI组件如LayoutGroup、ContentSizeFitter冲突。这些组件会自动计算子物体布局和尺寸会破坏我们手动计算的位置。解决方案绝对不要在无限列表的Content节点上挂载任何LayoutGroup或ContentSizeFitter组件。布局和尺寸必须由我们的脚本完全控制。5. 进阶优化与扩展思路当你解决了基本问题后可以考虑以下进阶优化让列表更加丝滑和强大。异步分帧初始化如果列表有上万条数据在InitializeContentSizeAndPositions中遍历所有数据计算高度和位置可能会造成主线程卡顿。可以将这个计算过程分到多帧完成每帧计算一定数量的Item位置计算完成后再启用滚动。支持动画与过渡在Item即将出现或消失时可以加入淡入淡出、缩放等简单动画。注意动画性能尽量使用CanvasGroup的Alpha或RectTransform的缩放避免使用Animator。支持多类型Item一个列表里可能有多种样式的Item如聊天列表中的文本消息、图片消息、系统提示。这需要扩展对象池为每种预制体类型维护一个独立的子池并根据数据索引返回对应的类型。与Addressable/AssetBundle集成Item的预制体可能来自AssetBundle。对象池的GetItem方法需要支持异步加载预制体。这增加了复杂度需要处理好“加载中”的占位显示和加载完成后的替换。虚拟化与渲染分离终极优化是只更新Item的“数据”而将复杂的渲染如大量文本、复杂网格委托给更底层的渲染方式。但这已经超出了标准UGUI的范畴可能需要结合自定义Shader或更高级的渲染管线。自研无限列表是一个“造轮子”的过程充满了挑战但带来的收益也是巨大的极致的性能、完全的掌控力、以及对UGUI底层机制更深刻的理解。它没有银弹需要你根据自己项目的具体需求是固定高度还是可变高度是否需要多类型数据量级有多大来调整和优化上述方案中的每一个细节。希望这篇从原理到实践再到踩坑经验的分享能为你自己的“造轮子”之旅提供一份可靠的路线图。记住性能优化永无止境关键是理解原理大胆实践细致测量。