资讯动态

Unity UGUI无限滚动列表:高性能实现与优化指南

发布时间:2026/8/10 9:17:11 来源:尧图企业网站定制
1. 项目概述为什么我们需要无限滑动列表在Unity开发中尤其是移动端游戏或应用处理大量数据项的UI展示是个老大难问题。想象一下你的游戏有一个排行榜动辄上千名玩家或者一个背包系统里面塞满了各种道具。如果一股脑地把所有UI元素比如GameObject和Image、Text组件都实例化出来会发生什么你的手机会瞬间卡顿内存飙升甚至直接闪退。这就是“全量渲染”带来的灾难性后果。无限滑动列表或者叫循环列表、动态列表就是为了解决这个问题而生的。它的核心思想是“所见即所得”只创建和渲染当前可视区域内的那几个UI项。当用户滑动时动态地回收滚出屏幕的项并用新的数据去填充即将进入屏幕的项。这样一来无论你的数据源有100条还是10000条屏幕上活跃的UI项数量始终是恒定的比如10个性能开销自然就降下来了。我接手过不少项目从早期的自己手搓ScrollRect加Content Size Fitter到后来用各种插件踩过的坑数不胜数。今天我就结合一个从排行榜到背包的通用案例手把手带你实现一个高性能的UGUI无限滑动列表并附上完整的、可直接复用的C#源码。我们会深入原理而不仅仅是调用API让你真正理解如何优化。2. 核心原理与架构设计2.1 动态回收与复用机制无限列表的性能核心在于“回收复用”。你可以把它想象成一个传送带。传送带上只有固定数量的“展示位”Item。每个展示位对应一个UI预制体Prefab。当传送带滚动时离开视野的展示位并不会被销毁而是被回收到一个“待用池”里。同时即将进入视野的区域需要一个新项来展示这时就从“待用池”里取出一个回收的项放到传送带另一端并更新它显示的数据。关键数据结构数据源 (Data Source):一个ListT存放所有要展示的数据模型如RankItemData,BagItemData。项池 (Item Pool):一个QueueRectTransform或ListRectTransform用于缓存已创建但暂时不可见的UI项。活跃项列表 (Active Items):一个ListRectTransform记录当前正在屏幕上显示的UI项。工作流程初始化根据可视区域的高度和每个项的高度计算出最多需要同时显示多少个项比如viewCount Mathf.CeilToInt(viewportHeight / itemHeight) 2加2是为了做缓冲防止滚动时出现空白。创建出viewCount个UI项放入活跃列表。滚动监听监听ScrollRect的onValueChanged事件。判断回收与添加向上滚动如果最顶部的活跃项已经完全滚出视口上方则回收它并将其放入项池。然后判断是否需要在下部添加新项即最底部活跃项的下一个数据索引是否在数据源范围内且未显示如果需要则从项池取一个项或新建放到底部并更新其数据。向下滚动逻辑相反回收底部项在顶部添加新项。数据绑定从数据源中根据当前项的索引取出对应的数据模型然后调用一个UpdateItem(int index, RectTransform item)方法将数据填充到UI项的各个子组件如Text、Image上。注意这里计算viewCount时“加2”是个经验值具体取决于滚动速度和项的高度。如果项很高加1可能就够了如果滚动非常快可能需要加3甚至更多以确保在快速滑动时新项能及时被创建和填充避免出现短暂的空白。这是一个需要根据实际效果微调的地方。2.2 与UGUI ScrollRect的协同我们并不需要完全重写滚动逻辑UGUI自带的ScrollRect组件已经很好地处理了拖动、惯性、弹性等交互。我们的无限列表组件是ScrollRect的“内容管理者”。ScrollRect的角色提供视口(Viewport)、内容容器(Content)并处理用户输入事件计算Content的归一化位置(normalizedPosition)。无限列表组件的角色挂在Content游戏对象上。它需要获取ScrollRect和Viewport的引用。根据数据总量和单项尺寸动态设置Content的RectTransform的尺寸sizeDelta以提供正确的滚动范围。这是实现无限感的关键——滚动条的长度对应的是全部数据的总高度/宽度但实际渲染的只是其中一小段。在ScrollRect滚动时根据Content的局部位置计算出当前应该显示哪些数据项并执行回收与填充。一个常见的坑如果你使用了Content Size Fitter或Layout Group如Vertical Layout Group来布局Content下的项一定要在运行时禁用它们。因为这些组件会在每一帧尝试重新排列所有子物体这与我们的动态回收机制严重冲突会导致性能问题或显示错乱。正确的做法是由我们的无限列表脚本完全控制每个项的位置通过设置anchoredPosition。3. 手把手实现C#核心代码解析下面我将分步拆解核心代码。为了通用性我们设计一个InfiniteScrollViewT : MonoBehaviour的基类其中T是你的数据模型类。3.1 基础属性与初始化using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; public abstract class InfiniteScrollViewT : MonoBehaviour where T : class { [SerializeField] protected ScrollRect scrollRect; // 关联的ScrollRect [SerializeField] protected RectTransform viewport; // 视口 [SerializeField] protected RectTransform itemPrefab; // 单项的预制体 [SerializeField] protected float itemSpacing 0f; // 项之间的间隔 protected RectTransform content; // ScrollRect的内容区域也是本脚本挂载的对象 protected ListT dataList new ListT(); // 数据源 protected float itemHeight; // 预设的单项高度垂直滚动 protected int currentTopIndex 0; // 当前可视区域顶部对应的数据索引 protected int viewItemCount; // 最多需要同时显示的项数 protected QueueRectTransform itemPool new QueueRectTransform(); // 项对象池 protected ListRectTransform activeItems new ListRectTransform(); // 活跃项列表 protected virtual void Awake() { if (scrollRect null) scrollRect GetComponentInParentScrollRect(); if (viewport null) viewport scrollRect.viewport; content scrollRect.content; // 确保Content的锚点在顶部方便计算位置 content.anchorMin new Vector2(0.5f, 1f); content.anchorMax new Vector2(0.5f, 1f); content.pivot new Vector2(0.5f, 1f); // 计算单项尺寸这里以垂直滚动为例 itemHeight itemPrefab.sizeDelta.y; // 计算需要预创建的项数 float viewportHeight viewport.rect.height; viewItemCount Mathf.CeilToInt(viewportHeight / itemHeight) 2; // 缓冲2个 // 禁用可能存在的自动布局组件 var layoutGroup content.GetComponentLayoutGroup(); var contentSizeFitter content.GetComponentContentSizeFitter(); if (layoutGroup ! null) layoutGroup.enabled false; if (contentSizeFitter ! null) contentSizeFitter.enabled false; scrollRect.onValueChanged.AddListener(OnScrollValueChanged); } }初始化要点在Awake中获取必要的组件引用并做好初始化设置。将Content的锚点(Anchor)和轴心(Pivot)设置为顶部中点(0.5, 1)这样它的anchoredPosition.y为0时顶部就与视口顶部对齐方便我们用y坐标来计算项的位置。viewItemCount的计算是性能的关键。viewportHeight / itemHeight得到刚好铺满一屏的项数加2是缓冲。对于水平滚动则需要计算宽度。3.2 设置数据与初始布局public void SetData(ListT data) { dataList data; currentTopIndex 0; // 1. 清空当前显示和对象池 foreach (var item in activeItems) { item.gameObject.SetActive(false); itemPool.Enqueue(item); } activeItems.Clear(); // 2. 设置Content的总高度决定滚动范围 float totalHeight dataList.Count * itemHeight Mathf.Max(0, dataList.Count - 1) * itemSpacing; content.sizeDelta new Vector2(content.sizeDelta.x, totalHeight); content.anchoredPosition Vector2.zero; // 复位到顶部 // 3. 预创建初始的viewItemCount个项并设置位置和数据 for (int i 0; i viewItemCount i dataList.Count; i) { RectTransform item GetNewItem(); SetItemPosition(item, i); UpdateItemContent(item, i); activeItems.Add(item); } } protected RectTransform GetNewItem() { RectTransform item; if (itemPool.Count 0) { item itemPool.Dequeue(); item.gameObject.SetActive(true); } else { item Instantiate(itemPrefab, content); } // 这里可以做一些Item的初始化比如重置状态 item.anchorMin new Vector2(0.5f, 1f); item.anchorMax new Vector2(0.5f, 1f); item.pivot new Vector2(0.5f, 1f); return item; } protected void SetItemPosition(RectTransform item, int dataIndex) { float y -dataIndex * (itemHeight itemSpacing); // 因为锚点在顶部所以用负的Y值向下排列 item.anchoredPosition new Vector2(0, y); }SetData方法解析清理现场将之前所有的活跃项回收进对象池。注意是SetActive(false)而不是Destroy这是对象池的意义所在。设定滚动范围根据数据总量和单项尺寸计算出Content应有的总高度并赋值给content.sizeDelta。这个高度决定了ScrollRect滚动条的长度和可滚动范围。这是实现“无限”滚动感觉的基石——滚动条反映总数据量但实际渲染的物体很少。初始填充为当前可视区域从索引0开始创建viewItemCount个项并设置好它们的位置和数据。实操心得SetItemPosition中的y坐标计算是核心。因为我们的锚点在顶部所以第一个项索引0的y坐标为0第二个项索引1的y坐标就是-(itemHeight itemSpacing)以此类推。务必理解本地坐标(anchoredPosition)与锚点的关系这是UGUI布局的基础。3.3 滚动回调与动态更新这是整个无限列表的“心脏”。protected void OnScrollValueChanged(Vector2 normalizedPos) { // 对于垂直滚动我们主要关心 normalizedPos.y (1是顶部0是底部) // 但更可靠的方法是直接计算Content的顶部位置相对于Viewport的位置 // 转换到Viewport的本地坐标系来比较更精确 Vector3[] viewportCorners new Vector3[4]; viewport.GetWorldCorners(viewportCorners); Vector3[] contentCorners new Vector3[4]; content.GetWorldCorners(contentCorners); // 简单判断如果Content的底部在世界空间中的Y值大于Viewport顶部的Y值说明Content顶部已滚出视口 // 但更通用的方法是计算当前最顶部项的数据索引 RecalculateCurrentTopIndex(); UpdateVisibleItems(); } protected void RecalculateCurrentTopIndex() { // 方法1通过Content的anchoredPosition计算更高效但需要处理锚点 // 因为锚点在顶部content.anchoredPosition.y 表示Content顶部偏离原点的距离向下为负 // 当前顶部数据索引 ≈ 偏移距离 / 单项高度 float offset -content.anchoredPosition.y; // 取反得到正的距离 currentTopIndex Mathf.FloorToInt(offset / (itemHeight itemSpacing)); currentTopIndex Mathf.Clamp(currentTopIndex, 0, Mathf.Max(0, dataList.Count - 1)); } protected void UpdateVisibleItems() { if (dataList.Count 0 || activeItems.Count 0) return; // 1. 回收已经完全滚出视口的项从列表头部或尾部 // 我们以当前顶部索引为基准判断活跃项的数据索引是否在有效范围内 [currentTopIndex, currentTopIndex viewItemCount) int validStartIndex currentTopIndex; int validEndIndex Mathf.Min(currentTopIndex viewItemCount, dataList.Count) - 1; // 从后往前遍历避免移除时索引错乱 for (int i activeItems.Count - 1; i 0; i--) { var item activeItems[i]; int itemDataIndex GetItemDataIndex(item); if (itemDataIndex validStartIndex || itemDataIndex validEndIndex) { // 回收该项 activeItems.RemoveAt(i); item.gameObject.SetActive(false); itemPool.Enqueue(item); } } // 2. 确保当前有效索引范围内的每个数据都有一个活跃项显示 for (int dataIndex validStartIndex; dataIndex validEndIndex; dataIndex) { bool isDisplayed false; foreach (var item in activeItems) { if (GetItemDataIndex(item) dataIndex) { isDisplayed true; break; } } if (!isDisplayed) { // 需要为这个dataIndex创建一个新的显示项 RectTransform item GetNewItem(); SetItemPosition(item, dataIndex); UpdateItemContent(item, dataIndex); // 找到合适的位置插入activeItems保持其顺序例如按Y坐标排序 // 这里简单添加到列表末尾因为顺序不影响显示后续可以根据需要排序 activeItems.Add(item); } } // 可选对activeItems按位置排序确保Hierarchy顺序可能对合批有微弱好处 activeItems.Sort((a, b) b.anchoredPosition.y.CompareTo(a.anchoredPosition.y)); // 从上到下排序 for (int i 0; i activeItems.Count; i) { activeItems[i].SetSiblingIndex(i); } } // 需要一个方法来从Item对象反推其当前代表的数据索引 protected int GetItemDataIndex(RectTransform item) { // 根据位置计算position.y / itemHeight float yPos item.anchoredPosition.y; int index Mathf.RoundToInt(-yPos / (itemHeight itemSpacing)); return Mathf.Clamp(index, 0, dataList.Count - 1); } // 抽象方法用于更新Item的显示内容 protected abstract void UpdateItemContent(RectTransform item, int dataIndex);滚动逻辑详解RecalculateCurrentTopIndex:这是最关键的函数之一。它通过Content当前的anchoredPosition计算出当前视口顶部应该对应数据源的哪个索引。公式currentTopIndex Mathf.FloorToInt(offset / (itemHeight itemSpacing))是核心其中offset是Content顶部滚动的正距离。这个计算的准确性直接决定了项回收和填充的时机。UpdateVisibleItems:回收阶段遍历所有当前活跃的项检查其对应的数据索引是否还在有效的显示范围内[validStartIndex, validEndIndex]。如果不在就将其回收至对象池。填充阶段遍历有效显示范围内的每一个数据索引检查是否已经有活跃项在显示它。如果没有就从对象池取出或新建一个项设置其位置和数据并加入活跃列表。排序可选最后可以按位置对活跃项排序并设置它们在Content下的兄弟顺序(SiblingIndex)。这有时能帮助UGUI的Draw Call合批但效果因情况而异。踩坑记录早期我尝试用ScrollRect的normalizedPosition来计算currentTopIndex但在快速滚动和边界回弹时这个值并不线性导致计算不准出现项闪烁或错位。后来改用content.anchoredPosition进行基于实际像素距离的计算稳定了很多。另外GetItemDataIndex方法通过项的位置反推索引必须和SetItemPosition中的计算公式严格互逆否则会出现数据绑定错乱。3.4 数据绑定与项更新UpdateItemContent是一个抽象方法需要子类根据具体的UI项样式来实现。这是将数据模型T与UI视图连接起来的地方。// 示例排行榜项的数据和视图 public class RankItemData { public int rank; public string playerName; public int score; public Sprite avatarSprite; } public class RankInfiniteScrollView : InfiniteScrollViewRankItemData { [SerializeField] private Sprite defaultAvatar; // 默认头像 protected override void UpdateItemContent(RectTransform item, int dataIndex) { RankItemData data dataList[dataIndex]; // 假设Item预制体上有以下组件 Text rankText item.Find(RankText).GetComponentText(); Image avatarImage item.Find(AvatarImage).GetComponentImage(); Text nameText item.Find(NameText).GetComponentText(); Text scoreText item.Find(ScoreText).GetComponentText(); rankText.text data.rank.ToString(); nameText.text data.playerName; scoreText.text data.score.ToString(N0); avatarImage.sprite data.avatarSprite ! null ? data.avatarSprite : defaultAvatar; // 可以根据排名改变颜色等 if (data.rank 3) { rankText.color Color.yellow; } else { rankText.color Color.white; } } }数据绑定最佳实践在UpdateItemContent中通过item.Find或缓存引用更推荐的方式获取子UI组件。强烈建议在GetNewItem中缓存这些组件引用避免每次更新都进行Find或GetComponent调用这是性能热点。更新数据时考虑状态重置。例如一个项之前被用于第1名高亮色回收后再用于第50名需要将其颜色重置为普通色。对于图片如头像要处理好加载和占位符。使用Sprite引用或通过地址加载如Addressables/AssetBundle并设置默认图。4. 高级性能优化技巧实现了基础功能只是第一步要让它在千元机上也能流畅运行还需要下面这些优化。4.1 减少Canvas重建与Draw CallUGUI的性能瓶颈常在Canvas的网格重建上。无限列表由于项的频繁更新和移动容易触发重建。静态与动态分离如果列表项内部结构复杂比如有背景图、头像框、多个文字确保列表项本身在一个子Canvas里或者更优的是确保整个无限列表的Content在一个独立的Canvas下。避免因为列表的频繁变动导致屏幕上其他不相关的UI元素也一起重建。合批优化材质与图集确保所有列表项使用的图片都在同一个图集(Atlas)中。相同的材质是合批的前提。层级顺序保持Content下子物体即各个Item的层级顺序稳定。我们之前在UpdateVisibleItems最后做的排序和SetSiblingIndex目的之一就是让它们的渲染顺序固定有助于合批。频繁改变兄弟顺序会打断合批。避免重叠确保列表项之间没有重叠正常情况下不会重叠的UI元素无法合批。使用CanvasRenderer的cull属性对于确定完全在视口外的项可以设置item.canvasRenderer.cull true。这会在渲染时完全跳过该渲染器降低渲染开销。在我们的回收逻辑中将项SetActive(false)已经达到了类似效果但cull可能更轻量。可以测试对比。4.2 对象池的深化管理我们实现了一个简单的Queue作为对象池还可以进一步优化预热在初始化时不是只创建viewItemCount个而是可以预先创建稍多一些如viewItemCount 5放入池中。这样在快速滚动需要瞬时创建多个新项时可以直接从池中取避免在滚动回调中实例化新对象造成的卡顿。按类型池化如果你的列表中有多种样式的项例如排行榜前3名样式特殊需要为每种样式建立独立的对象池。内存清理对于特别长的列表当数据源发生巨大变化如从10000条切换到10条时池中可能缓存了大量多余对象。可以增加一个方法在适当的时候如切换界面时清理对象池Destroy掉一部分对象。4.3 滚动性能与跳转滚动阈值优化在OnScrollValueChanged中不要每次滚动都触发完整的RecalculateCurrentTopIndex和UpdateVisibleItems。可以设置一个阈值比如当滚动距离超过半个项高度时再更新。但这会增加实现复杂度需要平衡精确性和性能。对于大多数情况每帧更新是可以接受的因为计算量本身不大。快速滚动与帧率在高速滚动时可以尝试降低UpdateVisibleItems中一些非关键操作的频率比如Sort和SetSiblingIndex。或者使用Coroutine分帧进行更新避免单帧卡顿。直接跳转实现一个ScrollToIndex(int index)方法。这需要直接设置content.anchoredPosition然后立即调用一次UpdateVisibleItems。注意要处理边界情况索引小于0或大于最大值。public void ScrollToIndex(int index, bool immediate true) { index Mathf.Clamp(index, 0, dataList.Count - 1); float targetY -index * (itemHeight itemSpacing); if (immediate) { content.anchoredPosition new Vector2(content.anchoredPosition.x, targetY); RecalculateCurrentTopIndex(); UpdateVisibleItems(); } else { // 可以使用DOTween等插件实现平滑滚动 // DOTween.To(() content.anchoredPosition.y, y content.anchoredPosition new Vector2(content.anchoredPosition.x, y), targetY, 0.3f) // .OnUpdate((){ // RecalculateCurrentTopIndex(); // UpdateVisibleItems(); // }); } }4.4 针对移动端的特别优化避免使用UI.Graphic的Raycast Target如果列表项不需要接收点击事件或者只需要一个按钮接收确保将项上其他Image、Text等组件的Raycast Target勾选去掉。这能显著减少UI事件系统的检测开销在长列表中效果明显。使用TMP_Text替代TextUnity原生的Text组件在频繁更新文本时性能较差。TextMeshPro的TMP_Text性能更好字体渲染效果也更佳是移动端的首选。图片加载优化头像等网络图片使用异步加载并做好缓存。加载过程中显示占位图。避免在滚动过程中同步加载大量图片。Profiler深度分析一定要在目标真机特别是低端机上使用Unity Profiler或UPR等工具进行分析。关注Canvas.SendWillRenderCanvasesCanvas重建、UI.Render渲染、GC.Alloc垃圾分配这几个关键指标。我们的优化目标就是降低它们的开销。5. 实战问题排查与扩展5.1 常见问题速查表问题现象可能原因解决方案滚动时项闪烁或消失1.currentTopIndex计算错误。2. 回收/填充的判断条件有误缓冲项不足(viewItemCount太小)。3.GetItemDataIndex与SetItemPosition计算不匹配。1. 调试输出currentTopIndex和content.anchoredPosition检查计算逻辑。2. 适当增加viewItemCount的缓冲值如2改成3。3. 确保两个方法中的itemHeight和itemSpacing使用相同的值且计算互为逆运算。滚动到底部或顶部有空白1.Content的总高度计算错误。2. 数据源为空或数量小于viewItemCount时初始化逻辑有误。3.ScrollRect的Movement Type或Inertia设置导致。1. 检查totalHeight的计算公式特别是间距的处理。2. 在SetData中对数据量少的情况做特殊处理直接显示所有项无需滚动。3. 检查ScrollRect的Viewport和Content的锚点设置是否正确。项显示错乱数据不对应1.UpdateItemContent中绑定数据时索引用错。2. 回收后项的内部状态如颜色、选中态没有重置。1. 在UpdateItemContent中打印dataIndex和绑定后的数据确认对应关系。2. 在将项回收到池中前或从池中取出后增加一个ResetItemState方法清除所有特定数据的状态。滚动卡顿特别是安卓低端机1. Canvas频繁重建。2. 每帧GetComponent或Find开销大。3. 垃圾回收(GC)频繁。1. 使用Profiler查看Canvas重建开销尝试静态/动态分离。2. 缓存UI组件引用。3. 避免在Update或滚动回调中分配新对象如new List,new Vector3[]将viewportCorners等数组定义为成员变量复用。点击事件无效1. 项被回收后其上的按钮事件监听未正确移除或重新绑定。2.Raycast Target被错误禁用。1. 在数据绑定时重新为按钮设置监听并确保使用当前正确的dataIndex。2. 确保需要接收点击的组件如按钮Image启用了Raycast Target。5.2 功能扩展思路多列布局如网格背包核心逻辑不变但位置计算从一维索引变为二维索引行、列。SetItemPosition需要计算x和y。viewItemCount需要根据视口尺寸和单项尺寸计算出行数和列数。回收判断也需要同时考虑行和列的方向。项高度不固定这是更大的挑战。需要预先知道或能计算出每一项的高度。可以在数据模型T中增加一个ItemHeight属性。Content的总高度变为所有项高度之和。SetItemPosition时需要累加前面所有项的高度来计算当前项的y坐标。回收判断也需要基于项的绝对位置范围计算会更复杂。可以考虑使用“估值-测量-调整”的策略先给一个预估高度等项创建并布局完成后再获取其实际高度并更新后续项的位置。下拉刷新/上拉加载更多监听ScrollRect的滚动位置。当normalizedPosition.y接近1顶部或0底部时触发加载更多数据的回调。注意防抖处理避免连续触发。项的选择与状态管理在数据模型T中增加IsSelected等状态。在UpdateItemContent中根据状态更新项的外观如高亮。同时在项的点击事件中修改对应数据的状态并刷新显示。注意处理单选、多选等不同模式。实现一个健壮、高性能的无限滚动列表是Unity UI开发中的一项重要技能。它没有银弹需要根据项目的具体需求数据量、项复杂度、目标平台进行细致的调整和优化。本文提供的代码框架和优化思路希望能为你打下坚实的基础。在实际项目中多使用性能分析工具在真机上测试才能打磨出最佳体验。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价