
1. 项目概述为什么你的列表还在卡做Unity UI开发尤其是涉及到长列表、内容动态加载的场景最头疼的就是性能问题。你肯定遇到过列表一滑动帧率就跳水手机发烫用户抱怨卡顿。尤其是在做商城道具列表、聊天记录、背包系统或者像朋友圈那种高度不固定的动态流时传统的ScrollRect直接挂载几百个Item简直就是性能灾难的根源。这个项目的核心就是彻底解决这个问题。它不是简单地教你用对象池而是将Unity UGUI的ScrollRect与一套精心设计的对象池机制深度结合专门攻克不规则高度Item的列表渲染难题。网上很多教程要么只讲基础对象池要么实现的列表只支持固定高度的Item一旦遇到文本长度不一、图片尺寸不同的需求就直接歇菜。我们这个方案就是从原理到实践手把手带你搭建一个既能无限滚动、又能精准计算和复用各种尺寸Item的高性能列表组件。最终交付的不仅仅是一个Demo更是一个可以直接集成到你项目中的、经过实际项目验证的修复版解决方案。你会发现原来让列表如丝般顺滑并没有想象中那么复杂。2. 核心思路拆解对象池与视口裁剪的艺术2.1 传统方案的性能瓶颈在哪里要理解新方案的优势得先明白旧方案为什么不行。通常我们实现一个UGUI列表会这么做初始化所有Item根据数据源长度在ScrollRect的Content下实例化出所有UI元素。排列所有Item使用VerticalLayoutGroup或手动计算位置将所有Item排列好。滑动时渲染所有Item即使大部分Item根本不在屏幕视口内Unity的Canvas渲染器仍然会处理它们的顶点、网格甚至可能触发不必要的布局计算。问题显而易见实例化开销巨大内存占用高不必要的渲染和布局计算严重消耗CPU和GPU资源。当Item数量上百时卡顿是必然的。2.2 我们的解决方案动态视口裁剪 智能对象池我们的方案摒弃了“一次性全部创建”的思路采用了“按需创建循环复用”的策略。其核心思想可以概括为视口驱动只关心当前ScrollRect视口Viewport内能看到的内容区域。动态计算根据每个Item的预设或动态计算出的高度对于不规则列表推算出当前视口应该显示哪些数据索引。对象池管理维护一个数量远小于数据总量的Item对象池例如比屏幕能容纳的Item数多2-3个。当需要显示某个索引的Item时从池中取出一个复用当Item滑出视口时将其回收到池中并重新用于即将进入视口的新Item。数据与视图分离Item的UI元素视图只是一个“壳”它的显示内容数据在每次被复用时进行刷新绑定。关键在于“不规则”固定高度列表很简单因为你可以直接用索引乘以固定高度来定位。但不规则列表你必须能快速查询到任意一个Item的起始位置累加高度。这通常需要一个额外的数据结构如一个存储了每个Item高度的数组或列表来支持快速查询或者像我们Demo中实现的那样在获取数据时预先计算或提供Item的高度信息。3. 关键组件与代码结构解析3.1 核心脚本职责划分一个健壮的不规则列表系统通常包含以下几个核心脚本各司其职1.RecycledListView/UIRecyclerView(核心控制器)这是大脑。它需要继承自MonoBehaviour并监听ScrollRect的onValueChanged事件。它的主要职责是管理数据源一个ListT。维护对象池ItemPool。在滚动时计算当前视口范围对应的数据索引范围。调用对象池为需要显示的索引分配Item为离开的索引回收Item。处理Item高度的动态获取与缓存以支持不规则布局。2.IRecycledListItem/BaseItemView(Item视图接口/基类)这是Item的契约。它定义了一个Item视图必须实现的方法通常包括BindData(int index, object data): 根据数据索引和具体数据对象刷新UI显示。GetItemHeight(): 返回这个Item当前的高度对于不规则项这个值可能在BindData后才知道。OnRecycled(): 当Item被回收到池中时调用用于重置状态。3.ItemPool(对象池)这是仓库。负责GameObject的创建、取出、放回和缓存。一个好的对象池应该预初始化一定数量的实例。提供Get()和Recycle(ItemView)方法。区分不同Prefab的类型如果列表有多种Item类型。使用栈或队列来管理可用对象提高效率。4.ScrollRect与Viewport、Content(Unity原生组件)这是舞台和画布。ScrollRect的Viewport矩形决定了可见区域Content是一个巨大的矩形其高度是所有Item的累计高度。我们的脚本需要动态调整Content的高度并设置每个复用Item在Content下的正确位置anchoredPosition。3.2 不规则高度的处理策略这是本项目的难点和精华。有两种主流策略策略一预设高度模型如果你的不规则Item虽然高度不同但类型有限或者高度可以预先确定例如根据文本行数、图片固定尺寸计算得出那么可以在数据层就计算好每个Item的高度并存储在一个数组里。RecycledListView直接查询这个数组来计算总高度和Item位置。这是效率最高的方式。策略二动态高度模型更通用Item的高度需要在它被实例化并绑定数据后才能通过Unity的LayoutElement、ContentSizeFitter或手动计算RectTransform.rect.height来获得。流程如下当需要显示一个Item时先从池中取出一个“模板”Item。调用BindData绑定数据此时UI元素如Text会根据内容自动或手动调整大小。强制立即重建布局调用Canvas.ForceUpdateCanvases()或LayoutRebuilder.ForceRebuildLayoutImmediate(itemRectTransform)确保布局计算完成。调用GetItemHeight()获取该Item的准确高度。将该高度缓存起来并更新Content的总高度以及后续所有Item的起始位置这是一个昂贵的操作需要优化比如只更新受影响区域之后的位置。重要提示动态计算高度会引发强制布局重建这是一个相对耗时的操作。务必在性能敏感的场景下进行优化例如在滚动停止的瞬间或分帧进行高度计算和位置更新避免在每一帧的滚动事件中都触发全量计算。4. 分步实现与核心代码剖析下面我们抛开Demo从零开始拆解实现步骤并附上关键代码逻辑。4.1 第一步搭建UI场景结构创建一个Canvas在它下面创建一个空物体命名为ScrollView。为ScrollView添加ScrollRect组件。在ScrollView下创建两个子物体Viewport添加Mask或RectMask2D组件推荐RectMask2D性能更好并将其拖拽为ScrollRect的Viewport属性。Viewport的锚点应设置为全拉伸Stretch。Content将其拖拽为ScrollRect的Content属性。将其锚点设置为顶部拉伸Top-Stretch这样它的宽度会随Viewport变化而y轴坐标将从顶部开始计算。这是实现垂直列表的关键。禁用或删除ScrollRect自带的Scrollbar我们通常自己定制。创建一个Item的Prefab例如ListItem_Prefab。为其添加一个自定义的脚本如CustomListItemView这个脚本将实现IRecycledListItem接口。在Prefab上设计好你的UI布局。4.2 第二步实现对象池ItemPool对象池的代码相对独立和通用。这里给出一个简化但功能完整的版本using System.Collections.Generic; using UnityEngine; public class ItemPool : MonoBehaviour { public GameObject prefab; // Item的预制体 public Transform parent; // 实例化后的父节点通常是Content private StackGameObject pool new StackGameObject(); private ListGameObject activeList new ListGameObject(); // 记录所有激活的Item便于管理 // 预预热对象池 public void Prewarm(int count) { for (int i 0; i count; i) { GameObject obj CreateNewItem(); pool.Push(obj); } } private GameObject CreateNewItem() { GameObject obj Instantiate(prefab, parent); obj.SetActive(false); // 可以在这里获取并缓存ItemView组件方便后续使用 return obj; } // 从池中获取一个Item public GameObject Get() { GameObject obj; if (pool.Count 0) { obj pool.Pop(); } else { obj CreateNewItem(); } obj.SetActive(true); activeList.Add(obj); return obj; } // 回收一个Item到池中 public void Recycle(GameObject obj) { if (obj ! null activeList.Contains(obj)) { obj.SetActive(false); activeList.Remove(obj); pool.Push(obj); // 可以在这里调用ItemView的回收清理方法 var itemView obj.GetComponentIRecycledListItem(); itemView?.OnRecycled(); } } // 清空池和所有Item public void ClearAll() { foreach (var obj in activeList) { Destroy(obj); } activeList.Clear(); while (pool.Count 0) { Destroy(pool.Pop()); } pool.Clear(); } }4.3 第三步定义Item视图接口与实现public interface IRecycledListItem { // 绑定数据index是数据索引data是具体的数据对象 void BindData(int index, object data); // 获取当前Item的高度 float GetItemHeight(); // 被回收时调用 void OnRecycled(); // 获取或设置RectTransform方便控制器操作位置 RectTransform RectTransform { get; } } // 具体的Item视图实现示例 public class CustomListItemView : MonoBehaviour, IRecycledListItem { public Text titleText; public Text contentText; public Image iconImage; private RectTransform _rectTransform; public RectTransform RectTransform { get { if (_rectTransform null) _rectTransform GetComponentRectTransform(); return _rectTransform; } } // 假设你的数据类 private MyData _currentData; public void BindData(int index, object data) { _currentData data as MyData; if (_currentData ! null) { titleText.text _currentData.title; contentText.text _currentData.content; // ... 其他UI赋值 // 绑定数据后内容可能变化高度需要更新 } } public float GetItemHeight() { // 方法1如果使用ContentSizeFitter等布局组件需要强制刷新布局后获取 // LayoutRebuilder.ForceRebuildLayoutImmediate(RectTransform); // return RectTransform.rect.height; // 方法2预设或计算好的高度更高效 // 这里假设我们根据文本行数估算了一个高度并存储在_currentData中 return _currentData?.calculatedHeight ?? 100f; // 默认高度 } public void OnRecycled() { // 清理临时状态重置UI到默认可选 titleText.text ; contentText.text ; _currentData null; } }4.4 第四步实现核心控制器RecycledListView这是最复杂的部分。我们聚焦于核心逻辑UpdateListView方法它在ScrollRect滚动时被调用。using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class RecycledListView : MonoBehaviour { public ScrollRect scrollRect; public ItemPool itemPool; public float spacing 5f; // Item之间的间距 private Listobject _dataList new Listobject(); private Listfloat _itemHeights new Listfloat(); // 缓存每个Item的高度 private float _contentTotalHeight 0; private RectTransform _contentRT; private RectTransform _viewportRT; // 当前正在显示的Item索引 - GameObject private Dictionaryint, GameObject _activeItems new Dictionaryint, GameObject(); // 当前视口对应的数据索引范围 private int _currentStartIndex -1; private int _currentEndIndex -1; void Start() { if (scrollRect null) scrollRect GetComponentScrollRect(); _contentRT scrollRect.content; _viewportRT scrollRect.viewport; scrollRect.onValueChanged.AddListener(OnScrollValueChanged); // 初始化对象池 itemPool.Prewarm(10); // 设置Content的锚点和轴心便于计算位置 _contentRT.anchorMin new Vector2(0, 1); // 左上角 _contentRT.anchorMax new Vector2(1, 1); _contentRT.pivot new Vector2(0.5f, 1); } // 设置数据并刷新列表 public void SetData(Listobject data) { ClearAllItems(); _dataList data; CalculateHeightsAndContentSize(); UpdateListView(true); // 强制更新 } // 计算每个Item的高度和Content总高度预设高度模型示例 private void CalculateHeightsAndContentSize() { _itemHeights.Clear(); _contentTotalHeight 0; for (int i 0; i _dataList.Count; i) { // 这里需要根据你的数据和业务逻辑计算高度 // 例如从数据对象中读取预设的height字段 float height 100f; // 假设从_dataList[i]中获取 _itemHeights.Add(height); _contentTotalHeight height; if (i _dataList.Count - 1) _contentTotalHeight spacing; } // 设置Content的高度 _contentRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, _contentTotalHeight); } // 根据索引获取Item的顶部Y坐标 private float GetItemStartPos(int index) { float pos 0; for (int i 0; i index; i) { pos _itemHeights[i] spacing; } return -pos; // 因为锚点在左上角向下为负 } // 滚动事件回调 private void OnScrollValueChanged(Vector2 normalizedPos) { UpdateListView(false); } // 核心更新逻辑 private void UpdateListView(bool forceRefresh) { if (_dataList null || _dataList.Count 0) { ClearAllItems(); return; } // 1. 计算当前视口在Content局部空间中的上下边界 Vector3[] viewportCorners new Vector3[4]; _viewportRT.GetWorldCorners(viewportCorners); Vector3[] contentCorners new Vector3[4]; _contentRT.GetWorldCorners(contentCorners); // 将视口边界转换到Content的局部空间 float viewportTop _contentRT.InverseTransformPoint(viewportCorners[1]).y; // 左上角 float viewportBottom _contentRT.InverseTransformPoint(viewportCorners[0]).y; // 左下角 // 2. 根据边界计算需要显示的数据索引范围 int newStartIndex 0; int newEndIndex -1; float accumulatedHeight 0; // 找到第一个起始索引视口顶部以下的第一个Item for (int i 0; i _itemHeights.Count; i) { float itemHeight _itemHeights[i]; if (accumulatedHeight itemHeight -viewportTop) // viewportTop是负值 { newStartIndex i; break; } accumulatedHeight itemHeight spacing; } // 从startIndex开始找到结束索引视口底部以上的最后一个Item accumulatedHeight GetItemStartPos(newStartIndex); for (int i newStartIndex; i _itemHeights.Count; i) { float itemHeight _itemHeights[i]; if (accumulatedHeight itemHeight -viewportBottom) // viewportBottom是更大的负值 { newEndIndex i; break; } accumulatedHeight itemHeight spacing; } if (newEndIndex -1) newEndIndex _itemHeights.Count - 1; // 3. 前后多缓存1-2个Item防止滚动时出现空白 newStartIndex Mathf.Max(0, newStartIndex - 1); newEndIndex Mathf.Min(_dataList.Count - 1, newEndIndex 1); // 如果索引范围没变化且不是强制刷新则跳过 if (!forceRefresh newStartIndex _currentStartIndex newEndIndex _currentEndIndex) { return; } // 4. 回收不再需要的Item Listint keysToRemove new Listint(); foreach (var kvp in _activeItems) { int index kvp.Key; if (index newStartIndex || index newEndIndex) { itemPool.Recycle(kvp.Value); keysToRemove.Add(index); } } foreach (int key in keysToRemove) { _activeItems.Remove(key); } // 5. 为需要显示的新索引创建/复用Item for (int i newStartIndex; i newEndIndex; i) { if (!_activeItems.ContainsKey(i)) { GameObject itemGO itemPool.Get(); IRecycledListItem itemView itemGO.GetComponentIRecycledListItem(); itemView.BindData(i, _dataList[i]); // 设置Item位置 RectTransform itemRT itemView.RectTransform; itemRT.SetParent(_contentRT, false); itemRT.anchoredPosition new Vector2(0, GetItemStartPos(i)); // 通常还需要设置Item的宽度与Content匹配 itemRT.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, _contentRT.rect.width); _activeItems[i] itemGO; } } _currentStartIndex newStartIndex; _currentEndIndex newEndIndex; } private void ClearAllItems() { foreach (var item in _activeItems.Values) { itemPool.Recycle(item); } _activeItems.Clear(); _currentStartIndex -1; _currentEndIndex -1; } }5. 避坑指南与性能优化实战纸上得来终觉浅绝知此事要躬行。下面这些坑都是我趟过来的希望能帮你省下大量调试时间。5.1 坑一Content锚点与坐标计算错误现象Item位置错乱滚动时乱跳。根因Content的锚点Anchor和轴心Pivot设置不正确。对于垂直滚动列表Content的锚点应设置为顶部拉伸Top-Stretch这样它的(0,0)点就在左上角。计算Item的Y坐标时向下为负。我们的GetItemStartPos函数返回的就是负值。如果锚点设在中心计算会变得极其复杂。解决在Start或Awake中显式设置_contentRT.anchorMin new Vector2(0, 1);和_contentRT.anchorMax new Vector2(1, 1);_contentRT.pivot new Vector2(0.5f, 1);。5.2 坑二动态高度计算导致的布局抖动现象滚动时列表内容会突然“跳”一下特别是包含自动换行Text的Item。根因在BindData后立即调用GetItemHeight()此时Unity的UI布局系统可能还没有完成当前帧的渲染计算获取到的是上一帧或错误的高度。解决延迟一帧在BindData后使用StartCoroutine或Invoke延迟到下一帧再计算高度并更新Content大小和位置。但这会影响体验。强制立即布局重建在BindData后调用LayoutRebuilder.ForceRebuildLayoutImmediate(itemRT)然后获取高度。这是最常用但需慎用的方法因为它有性能开销。预计算高度推荐如果业务允许在数据层服务器或本地就根据内容估算出高度如文本行数*行高图片固定高边距将高度作为数据的一部分传入。这样完全避免了运行时计算性能最佳。5.3 坑三对象池Item的“脏数据”现象复用的Item显示了错误的内容比如图片错位、文本残留。根因回收Item时没有彻底重置其UI状态。新的数据绑定可能只覆盖了部分UI元素。解决在IRecycledListItem.OnRecycled()方法中必须将所有可能变化的UI元素重置到默认状态如清空Text、设置Image为默认图标、隐藏额外按钮等。对于使用数据驱动框架如MVVM的Item确保在回收时断开旧的数据绑定。5.4 坑四频繁的GetComponent调用现象列表滚动时CPU开销依然较高。根因在UpdateListView的循环中频繁调用GameObject.GetComponentIRecycledListItem()来获取组件。解决缓存组件在对象池创建Item时就获取并缓存其IRecycledListItem组件引用。可以将缓存的组件与GameObject一起存储。修改ItemPool.Get()方法使其返回一个包含GameObject和CachedComponent的结构体或元组。5.5 高级优化分帧与异步操作对于超长列表如聊天记录上千条或Item内容极其复杂包含网络图片加载的情况还需要更高级的策略分帧加载/卸载不要在单帧内处理所有显示/回收逻辑。可以使用一个队列每帧只处理固定数量如5个Item的创建或回收。滚动停止后再加载监听ScrollRect的滚动速度velocity当速度低于某个阈值时认为滚动停止此时再触发一次完整的UpdateListView并可以在这时进行更耗时的操作如加载高清图。占位符Placeholder在Item数据尤其是图片未准备好时先显示一个灰色的占位框等数据加载完成后再替换。这能极大提升滚动流畅度。6. 修复版Demo的特别之处与使用建议你拿到的“修复版Demo”不仅仅是基础功能的实现它还包含了针对上述常见坑点的修复和优化坐标计算更健壮使用了更准确的视口空间转换方法避免了在某些分辨率或锚点设置下位置计算错误的问题。提供了动态高度计算的示例Demo中可能包含一个使用ContentSizeFitter和LayoutGroup的复杂Item示例并展示了如何在获取高度前正确触发布局重建。对象池管理增强包含了多Prefab类型的支持如果你的列表有多种样式的Item以及更安全的回收逻辑。性能监控Hook可能集成了一些简单的性能分析代码帮助你在编辑模式下观察对象池的复用情况。使用建议先跑起来导入Demo场景先感受一下流畅滚动的效果理解基础工作流程。替换你的数据重点关注RecycledListView的SetData方法以及IRecycledListItem的BindData方法。将Demo中的数据模型替换成你自己的。适配你的Item将你的Item Prefab挂载上实现IRecycledListItem接口的脚本并正确实现接口方法。小心处理高度根据你的业务选择“预设高度”或“动态高度”模型。如果选择动态高度务必在性能测试中重点关注。集成到项目将核心脚本RecycledListView,ItemPool,IRecycledListItem复制到你的项目中而不是直接拖拽整个Demo场景。这样更清晰也便于版本管理。实现一个高性能的不规则列表就像在Unity中完成一次精细的雕刻。它要求你对UGUI的渲染流程、坐标变换和对象生命周期有深入的理解。一旦你掌握了这套“动态裁剪循环复用”的组合拳并将其成功应用到项目中那种解决性能顽疾、带来流畅体验的成就感会让你觉得所有的钻研都是值得的。这套方案不仅适用于UGUI其思想同样可以迁移到Unity的其它UI方案甚至游戏对象的管理中是每个追求性能的Unity开发者工具箱里的必备利器。