
1. 项目概述为什么ScrollView不够用在Unity3D的UI开发里ScrollView组件绝对是处理列表、图集这类滚动内容的首选开箱即用拖拖拽拽就能实现基础功能。但如果你做过一个需要展示大量图片的相册、一个商品无限刷新的商城或者就像标题里说的“照片墙”你大概率会遇到一个头疼的问题随着加载的图片越来越多界面卡顿、内存飙升甚至直接崩溃。这就是“别再只用ScrollView了”这句话的由来。原生的ScrollView或者说我们最常用的ScrollRect配合GridLayoutGroup或VerticalLayoutGroup是一种“有多少数据就生成多少UI元素”的暴力模式。想象一下你的照片墙有1000张图片它就会在Content下生成1000个Image组件。即便有RectMask2D裁剪这1000个GameObject的实例化、销毁、以及持续的Canvas重绘对性能都是毁灭性的打击。尤其是在移动设备上这种消耗是难以接受的。所以我们需要的是“无限滑动”或者说“循环列表”的核心思想只实例化刚好能填满可视区域的UI元素当元素滑出视野时立即回收并重新利用它们来显示即将进入视野的新数据。比如你的屏幕一次只能看到9张照片那我就只创建12个多几个做缓冲照片格子。无论后台数据是100条还是10000条屏幕上活跃的UI对象数量始终是恒定的。这能极大降低Draw Call、减少GC垃圾回收压力实现丝滑的滚动体验。这个项目就是带你从零开始手搓一个高性能、可复用的无限滑动照片墙。我会把核心逻辑掰开揉碎了讲并附上可以直接“抄作业”的代码。无论你是刚接触UI优化的新手还是想深入理解UI渲染机制的开发者这篇文章都能给你带来实实在在的收获。2. 核心思路与架构设计2.1 无限滚动的核心原理对象池与数据驱动要实现无限滚动关键在于解耦“数据索引”和“UI实体”。我们的大脑里需要建立两个并行的列表数据列表 (Data List)一个纯粹的数据结构比如ListPhotoData存储所有要展示的信息如图片的ID、URL、描述等。这个列表可以非常长。UI对象池 (UI Object Pool)一个固定大小的GameObject集合比如12个PhotoItem预制体。它们就像舞台上的演员。滚动时发生的故事是这样的初始状态根据滚动视图的起始位置计算出当前可视区域应该显示数据列表中的哪几条数据例如索引0-8。然后从对象池里取出对应数量的UI对象例如9个把这些数据“装配”到这些UI对象上并摆放到正确的位置。向上滚动最下方的UI对象逐渐移出可视区域顶部。此时我们不是销毁它而是将它回收到对象池并立刻从池中取出一个空闲的UI对象可能就是刚回收的那个用它来承载即将从底部进入视野的新数据例如索引9的数据然后把这个“新”对象放置到列表的底部。向下滚动逻辑相反回收顶部的对象用来显示底部的新数据。整个过程UI对象在“使用中”和“对象池”两种状态间循环数据像流水一样流过这些固定的“窗口”。这就是对象池 (Object Pool)模式在UI滚动中的经典应用。2.2 方案选型基于ScrollRect的自定义布局为什么不从头写一个滚动控件因为Unity的ScrollRect已经完美处理了拖拽、惯性、弹性等复杂的交互逻辑和输入事件我们没必要重复造轮子。我们的目标是“驯服”它而不是替换它。因此我们的架构将基于ScrollRect进行扩展控制器 (Controller)一个核心的MonoBehaviour脚本例如InfiniteScrollView挂载在ScrollRect所在的游戏对象上。它负责管理数据、对象池、监听滚动事件并在滚动时执行布局计算和对象回收/复用。布局计算器 (Layout Calculator)控制器内部的核心逻辑模块。它需要根据ScrollRect的Content的锚点、轴心、GridLayoutGroup的配置如果有的话精确计算出每个UI对象应该出现的位置。这是整个系统最需要细心处理数学的部分。物品模板 (Item Template)一个预制体代表照片墙中的每一张照片卡片。它需要包含必要的组件如Image用于显示图片或许还有Text显示标题以及一个关键的脚本例如ScrollViewItem用于接收控制器分配的数据并进行更新。数据绑定接口 (Data Binding Interface)为了解耦控制器不应该知道如何更新具体的Image.sprite或Text.text。我们可以定义一个简单的接口如IItemView让每个Item自己去实现如何用数据刷新自己的显示。选择这个方案是因为它平衡了开发效率、灵活性和性能。我们复用Unity官方的交互系统只专注于解决“无限”这个核心矛盾。2.3 性能考量与陷阱规避在设计之初就要想好性能陷阱Canvas重建频繁地SetActive或改变UI元素的层级、位置会触发Canvas的SendWillRenderCanvases导致批处理破坏和重建。我们的方案中UI对象是循环使用的位置变化是持续的因此要确保PhotoItem预制体本身结构简单避免嵌套过深的、独立的Canvas。图片加载这是照片墙最大的性能瓶颈。绝不能在图项被复用时同步加载高清大图。必须使用异步加载并且在图项被回收或数据被替换时立即取消未完成的加载请求否则会造成图片错乱将A的图片显示在B上。可以考虑为每个PhotoItem维护一个当前加载任务的引用或唯一标识。计算频率ScrollRect的onValueChanged事件在滚动时每帧都会触发。如果我们在其中进行复杂的计算如遍历所有Item依然会卡顿。我们需要优化只在滚动值变化超过一个阈值例如0.1时或者使用Coroutine进行节流处理来执行回收/复用检测。3. 核心组件与代码实现拆解3.1 数据模型与物品接口定义首先定义我们的数据。它应该尽可能轻量只存储必要信息。// PhotoData.cs [System.Serializable] public class PhotoData { public string id; // 唯一标识可用于缓存键 public string imageUrl; // 图片资源地址可以是远程URL或Resources路径 public string title; // 照片标题 // 可以扩展其他字段如拍摄时间、作者等 }接着定义一个所有可滚动项都需要实现的接口。这确保了控制器可以以统一的方式操作它们。// IItemView.cs public interface IItemView { // 物品在列表中的索引由控制器设置 int Index { get; set; } // 绑定数据的方法 void BindData(object data); // 当物品被回收进对象池时调用用于重置状态、取消加载任务等 void OnRecycle(); }我们的照片墙物品脚本需要实现这个接口。// PhotoItem.cs using UnityEngine; using UnityEngine.UI; using System; // 为了使用Action public class PhotoItem : MonoBehaviour, IItemView { public Image photoImage; public Text titleText; private int _index; public int Index { get _index; set _index value; } // 当前正在加载的图片协程或异步操作标识 private Coroutine _currentLoadRoutine; // 当前显示的数据ID用于校验 private string _currentDataId; public void BindData(object data) { if (data is PhotoData photoData) { _currentDataId photoData.id; titleText.text photoData.title; // **关键点1取消上一次可能未完成的加载** if (_currentLoadRoutine ! null) { StopCoroutine(_currentLoadRoutine); _currentLoadRoutine null; } // **关键点2先设置一个占位图可选** // photoImage.sprite placeholderSprite; // **关键点3异步加载图片** _currentLoadRoutine StartCoroutine(LoadImageAsync(photoData.imageUrl)); } } private System.Collections.IEnumerator LoadImageAsync(string url) { // 这里是简化示例。实际项目中你需要根据url类型处理 // 1. Resources.LoadAsync (本地Resources) // 2. AssetBundle.LoadAssetAsync (AssetBundle) // 3. UnityWebRequest (远程图片) - **注意网络加载务必考虑取消和超时** // 以下以Resources为例 ResourceRequest request Resources.LoadAsyncSprite(url); yield return request; // **关键点4加载完成后校验数据是否仍然匹配** if (request.asset ! null _currentDataId ! null) // 简单校验实际应根据索引或ID { photoImage.sprite request.asset as Sprite; } _currentLoadRoutine null; } public void OnRecycle() { // 取消任何进行中的加载 if (_currentLoadRoutine ! null) { StopCoroutine(_currentLoadRoutine); _currentLoadRoutine null; } // 清空显示避免错乱 photoImage.sprite null; titleText.text ; _currentDataId null; } }注意LoadImageAsync中的图片加载逻辑是高度简化的。生产环境中你必须实现一个健壮的图片加载管理器它应该处理多种来源本地、远程AB、远程Web、内存缓存避免重复加载、下载队列、优先级、失败重试以及最重要的——加载取消。当OnRecycle被调用时必须能通过某个唯一标识如_currentDataId或Index通知加载管理器取消对应任务。3.2 无限滚动视图控制器核心逻辑这是最核心的脚本InfiniteScrollView.cs。我们将它挂载到包含ScrollRect的物体上。// InfiniteScrollView.cs using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; public class InfiniteScrollView : MonoBehaviour { [SerializeField] private ScrollRect scrollRect; // 关联的ScrollRect [SerializeField] private RectTransform viewport; // 可视区域通常是ScrollRect的Viewport [SerializeField] private RectTransform content; // 内容区域 [SerializeField] private GameObject itemPrefab; // 物品预制体 [SerializeField] private GridLayoutGroup gridLayout; // 可选如果使用网格布局 [SerializeField] private bool isVertical true; // 滚动方向 private ListIItemView _activeItems new ListIItemView(); // 当前活跃的物品视图 private QueueRectTransform _itemPool new QueueRectTransform(); // 对象池 private Listobject _dataList new Listobject(); // 所有数据 // 布局相关参数 private float _itemSize; // 单个物品含间隔在滚动方向上的尺寸 private int _itemsPerRow 1; // 每行/列物品数网格布局时1 private int _totalRows; // 总行/列数根据数据量计算 private Vector2 _viewportSize; // 可视区域尺寸 private int _currentStartIndex 0; // 当前可视区域起始数据索引 private int _currentEndIndex 0; // 当前可视区域结束数据索引 void Start() { if (scrollRect null) scrollRect GetComponentScrollRect(); if (viewport null) viewport scrollRect.viewport; if (content null) content scrollRect.content; // 初始化布局参数 CalculateLayoutParameters(); // 初始化对象池创建比刚好填满屏幕多一些的物品作为缓冲 InitializeItemPool(); // 设置滚动回调 scrollRect.onValueChanged.AddListener(OnScrollValueChanged); // 首次刷新显示 UpdateVisibleItems(true); } // 设置数据源并刷新视图 public void SetData(Listobject data) { _dataList data; CalculateTotalRows(); UpdateContentSize(); UpdateVisibleItems(true); } // 计算关键布局参数 private void CalculateLayoutParameters() { _viewportSize viewport.rect.size; if (gridLayout ! null) { // 使用网格布局 Vector2 cellSize gridLayout.cellSize; Vector2 spacing gridLayout.spacing; _itemsPerRow isVertical ? Mathf.Max(1, Mathf.FloorToInt((_viewportSize.x - gridLayout.padding.horizontal spacing.x) / (cellSize.x spacing.x))) : Mathf.Max(1, Mathf.FloorToInt((_viewportSize.y - gridLayout.padding.vertical spacing.y) / (cellSize.y spacing.y))); _itemSize isVertical ? (cellSize.y spacing.y) : (cellSize.x spacing.x); } else { // 使用单一布局如VerticalLayoutGroup需要从预制体获取尺寸 _itemsPerRow 1; RectTransform prefabRect itemPrefab.GetComponentRectTransform(); _itemSize isVertical ? prefabRect.rect.height : prefabRect.rect.width; // 注意如果使用LayoutGroup这里可能需要加上spacing为了简化先忽略 } } // 根据数据总量计算总行数垂直滚动/总列数水平滚动 private void CalculateTotalRows() { if (_dataList.Count 0) { _totalRows 0; return; } _totalRows Mathf.CeilToInt((float)_dataList.Count / _itemsPerRow); } // 更新Content的尺寸使其能容纳所有数据 private void UpdateContentSize() { float totalSize _totalRows * _itemSize; if (isVertical) { content.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, totalSize); // 水平方向通常设为Viewport的宽度或由布局控制 content.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, _viewportSize.x); } else { content.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, totalSize); content.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, _viewportSize.y); } } // 初始化对象池 private void InitializeItemPool() { // 计算需要多少个Item来填满Viewport并多加一行/列作为缓冲 int visibleItemCount Mathf.CeilToInt((isVertical ? _viewportSize.y : _viewportSize.x) / _itemSize) * _itemsPerRow; int poolSize visibleItemCount _itemsPerRow * 2; // 缓冲两行 for (int i 0; i poolSize; i) { GameObject go Instantiate(itemPrefab, content); go.SetActive(false); _itemPool.Enqueue(go.GetComponentRectTransform()); } } // 从对象池获取一个物品 private RectTransform GetItemFromPool() { if (_itemPool.Count 0) { return _itemPool.Dequeue(); } // 如果池子空了动态实例化一个新的理论上不应该发生但保底 GameObject go Instantiate(itemPrefab, content); go.SetActive(false); return go.GetComponentRectTransform(); } // 回收一个物品到对象池 private void RecycleItemToPool(RectTransform itemRect) { IItemView itemView itemRect.GetComponentIItemView(); if (itemView ! null) { itemView.OnRecycle(); // 通知物品进行清理 } itemRect.gameObject.SetActive(false); _itemPool.Enqueue(itemRect); } // 核心根据当前滚动位置计算应该显示哪些数据并更新物品 private void UpdateVisibleItems(bool forceUpdate false) { if (_dataList.Count 0 || _itemSize 0) { ClearAllActiveItems(); return; } // 1. 计算当前可视区域在Content空间中的范围 Vector3[] viewportCorners new Vector3[4]; viewport.GetWorldCorners(viewportCorners); Vector3[] contentCorners new Vector3[4]; content.GetWorldCorners(contentCorners); // 将Viewport的角点转换到Content的局部空间 for (int i 0; i 4; i) { viewportCorners[i] content.InverseTransformPoint(viewportCorners[i]); } // 计算在滚动方向上的可视范围 float viewportMin isVertical ? viewportCorners[0].y : viewportCorners[0].x; float viewportMax isVertical ? viewportCorners[2].y : viewportCorners[2].x; // 2. 计算对应的起始和结束行索引 int newStartRow Mathf.FloorToInt((viewportMin) / _itemSize); int newEndRow Mathf.CeilToInt((viewportMax) / _itemSize); // 限制在有效范围内 newStartRow Mathf.Clamp(newStartRow, 0, _totalRows - 1); newEndRow Mathf.Clamp(newEndRow, 0, _totalRows - 1); // 转换为数据索引 int newStartIndex newStartRow * _itemsPerRow; int newEndIndex Mathf.Min((newEndRow 1) * _itemsPerRow, _dataList.Count) - 1; // 如果索引没变化且不是强制刷新则跳过 if (!forceUpdate newStartIndex _currentStartIndex newEndIndex _currentEndIndex) { return; } // 3. 回收不再可见的物品 for (int i _activeItems.Count - 1; i 0; i--) { IItemView item _activeItems[i]; if (item.Index newStartIndex || item.Index newEndIndex) { RecycleItemToPool((item as MonoBehaviour).GetComponentRectTransform()); _activeItems.RemoveAt(i); } } // 4. 为需要新显示的数据获取物品并设置 for (int dataIndex newStartIndex; dataIndex newEndIndex; dataIndex) { // 检查这个数据是否已经有活跃的物品在显示 bool alreadyActive false; foreach (var activeItem in _activeItems) { if (activeItem.Index dataIndex) { alreadyActive true; break; } } if (alreadyActive) continue; // 从池中获取物品 RectTransform itemRect GetItemFromPool(); IItemView itemView itemRect.GetComponentIItemView(); // 计算该数据对应的位置 int row dataIndex / _itemsPerRow; int col dataIndex % _itemsPerRow; Vector2 itemPosition CalculateItemPosition(row, col); // 设置物品位置、索引和数据 itemRect.anchoredPosition itemPosition; itemView.Index dataIndex; itemView.BindData(_dataList[dataIndex]); itemRect.gameObject.SetActive(true); _activeItems.Add(itemView); } // 5. 更新当前索引范围 _currentStartIndex newStartIndex; _currentEndIndex newEndIndex; } // 根据行、列计算物品的 anchoredPosition private Vector2 CalculateItemPosition(int row, int col) { float xPos 0, yPos 0; if (gridLayout ! null) { // 网格布局的位置计算 Vector2 cellSize gridLayout.cellSize; Vector2 spacing gridLayout.spacing; RectOffset padding gridLayout.padding; if (isVertical) { // 垂直滚动内容从上到下排列 xPos padding.left col * (cellSize.x spacing.x); yPos -padding.top - row * (cellSize.y spacing.y); // Y轴向下为负 } else { // 水平滚动内容从左到右排列 xPos padding.left row * (cellSize.x spacing.x); yPos -padding.top - col * (cellSize.y spacing.y); } } else { // 单一纵向/横向排列 if (isVertical) { yPos -row * _itemSize; // 假设从顶部开始向下排列 } else { xPos row * _itemSize; // 假设从左开始向右排列 } } return new Vector2(xPos, yPos); } private void ClearAllActiveItems() { foreach (var item in _activeItems) { RecycleItemToPool((item as MonoBehaviour).GetComponentRectTransform()); } _activeItems.Clear(); } // 滚动值变化时的回调 private void OnScrollValueChanged(Vector2 normalizedPos) { // 可以在这里加入节流逻辑例如每0.1秒检测一次避免每帧计算 UpdateVisibleItems(); } void OnDestroy() { if (scrollRect ! null) { scrollRect.onValueChanged.RemoveListener(OnScrollValueChanged); } } }这段代码是核心骨架它完成了数据管理、对象池、布局计算和动态更新的闭环。CalculateItemPosition函数需要根据你的实际布局是否使用GridLayoutGroup锚点设置进行精确调整这是最容易出bug的地方。3.3 场景搭建与参数配置现在让我们在Unity编辑器中将其组装起来。创建UI结构在Canvas下创建一个空物体命名为PhotoWall。为PhotoWall添加ScrollRect组件。设置Movement Type为Elastic弹性或Clamped夹紧看需求。取消勾选Horizontal只保留Vertical对于垂直滚动墙。将PhotoWall的RectTransform锚点设置为Stretch铺满整个Canvas。在PhotoWall下创建一个Image作为Viewport将其Source Image设为空Color的A值设为0完全透明。将其锚点也设为Stretch并留出你想要的边距例如上下各50像素。将这个Image拖拽到ScrollRect的Viewport属性上。在PhotoWall下再创建一个空物体作为Content将其锚点设为Top-Stretch对于垂直滚动。将其拖拽到ScrollRect的Content属性上。为Content添加Grid Layout Group组件并设置你想要的Cell Size、Spacing和Padding。关键一步将Grid Layout Group的Start Corner设为Upper LeftStart Axis设为HorizontalConstraint设为Flexible。这决定了布局的起始方向和排列方式。创建物品预制体在Content下创建一个Image作为照片的容器命名为PhotoItemPrefab。为其添加PhotoItem脚本。在PhotoItemPrefab下创建一个子Image用于显示照片再创建一个Text显示标题。在PhotoItem脚本的Inspector面板上将这两个组件拖拽到对应的公共字段上。将这个PhotoItemPrefab从Hierarchy拖到Project窗口生成一个预制体。然后删除Hierarchy中的实例。配置控制器选中PhotoWall物体添加InfiniteScrollView脚本。将ScrollRect、Viewport、Content、Item Prefab和Grid Layout Group分别拖拽到脚本的对应字段。确认Is Vertical是否勾选正确。提供测试数据创建一个测试脚本例如TestDataProvider.cs挂载在某个物体上。在Start中生成一个ListPhotoData并调用InfiniteScrollView的SetData方法。运行游戏你应该能看到一个可以流畅滑动、无论有多少数据都只使用固定数量UI对象的照片墙了。4. 高级优化与常见问题排查4.1 性能优化进阶技巧基础的无限滚动实现了但要达到生产级应用还需要以下优化滚动事件节流ScrollRect.onValueChanged在快速滚动时每帧触发频繁调用UpdateVisibleItems中的复杂计算如GetWorldCorners和空间转换可能成为瓶颈。一个简单的优化是使用协程进行节流。// 在InfiniteScrollView中添加 private bool _isUpdating false; private void OnScrollValueChanged(Vector2 normalizedPos) { if (!_isUpdating) { StartCoroutine(ThrottledUpdateVisibleItems()); } } private System.Collections.IEnumerator ThrottledUpdateVisibleItems() { _isUpdating true; UpdateVisibleItems(); // 立即更新一次 yield return new WaitForSeconds(0.05f); // 等待0.05秒 _isUpdating false; }更精细的做法可以判断滚动速度当速度极快时降低更新频率当滚动停止或减速时再精确更新。图片加载管理器 这是重中之重。你需要一个中心化的ImageLoader单例或服务。它应该维护一个Dictionarystring, Sprite作为内存缓存。管理一个加载队列避免同一帧发起过多网络请求。为每个加载请求分配一个唯一的CancellationTokenSource当PhotoItem.OnRecycle()被调用时能通过数据ID或索引找到并取消对应的加载任务。实现占位图、加载失败图、渐进式加载对于大图等功能。Canvas分层与合批 确保所有PhotoItem都在同一个Canvas下并且没有嵌套的子Canvas。如果照片墙非常复杂考虑使用RectMask2D而不是Mask组件因为前者性能更好。检查Unity Profiler中Canvas.SendWillRenderCanvases的耗时如果过高说明有频繁的布局重建需要检查是否有代码在频繁修改LayoutGroup的属性或GameObject的激活状态。4.2 常见问题与解决方案实录在实际使用中你几乎一定会遇到下面这些问题问题1滚动时物品闪烁、错位或重叠。原因ACalculateItemPosition函数计算错误特别是当使用GridLayoutGroup且Content的锚点/轴心点设置不当时。anchoredPosition是相对于锚点的位置务必理清坐标系。排查在UpdateVisibleItems中打印出计算出的itemPosition和物品实际的anchoredPosition进行对比。确保Content的轴心点Pivot设置正确垂直滚动通常为(0.5, 1)即顶部中心水平滚动为(0, 0.5)即左侧中心。原因B_itemSize计算错误没有把GridLayoutGroup的Spacing算进去。解决仔细检查CalculateLayoutParameters方法中_itemSize的计算公式确保是cellSize spacing。问题2快速滚动到底部或顶部时出现空白区域。原因对象池大小不足或者UpdateVisibleItems中计算newStartRow和newEndRow的缓冲不够。当滚动过快时新数据需要显示但回收的旧物品还没来得及被复用。解决增加对象池的初始化数量InitializeItemPool中的poolSize比如从 _itemsPerRow * 2改为 _itemsPerRow * 4。同时在计算可视范围时可以主动扩大一下newStartRow和newEndRow的范围进行“预加载”。int bufferRows 1; // 预加载一行 int newStartRow Mathf.FloorToInt((viewportMin) / _itemSize) - bufferRows; int newEndRow Mathf.CeilToInt((viewportMax) / _itemSize) bufferRows;问题3图片加载混乱A的图片显示在B上。原因异步加载图片时没有做好任务取消和结果校验。当Item A的加载任务还没完成时它被回收并用于显示数据B。此时A的加载任务完成错误地将图片设置给了B。解决这就是为什么在PhotoItem.BindData中要先取消旧任务并在LoadImageAsync完成后要校验_currentDataId。更健壮的做法是将加载任务交给中心化的ImageLoader每个请求携带一个与当前数据绑定的唯一令牌Token加载完成后ImageLoader回调时携带这个令牌Item只处理与自己当前令牌匹配的结果。问题4在编辑器中运行正常真机上卡顿。原因GetWorldCorners和InverseTransformPoint在每帧调用是昂贵的操作尤其是在低端移动设备上。优化将UpdateVisibleItems中的GetWorldCorners和空间转换计算移到OnScrollValueChanged外部只在Viewport或Content的尺寸发生变化时可通过LayoutGroup的OnRectTransformDimensionsChange事件或自己检测重新计算一次_viewportSize和转换矩阵。考虑使用基于normalizedPosition的近似计算来替代精确的世界空间计算虽然精度稍差但性能更好。例如visibleDataRange totalDataCount * (normalizedPos, normalizedPos viewportPercentage)。问题5如何实现下拉刷新和上拉加载更多思路监听ScrollRect的verticalNormalizedPosition垂直或horizontalNormalizedPosition水平。当下拉时该值会大于1.0如果Content尺寸大于Viewport或接近0取决于锚点。可以设置一个阈值如verticalNormalizedPosition 0.95表示接近顶部触发下拉刷新 0.05表示接近底部触发上拉加载。实现在InfiniteScrollView中添加相关事件Action OnPullToRefresh和Action OnLoadMore并在OnScrollValueChanged中判断位置触发事件。外部脚本监听这些事件去请求新数据然后调用SetData或一个新增的AppendData方法。4.3 扩展思考从照片墙到通用列表我们这个InfiniteScrollView虽然以照片墙为例但其核心是一个数据虚拟化的滚动视图。你可以很容易地将其改造成一个通用的无限滚动列表抽象数据绑定将IItemView接口做得更通用BindData接受一个泛型T。支持多种预制体可以通过数据中的某个类型字段来指定使用不同的ItemPrefab。这需要维护多个对象池。动态尺寸项这是最大的挑战。如果每个Item高度不固定如朋友圈图文需要预先计算或异步计算每一项的高度并维护一个累积高度数组。滚动时需要通过二分查找来定位起始项。这涉及到更复杂的算法如Unity的ListViewUI Toolkit或社区插件EnhancedScroller所实现的。手写一个无限滚动的过程是对UnityUI系统、RectTransform坐标系、对象池模式和异步编程的一次深度历练。它强迫你去思考性能、去处理边界情况、去设计解耦的架构。当你成功实现并优化好它之后面对任何需要展示大量数据的UI需求你都会游刃有余。