1. 项目概述与核心痛点做Unity UI开发尤其是需要展示大量图片、道具或者聊天记录的时候新手和老手的第一反应可能都是“上ScrollView”。这没错Unity自带的ScrollRect组件配合Content Size Fitter和Layout Group能快速搭建一个可滚动的列表。但当你真的把几百张、甚至上千张高清照片塞进去试图在移动设备上流畅滑动时噩梦就开始了卡顿、掉帧、内存飙升甚至直接闪退。这就是我们今天要解决的核心痛点如何高效、流畅地展示一个理论上可以无限长的图片列表同时避免资源浪费和逻辑错误。这个“智能照片墙”项目要解决的就是ScrollView的“硬伤”。传统的ScrollView会一次性实例化所有子项哪怕那些在屏幕外的项你看不见它们也真实地存在于场景中消耗着Draw Call和内存。我们的目标是打造一个“所见即所得”的列表屏幕上显示几个内存里就只存在几个。当用户滑动时动态复用已经离开屏幕的项来填充即将进入屏幕的位置实现视觉上的“无限滑动”。同时针对照片墙这种特定场景我们还要加入“防重复”逻辑确保在数据刷新或循环加载时同一张照片不会在相邻或特定范围内重复出现提升用户体验。这个方案的核心价值在于它不仅仅是一个UI组件更是一种面向性能的设计思想。无论是做社交应用的照片流、电商平台的商品瀑布流还是游戏内的背包、排行榜只要涉及大量数据项的展示这套复用机制都能让你的应用性能提升一个档次。接下来我会手把手带你从零实现它并附上完整的算法源码和避坑指南。2. 核心设计思路与方案选型要实现一个高性能的无限滑动列表关键在于“对象池”和“动态布局计算”。我们不能再用ScrollView那种“有多少数据就创建多少Item”的粗暴方式了。2.1 为什么不用传统ScrollView传统ScrollViewScrollRect的工作方式是线性的Content下面挂载所有子物体通过改变Content的anchoredPosition来实现滚动。它的优点在于简单、易用配合Auto Layout能自动排列。但缺点在数据量面前被无限放大实例化开销每个Item都是一个独立的GameObjectInstantiate操作本身就有开销成百上千次调用会导致明显的卡顿。内存占用所有Item的Mesh、材质、脚本组件都驻留在内存中尤其是带高清纹理的图片内存压力巨大。渲染开销即使Item不在视口内Unity的裁剪Culling可能使其不渲染但Canvas的布局和重建过程可能仍然会将其考虑在内造成不必要的CPU消耗。2.2 我们的智能方案循环复用列表我们的方案可以称为“循环复用列表”或“虚拟列表”。其核心思想如下有限的对象池我们只创建比屏幕可视区域稍多一点的Item例如屏幕能显示5个我们创建7或9个并将它们放入一个池中管理。动态赋值与定位滚动时实时计算当前视口Viewport所对应的数据索引范围。将离开视口顶部的Item回收并重新赋值给即将进入视口底部的数据反之亦然然后更新这个Item的位置和显示内容。按需加载图片的加载如下载、从磁盘读取也应该是异步的并且只在Item即将进入视口或进入后才会触发进一步减少初始化和内存压力。方案选型考量单项固定高度 vs 不规则高度为了简化首期实现我们先从固定高度的Item开始。这简化了位置计算Item位置 索引 * 固定高度。后续可以扩展为支持不规则高度但这需要预先计算或动态计算每个Item的高度并累加复杂度更高。垂直滑动 vs 水平滑动原理完全相通只是计算轴向从Y轴变为X轴。本文将以垂直滑动为例进行讲解。数据驱动所有Item的显示内容如图片URL、描述文字应由一个核心的数据列表ListPhotoData来驱动。Item自身只是一个“视图”它根据分配到的数据索引去更新自己的UI。这个设计模式本质上是在数据索引和有限的视图对象之间建立一层动态映射关系。理解了这一点代码实现就有了清晰的路线图。3. 关键组件与算法源码实现让我们开始动手构建。我们将创建几个核心的C#脚本。3.1 数据结构定义首先定义照片的数据结构。这通常来自网络接口或本地配置。// PhotoData.cs [System.Serializable] public class PhotoData { public string id; // 唯一标识用于防重复判断 public string imageUrl; // 图片地址远程或本地 public string title; // 可选图片标题 // 可以根据需要添加更多字段如点赞数、作者等 }3.2 核心管理器InfiniteScrollView这是我们的大脑负责所有计算和调度。// InfiniteScrollView.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class InfiniteScrollView : MonoBehaviour { [Header(基础引用)] public ScrollRect scrollRect; // Unity的ScrollRect组件 public RectTransform viewport; // 视口矩形 public RectTransform content; // 内容根节点 public GameObject itemPrefab; // Item的预制体 [Header(布局参数)] public float itemHeight 200f; // 每个Item的固定高度 public float spacing 10f; // Item之间的间隔 public int bufferCount 1; // 视口外缓冲的Item数量通常为1或2 [Header(数据)] public ListPhotoData allPhotoData new ListPhotoData(); // 全部数据源 private QueuePhotoData availablePhotoQueue new QueuePhotoData(); // 可用于防重复分配的待选数据队列 // 对象池和索引映射 private ListRectTransform itemPool new ListRectTransform(); private DictionaryRectTransform, int itemToDataIndexMap new DictionaryRectTransform, int(); // 计算属性 private float ViewportHeight viewport.rect.height; private int MaxVisibleItems Mathf.CeilToInt(ViewportHeight / (itemHeight spacing)) bufferCount * 2; private float TotalContentHeight allPhotoData.Count * (itemHeight spacing); // 当前视口对应的数据索引范围 [startIndex, endIndex) private int currentStartIndex 0; private int currentEndIndex 0; void Start() { if (scrollRect null) scrollRect GetComponentScrollRect(); InitializeContentSize(); InitializeItemPool(); scrollRect.onValueChanged.AddListener(OnScrollValueChanged); // 初始填充一次 UpdateVisibleItems(true); } // 初始化Content的总高度这是实现“无限感”的基石 void InitializeContentSize() { content.sizeDelta new Vector2(content.sizeDelta.x, TotalContentHeight); } // 初始化对象池创建刚好够用的Item void InitializeItemPool() { int poolSize Mathf.Min(MaxVisibleItems, allPhotoData.Count); for (int i 0; i poolSize; i) { GameObject itemObj Instantiate(itemPrefab, content); RectTransform itemRT itemObj.GetComponentRectTransform(); itemRT.pivot new Vector2(0.5f, 1f); // 设定轴心方便从上往下布局 itemRT.anchorMin new Vector2(0, 1); itemRT.anchorMax new Vector2(1, 1); itemRT.sizeDelta new Vector2(0, itemHeight); // 宽度撑满高度固定 itemObj.SetActive(false); // 先全部隐藏 itemPool.Add(itemRT); } // 初始化防重复队列简单洗牌逻辑 ResetAvailablePhotoQueue(); } // 核心滚动回调 void OnScrollValueChanged(Vector2 normalizedPos) { // normalizedPos.y: 1表示顶部0表示底部对于Vertical Scroll UpdateVisibleItems(false); } // 更新当前需要显示的Item void UpdateVisibleItems(bool forceUpdate) { // 1. 计算当前视口应该显示的数据索引范围 float contentTopPos content.anchoredPosition.y; // Content顶部相对于Viewport顶部的距离 int newStartIndex Mathf.FloorToInt(contentTopPos / (itemHeight spacing)); int newEndIndex Mathf.CeilToInt((contentTopPos ViewportHeight) / (itemHeight spacing)); // 加上缓冲 newStartIndex Mathf.Max(0, newStartIndex - bufferCount); newEndIndex Mathf.Min(allPhotoData.Count, newEndIndex bufferCount); // 如果索引范围没变化且不是强制更新则跳过 if (!forceUpdate newStartIndex currentStartIndex newEndIndex currentEndIndex) return; // 2. 回收不再需要的Item ListRectTransform itemsToRecycle new ListRectTransform(); foreach (var kvp in itemToDataIndexMap) { int index kvp.Value; if (index newStartIndex || index newEndIndex) { itemsToRecycle.Add(kvp.Key); kvp.Key.gameObject.SetActive(false); } } foreach (var item in itemsToRecycle) { itemToDataIndexMap.Remove(item); // 这里可以将Item放回一个空闲池我们简单起见直接加入复用逻辑 } // 3. 为需要显示的数据索引分配或复用Item for (int dataIndex newStartIndex; dataIndex newEndIndex; dataIndex) { // 检查这个数据索引是否已经有Item在显示 bool alreadyHasItem false; foreach (var kvp in itemToDataIndexMap) { if (kvp.Value dataIndex) { alreadyHasItem true; break; } } if (alreadyHasItem) continue; // 找一个可用的Item隐藏的或回收的 RectTransform availableItem null; foreach (var item in itemPool) { if (!itemToDataIndexMap.ContainsKey(item) !item.gameObject.activeInHierarchy) { availableItem item; break; } } // 如果没有则从我们之前回收的里取第一个理论上不会发生因为池大小足够 if (availableItem null itemsToRecycle.Count 0) { availableItem itemsToRecycle[0]; itemsToRecycle.RemoveAt(0); } if (availableItem ! null) { SetupItemForIndex(availableItem, dataIndex); itemToDataIndexMap[availableItem] dataIndex; } } currentStartIndex newStartIndex; currentEndIndex newEndIndex; } // 设置Item的内容和位置 void SetupItemForIndex(RectTransform itemRT, int dataIndex) { // 1. 设置位置 (锚点在上方所以Y坐标为负) float yPos -dataIndex * (itemHeight spacing); itemRT.anchoredPosition new Vector2(0, yPos); // 2. 获取数据并更新UI PhotoData data allPhotoData[dataIndex]; ItemController itemController itemRT.GetComponentItemController(); if (itemController ! null) { itemController.SetData(data); } // 3. 激活Item itemRT.gameObject.SetActive(true); } // 防重复逻辑重置可用数据队列简易洗牌 void ResetAvailablePhotoQueue() { // 这里是一个简单的实现将allPhotoData乱序后放入队列。 // 更复杂的逻辑可以防止最近显示过的N张图片再次出现。 ListPhotoData shuffledList new ListPhotoData(allPhotoData); // 使用一个简单的随机洗牌算法 for (int i 0; i shuffledList.Count; i) { int randomIndex Random.Range(i, shuffledList.Count); var temp shuffledList[i]; shuffledList[i] shuffledList[randomIndex]; shuffledList[randomIndex] temp; } availablePhotoQueue new QueuePhotoData(shuffledList); } // 外部调用添加更多数据例如上拉加载更多 public void AppendData(ListPhotoData newData) { int oldCount allPhotoData.Count; allPhotoData.AddRange(newData); // 更新Content大小 InitializeContentSize(); // 将新数据加入防重复队列 foreach (var data in newData) { availablePhotoQueue.Enqueue(data); } // 更新显示因为内容高度变了当前显示区域可能需要调整 UpdateVisibleItems(true); } }3.3 Item控制器ItemController每个Item预制体上挂载的脚本负责根据数据更新自身UI。// ItemController.cs using UnityEngine; using UnityEngine.UI; using System.Collections; // 用于协程 public class ItemController : MonoBehaviour { public Image photoImage; public Text titleText; public string currentImageUrl; // 图片加载器引用可自己实现或使用UnityWebRequest, AssetBundle等 private ImageLoader imageLoader; void Awake() { imageLoader GetComponentImageLoader(); if (imageLoader null) { // 如果没有专门的Loader可以在这里实现简单的加载逻辑 } } public void SetData(PhotoData data) { // 更新文本 if (titleText ! null) titleText.text data.title; // 加载图片关键避免重复加载和内存泄漏 if (currentImageUrl ! data.imageUrl) { currentImageUrl data.imageUrl; LoadImageAsync(data.imageUrl); } } void LoadImageAsync(string url) { // 这里是一个示意性的异步加载流程 // 实际项目中你需要一个更健壮的图片加载管理器处理缓存、取消、失败重试等。 if (imageLoader ! null) { imageLoader.LoadImage(url); } else { // 简单示例使用协程和UnityWebRequest注意需处理取消 StartCoroutine(LoadImageWithWWW(url)); } } IEnumerator LoadImageWithWWW(string url) // 注意WWW已过时建议用UnityWebRequest { // 仅为示例生产环境请使用UnityWebRequest并加入取消逻辑 // 当Item被快速滚动复用旧的加载请求应该被取消。 Texture2D tex new Texture2D(2, 2); // ... 加载逻辑 ... // 加载完成后赋值给photoImage.sprite yield return null; } // 当Item被回收时调用用于清理资源 public void Clear() { currentImageUrl null; if (titleText ! null) titleText.text ; // 可以设置一个默认图或清空图片 if (photoImage ! null) photoImage.sprite null; // 取消正在进行的图片加载请求 if (imageLoader ! null) imageLoader.CancelCurrentLoad(); StopAllCoroutines(); } }3.4 防重复算法增强上面的ResetAvailablePhotoQueue是一个简单的全局洗牌。但在真实照片墙中我们可能希望“最近显示过的N张图片不要马上再次出现”。这里提供一个更实用的算法思路// 在InfiniteScrollView中添加 private Queuestring recentlyShownIds new Queuestring(); private int noRepeatRange 10; // 防止在此范围内重复 PhotoData GetNonRepeatingPhotoData(int targetIndex) { // 这是一个简化的示意函数你可能需要根据实际数据源调整 // 思路从availablePhotoQueue中取出一个检查其ID是否在最近显示的队列中 // 如果在就放回队列尾部取下一个直到找到一个不在最近列表里的或者尝试次数过多。 int maxAttempts availablePhotoQueue.Count; for (int i 0; i maxAttempts; i) { PhotoData candidate availablePhotoQueue.Dequeue(); availablePhotoQueue.Enqueue(candidate); // 取出来检查后无论如何都放回去保持队列循环 if (!recentlyShownIds.Contains(candidate.id)) { // 记录这个ID到“最近显示”队列 recentlyShownIds.Enqueue(candidate.id); // 保持队列长度移除最旧的记录 if (recentlyShownIds.Count noRepeatRange) { recentlyShownIds.Dequeue(); } return candidate; } } // 如果所有都重复了极端情况返回第一个 return availablePhotoQueue.Peek(); } // 然后在SetupItemForIndex中不再直接取allPhotoData[dataIndex]而是调用GetNonRepeatingPhotoData(dataIndex)。 // 注意这需要你的allPhotoData数据量远大于屏幕显示数量否则防重复没有意义。注意防重复逻辑会改变数据索引与显示内容的固定对应关系。如果你的业务强依赖“第N项必须显示某张固定图片”那么防重复逻辑需要更精巧的设计例如在数据层面预先处理好顺序而不是在显示时动态挑选。4. 场景搭建与参数配置实操理论说完我们来看看在Unity Editor里怎么把它搭起来。创建UI结构在Canvas下创建一个空GameObject命名为PhotoWall为其添加Scroll Rect组件和Mask组件或使用Image设置Maskable。这就是我们的viewport。在PhotoWall下创建一个空GameObject命名为Content作为Scroll Rect的Content。为其添加Vertical Layout Group组件但注意我们后续会用代码控制位置所以这里Layout Group的Child Controls Size和Child Force Expand最好先禁用或者不用Layout Group。更推荐的做法是不挂任何Layout Group完全由我们的脚本控制位置。将PhotoWallViewport拖拽给Scroll Rect的Viewport字段。将Content拖拽给Scroll Rect的Content字段。设置Scroll Rect的Movement Type为Clamped或ElasticScroll Sensitivity调整到舒适值。创建Item预制体在Content下创建一个Image作为背景再创建一个Image作为显示照片的容器photoImage一个TexttitleText。将它们摆好。将这个Item拖到Project窗口做成预制体。然后从场景的Content下删除它。为这个预制体添加ItemController脚本并将photoImage和titleText拖拽赋值。配置InfiniteScrollView在PhotoWallGameObject上添加InfiniteScrollView脚本。将场景中的Scroll Rect组件拖给scrollRect字段。将PhotoWall的RectTransform拖给viewport字段。将Content的RectTransform拖给content字段。将刚刚创建的Item预制体拖给itemPrefab字段。设置itemHeight为你的预制体实际高度如200spacing为间隔如10bufferCount为1或2。准备测试数据在InfiniteScrollView脚本的allPhotoData列表里通过Inspector面板手动添加一些测试用的PhotoData填写id和imageUrl可以使用本地Resources路径或测试网络图床URL。运行测试运行游戏你应该能看到一个可以滑动的列表但只创建了有限个Item实例。滑动时通过Log或Profiler观察GameObject的数量是稳定的不会随数据增多而增加。关键参数调试心得bufferCount这个值很关键。设为0快速滑动时边缘可能出现空白。设为1在大多数60帧情况下够用。如果滑动速度极快或者帧率较低可以设为2。但越大常驻的Item就越多会轻微增加开销。建议从1开始根据实际测试调整。itemHeight必须和预制体的实际高度一致否则计算的位置会错乱。如果你的Item高度可变这里就需要更复杂的动态计算逻辑。Content的锚点Pivot我们的计算基于Content左上角Pivot (0.5, 1)为起始点。如果你的布局方式不同例如从下往上需要相应调整位置计算公式中的正负号。5. 性能优化与深度避坑指南实现基本功能只是第一步要让它在生产环境中稳定高效还需要注意以下这些我踩过的坑。5.1 图片加载的巨坑与优化这是性能问题和Bug的重灾区。坑1异步加载与取消当Item被快速滚动复用时前一个分配给它的加载任务必须被取消否则不仅浪费网络/IO还可能导致图片显示错乱A项显示了B项的图。ItemController中的Clear()方法必须被InfiniteScrollView在回收Item时调用。解决方案使用UnityWebRequest并持有它的引用在Clear()时调用Abort()。或者使用一个统一的ImageLoaderManager每个加载请求都有一个唯一ID如Item实例ID数据ID新的加载请求会覆盖旧的。坑2内存泄漏与缓存直接使用Resources.Load或WWW/UnityWebRequest加载的纹理如果不手动管理会一直留在内存中。当你有成千上万张图片需要循环展示时内存会爆炸。解决方案实现一个LRU最近最少使用缓存。设定一个最大缓存纹理数量或内存上限。当Item的图片被替换时将其旧纹理的引用计数减一。当缓存满时释放最久未使用的纹理Resources.UnloadAsset或Destroy。Unity的AssetBundle有自身的缓存机制但也需要谨慎管理卸载。坑3图集与Sprite的滥用如果你使用Unity的Sprite图集并且每个Item的Image都引用同一个Sprite这没问题。但如果每个Item都需要不同的Sprite如用户头像动态创建大量Sprite也会增加开销。可以考虑使用Raw Image配合Texture2D但要注意Raw Image缺少Sprite的一些便利功能如九宫格拉伸。5.2 滑动卡顿的元凶即使Item数量固定滑动也可能卡顿。原因1Canvas的频繁重建如果Item内部有频繁变化的UI元素如倒计时文本会触发所在Canvas的批处理重建。确保所有动态变化的UI元素Text, Image都在同一个Canvas下并且这个Canvas下只有这个列表。最好将整个无限滚动列表放在一个独立的、嵌套的Canvas里与游戏其他UI隔离。原因2昂贵的操作在Update中OnScrollValueChanged在每一帧滚动时都会调用。UpdateVisibleItems方法里的计算要尽可能轻量。避免在这里进行复杂的查找、实例化或IO操作。原因3布局计算如果你错误地在Content上开启了Vertical Layout Group并且没有禁用Child Controls Size那么Unity会在每次Item位置变化时尝试重新布局造成巨大开销。我们的方案必须禁用所有自动布局组件完全由代码控制位置。5.3 防重复逻辑的边界情况数据量小于防重复范围如果你的总照片数allPhotoData.Count小于noRepeatRange比如你只有8张图却设置了防止10张内重复那么防重复算法会陷入死循环或失效。需要在逻辑开始前判断如果数据量太少就降级为普通随机或顺序显示。“随机性”与“用户预期”的平衡完全的随机防重复可能让用户觉得混乱。可以考虑“分段随机”例如每滑动20个Item从数据源中随机抽取一批在这批内部保证不重复然后下一批再换一批。这样既有变化又有一定的局部有序性。5.4 扩展性思考不规则高度与动态大小这是进阶需求。核心思路是需要预先知道或能快速计算出每一个Item的高度。方案A推荐服务端下发或本地预计算。如果Item高度由图片宽高比决定且图片URL已知可以在数据层预先计算好每个Item的最终显示高度并保存在PhotoData中。InfiniteScrollView需要维护一个“累积高度”的数组用于将滚动位置快速转换为数据索引。方案B运行时动态计算。为每个数据索引预先创建一个“测量用”的Item离屏或隐藏设置好数据等待一帧让Unity布局引擎计算出它的实际高度记录结果后销毁。这个过程可以异步进行在数据加载后预先计算一批。滚动时使用这些预先计算好的高度进行定位。计算公式升级Item的位置 前面所有Item高度之和。这就需要将itemHeight从固定值改为一个数组float[] itemHeights。TotalContentHeight也变为所有itemHeights的累加。UpdateVisibleItems中的索引计算需要用到二分查找在累积高度数组中定位而不是简单的乘除法。实现不规则高度是性能与功能的权衡会显著增加复杂度。如果业务非必需固定高度是性价比最高的选择。6. 常见问题排查与实战技巧这里记录了一些开发过程中必然会遇到的“坑”和解决办法。问题1滑动时Item闪烁或位置跳变。检查itemHeight和spacing的值是否与预制体实际尺寸、以及位置计算公式完全匹配。检查Content的Pivot和Anchor设置。确保UpdateVisibleItems中的索引计算逻辑正确特别是contentTopPoscontent.anchoredPosition.y的符号。垂直滚动时Content的Y坐标通常是负值表示向下移动了多少。问题2快速滑动到底部或顶部时出现空白。检查bufferCount是否足够。尝试将其增加到2。检查MaxVisibleItems的计算公式Mathf.CeilToInt(ViewportHeight / (itemHeight spacing)) bufferCount * 2。确保ViewportHeight获取正确有时在Start时Canvas未渲染完成高度为0可以在OnRectTransformDimensionsChange事件中或延迟一帧初始化。问题3图片加载慢滚动时先看到旧图再闪成新图。解决实现一个占位图机制。在ItemController中SetData时先显示一个本地占位图或上一张图然后发起异步加载。加载成功后替换。在Clear()时可以重置为占位图。同时必须实现加载取消如上文所述。问题4在Editor里运行正常打到真机上卡顿。排查使用Unity Profiler特别是Deep Profile连接真机查看CPU耗时最高的函数。大概率是Canvas.SendWillRenderCanvasesUI重建或我们的UpdateVisibleItems。优化方法见第5.2节。另外确保图片纹理的压缩格式如ASTC适合目标平台且Max Size设置合理避免加载超大纹理。问题5如何实现下拉刷新和上拉加载更多思路这通常与无限滚动列表本身解耦。可以在Content上方放置一个“RefreshPanel”下方放置一个“LoadMorePanel”。监听ScrollRect的滚动位置当normalizedPosition.y接近1顶部或0底部时触发相应的事件。加载新数据后调用InfiniteScrollView.AppendData()即可。注意加载更多时由于Content高度增加当前滚动位置可能需要微调以保持视觉连续性。一个实用的调试技巧在SetupItemForIndex方法里临时给Item的Image或Text加上当前数据索引dataIndex的显示。这样在运行时你可以清晰地看到每个Item当前代表的是数据列表中的第几项对于验证复用逻辑是否正确至关重要。最后这套无限滚动方案是一个强大的性能优化工具但它引入了额外的复杂度。对于小型列表几十项传统的ScrollView可能更简单快捷。但对于任何中大型列表投入时间实现它都是值得的。理解其“数据-视图”分离和“对象池复用”的核心思想比记住代码更重要。你可以根据具体项目需求在此基础上扩展出水平滚动、网格布局、动画插入删除等更复杂的功能。