Unity 排行榜 UI 优化:从全量生成到滚动复用


B站测验视频

一、RectTransform 的 Anchor / Pivot 基础

1.1 坐标系区分

Unity UI 里有两套容易混淆的坐标系:

  • Anchor / Pivot 的坐标系(0~1 范围,归一化)
    • 相对于父 RectTransform 的宽高做归一化:
      • (0, 0):左下角
      • (0, 1):左上角
      • (1, 0):右下角
      • (1, 1):右上角
  • anchoredPosition 的坐标系(像素)
    • 相对于锚点位置的 2D 偏移(单位:像素)
    • (0, 0) 表示「锚点 + pivot 那个点」正好对齐在一起

1.2 Anchor 的作用

  • AnchorMin / AnchorMax 决定子物体如何「附着」在父 Rect 上:
    • anchorMin = (0, 1); anchorMax = (0, 1) → 左上角一个点
    • anchorMin = (0, 1); anchorMax = (1, 1) → 水平方向拉伸到父节点的整个上边
  • 当父 Rect 大小变化时:
    • 如果 MinMax 不同,子 Rect 会跟着缩放 / 拉伸
    • 如果 Min == Max,子 Rect 当成一个固定大小的"点",只跟着移动,不跟着缩放

1.3 Pivot 的作用

  • Pivot 是自身矩形内部 的一个归一化点:
    • (0, 1):自身左上角
    • (0.5, 0.5):自身中心
    • (1, 0):自身右下角
  • 影响:
    • anchoredPosition 是针对 pivot 来偏移的
    • 旋转、缩放都会绕 pivot 旋转 / 缩放

1.4 典型布局:左上对齐的列表项

排行榜每一行 Item 最常见的设置:

  • Content
    • AnchorMin = (0, 1)
    • AnchorMax = (1, 1)(左右拉伸,靠上)
    • Pivot = (0.5, 1)
  • RankItem(单个条目):
    • AnchorMin = (0, 1)
    • AnchorMax = (0, 1)
    • Pivot = (0, 1)
    • anchoredPosition = (0, y)

含义:

  • Content 的上边永远贴着父节点上边,宽度随父节点变化
  • 每个 RankItem 的「左上角」作为参考点:
    • x = 0 → 靠 Content 左边对齐
    • y = 0 → 顶部第一行
    • y = -cellSize * index → 从上往下均匀排布

二、无限列表(虚拟列表)的整体思想

2.1 问题背景

  • 排行榜可能有大量数据(几十、几百、几千条)
  • 如果对每一条都 Instantiate 一个 UI:
    • 内存占用大
    • 生成瞬间卡顿
    • 滚动时也会更重

需求:只创建视口内能看到的那几屏 Item,但看起来像是有完整的长列表。

2.2 核心思路:复用少量「演员」

概念类比:

  • 数据 :后台的 List<RankItemClass>,比如长度 100
  • UI 物体 :显示一行的 RankItem 预制体
  • 对象池 / 演员池:只创建「屏幕能显示的数量 + 少量缓冲」

算法上做两件事:

  1. 根据滚动位置,计算当前需要显示的是第几条数据(start index)
  2. 把有限个 UI 物体重新定位并绑定到对应的数据上

用户眼里看到的是「列表在滚」,实际上是「同一批 UI 物体在换数据、换位置」。

2.3 关键参数

  • itemHeight:单个条目的高度
  • spacing:条目之间的间距
  • cellSize = itemHeight + spacing
  • viewPortHeight:可视区域的高度(通常是 ScrollRect 或 Viewport 的 rect.height
  • visibleCount ≈ viewPortHeight / cellSize + bufferCount
    • bufferCount:为了避免边缘刚出现就闪现的缓冲数量

2.4 Content 高度与滚动

  1. 撑开 Content 的高度

    csharp 复制代码
    totalHeight = cellSize * mockDataList.Count;
    content.sizeDelta = new Vector2(content.sizeDelta.x, totalHeight);
    • 这样 ScrollRect 认为「有这么高的内容可以滚」
    • 滚动条比例、可滚动范围就都正确了
  2. 监听 ScrollRect 滚动事件

    csharp 复制代码
    scrollRect.onValueChanged.AddListener(OnScroll);
    
    void OnScroll(Vector2 pos)
    {
        Refresh(content.anchoredPosition.y);
    }
    • 在 UGUI 里,Content 向上滚,anchoredPosition.y 会变大

2.5 计算当前起始数据索引(start index)

csharp 复制代码
float cellSize = itemHeight + spacing;
int startIdx = Mathf.FloorToInt(Mathf.Max(0, scrollY) / cellSize);
startIdx = Mathf.Clamp(startIdx, 0, Mathf.Max(0, mockDataList.Count - 1));

含义:

  • scrollY / cellSize ≈「已经滚走了多少行」
  • FloorToInt → 向下取整,得到当前应当显示的第一条的索引

为了防止频繁刷新,在起始索引没变的时候直接跳过:

csharp 复制代码
if (startIdx == prevStartIdx) return;
prevStartIdx = startIdx;

2.6 更新对象池里的「演员」

csharp 复制代码
for (int i = 0; i < visibleCount; i++)
{
    int dataIdx = startIdx + i;
    var itemRT = _pool[i];

    if (dataIdx < mockDataList.Count)
    {
        itemRT.gameObject.SetActive(true);

        float y = -dataIdx * cellSize;                // 从上往下排
        itemRT.anchoredPosition = new Vector2(0, y);  // 左上对齐

        UpdateItem(itemRT, dataIdx);                  // 绑定数据
    }
    else
    {
        itemRT.gameObject.SetActive(false);           // 超出范围就隐藏
    }
}

UpdateItem 只负责把「某条数据」刷到「某个 RankItem UI」上:

csharp 复制代码
void UpdateItem(RectTransform item, int index)
{
    if (index < 0 || index >= mockDataList.Count) return;

    RankItem ui = item.GetComponent<RankItem>();
    RankItemClass data = mockDataList[index];

    ui.Refresh(
        data.RankNumber,
        data.PlayerName,
        data.PlayerImagePath,
        data.CountryImagePath,
        data.WinNumber
    );
}

2.7 对象池初始化

csharp 复制代码
visibleCount = Mathf.CeilToInt(viewPortHeight / cellSize) + bufferCount;
_pool.Clear();
for (int i = 0; i < visibleCount; i++)
{
    var _item = Instantiate(rankItem, content);
    var rt = _item.GetComponent<RectTransform>();

    // 可选:在代码里强制做一次左上锚点
    // rt.anchorMin = new Vector2(0, 1);
    // rt.anchorMax = new Vector2(0, 1);
    // rt.pivot     = new Vector2(0, 1);

    _item.gameObject.SetActive(false);
    _pool.Add(rt);
}

三、普通列表 vs 无限列表

3.1 普通列表(当前 RankPanel

  • 做法:
    • 拿到 100 条数据
    • for 循环里 Instantiate 100 个 RankItem,依次 Refresh
  • 优点:
    • 实现简单、调试直观
    • 适合数据量不太大、UI 复杂度不高的界面
  • 缺点:
    • 数据很多时会有内存 / 性能问题
    • 一次性生成时可能有卡顿

3.2 无限列表(RankPanelInfinite

  • 做法:
    • 仍然准备 100 条数据,但只生成「几屏范围内」的 RankItem 物体
    • 滚动时复用这些物体,并根据位置换数据、换坐标
  • 优点:
    • UI 实例数基本恒定(几十个),内存占用更稳定
    • 初次加载快、不容易掉帧
  • 缺点:
    • 实现复杂度更高,特别是:
      • 锚点 / pivot
      • Content 高度
      • 滚动方向
      • 复用逻辑

四、实现过程里踩过的典型坑(可作为注意事项)

  • 1. itemHeight 在使用前没赋值
    • 用它算 visibleCount 之前必须先从 rankItem.rect.height 拿到真实值。
  • 2. 把总像素高度当成「数量」来比较
    • totalHeight = cellSize * count;
    • 判断是否越界时要用 dataIdx < count,不能用 dataIdx < totalHeight
  • 3. 锚点 / pivot 导致 Item 出现在左上角,只能看到一半
    • Prefab 默认中心锚点,父节点左上对齐 → 中心点在左上 → 左半被裁掉。
  • 4. ScrollRect 结构与 viewport 变量不一致
    • 实际层级没有 Viewport 节点时,可以直接把 ScrollRect 自己拖给 viewport 用。

csharp 复制代码
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class RankPanelInfinite : BasePanel
{
    public  RankItem rankItem;
    
     public RectTransform content;   
    public RectTransform viewport;
    public ScrollRect scrollRect;
    private List<RankItemClass> mockDataList = new List<RankItemClass>();




    public int spacing = 10;
    public int bufferCount = 3; //缓冲数量

    private List<RectTransform> _pool = new List<RectTransform>();
    private float itemHeight;
    private int visibleCount;
    private float totalDataCount; //总高度, 撑开到总高度
    private int prevStartIdx = -1;


 private void Start()
  {
    SetButtonListener();


    //   GenerateMockData();

       mockDataList = RankMockDataGenerator.GenerateMockRankData(100);


    itemHeight = rankItem.GetComponent<RectTransform>().rect.height;
      float cellSize = itemHeight + spacing;


     // 1. 计算需要多少个"演员" (可见数量 + 缓冲)
    float viewPortHeight = viewport.rect.height;

    visibleCount = Mathf.CeilToInt(viewPortHeight / cellSize) + bufferCount;

    totalDataCount = cellSize * mockDataList.Count;

       // 2. 核心操作:手动设置 Content 总高度
        // totalDataCount 是 所有数据的总高度,itemHeight 是 160,这里直接撑开到 最多需要显示的像素
        // 这样 ScrollRect 的滚动条比例才会显示正确,且不需要 ContentSizeFitter

        //ScrollRect 组件是根据 Content 的高度来决定滚动条(Handle)的大小和滑动范围的。
        content.sizeDelta = new Vector2(content.sizeDelta.x, totalDataCount);
    

          _pool.Clear();
     // 3. 实例化对象池 (Object Pooling)
        for (int i = 0; i < visibleCount; i++)
        {
            var _item = Instantiate(rankItem, content);
            _item.gameObject.SetActive(false);
            _pool.Add(_item.GetComponent<RectTransform>());
        }

   // 4. 监听 ScrollRect 的滚动事件
        scrollRect.onValueChanged.AddListener(OnScroll);

  
  // 5. 初次刷新一次,显示初始区域
      Refresh(0f);


    //   CreateRankList();
  }
 

 private void CreateRankList()
  {
   for (int i = content.childCount - 1; i >= 0; i--)
      {
          Destroy(content.GetChild(i).gameObject);
      }

   
      foreach (var data in mockDataList)
      {
  
          RankItem item = Instantiate(rankItem, content);
          item.gameObject.SetActive(true);
          item.Refresh(
              data.RankNumber,
              data.PlayerName,
              data.PlayerImagePath,
              data.CountryImagePath,
              data.WinNumber
          );

      }


  }

  void OnScroll(Vector2 pos)
  {
      // Unity UGUI 中,当 Content 向上滑时,其 anchoredPosition.y 会逐渐增大
      Refresh(content.anchoredPosition.y);
  }

//根据当前滚动的距离(scrollY)来决定:现在屏幕上应该显示哪几条数据,并把对象池里的 Item 排好位置、刷好数据。

  void Refresh(float scrollY)
  {
//空列表保护:
// 如果没有数据,直接返回,避免后面各种越界。
        if (mockDataList == null || mockDataList.Count == 0) return;

//一行所占的「总高度」:单个 Item 高度 + 行间距。
         float cellSize = itemHeight + spacing;

      // 1. 确定当前视口中第一条数据的索引 (Data Index)
      // 使用 Mathf.Max 确保不会出现负数索引

      //Mathf.Max(0, scrollY):防止因为精度之类出现负数。

        //    / cellSize:滚了多少像素 / 每行像素 = 大概滚过了多少行。
      // FloorToInt:往下取整 → 比如滚了 1.2 行,也当作「起点是第 1 行」。
     
      int startIdx = Mathf.FloorToInt(Mathf.Max(0, scrollY) / cellSize);

 // Clamp:再把 startIdx 限制在 [0, mockDataList.Count - 1] 之间,防止越界。
     startIdx = Mathf.Clamp(startIdx, 0, Mathf.Max(0, mockDataList.Count - 1));


  
      //startIdx = 当前屏幕顶部刚好或刚滚过去的那行数据的索引。 
          // 2. 帧过滤:如果起始索引没有变化,说明没滑出一个 Item 的距离,跳过刷新
      if (startIdx == prevStartIdx) return;
      prevStartIdx = startIdx;
      
      
      // 3. 更新所有"演员"的坐标和数据绑定
      for (int i = 0; i < visibleCount; i++)
      {
          int dataIdx = startIdx + i;
          // 使用取模运算循环复用 Pool 里的 UI 物体
          var itemRT = _pool[i];
          
          if (dataIdx <  mockDataList.Count)
          {
              itemRT.gameObject.SetActive(true);

                     // 计算每个 Item 的位置( 从上往下排)
              float y = -dataIdx * cellSize;
              itemRT.anchoredPosition = new Vector2(0, y);

              // 关键点:根据数据索引计算该条目的绝对位置 (Top Alignment)
              itemRT.anchoredPosition = new Vector2(0, y);
                
              // 触发数据绑定逻辑
              UpdateItem(itemRT, dataIdx);
          }
          else
          {
              // 超出总数据量范围,隐藏多余的"演员"
              itemRT.gameObject.SetActive(false);
          }
          
      }
      
      void UpdateItem(RectTransform item, int index)
      {
               if (index < 0 || index >= mockDataList.Count) return;
      RankItem ui = item.GetComponent<RankItem>();
      RankItemClass data = mockDataList[index];
      ui.Refresh(
          data.RankNumber,
          data.PlayerName,
          data.PlayerImagePath,
          data.CountryImagePath,
          data.WinNumber
      );

      }

  }

  void SetButtonListener()
    {

      GetUiContro<Button>("Button_Back").onClick.AddListener(() =>
    {
        UiManager.GetInstance().HidePanel("RankPanel");           
    });
    GetUiContro<Button>("Button_Home").onClick.AddListener(() =>
    {
        UiManager.GetInstance().HidePanel("RankPanel");          
    });

    }

}
相关推荐
Juicedata2 小时前
一文解锁 JuiceFS 在 AI 场景中的性能优化
人工智能·性能优化
CDN3602 小时前
游戏盾导致 Unity/UE 引擎崩溃?内存占用、SO 库冲突深度排查
游戏·unity·游戏引擎
心前阳光2 小时前
Unity之Luban使用流程
unity·游戏引擎
mxwin3 小时前
Unity URP 下的 GPU Instancing减少 DrawCall 的关键技术
unity·游戏引擎·shader
小贺儿开发3 小时前
Unity3D LED点阵屏幕模拟
http·unity·浏览器·网络通信·led·互动·点阵屏
智算菩萨4 小时前
【Pygame】第18章 游戏性能优化与帧率控制
游戏·性能优化·pygame
是席木木啊5 小时前
前端接口熔断:概念、场景、自定义封装及企业级库对比
性能优化·前端开发·接口熔断
RReality5 小时前
【Unity Shader】 溶解效果实战教程
unity·游戏引擎
mxwin5 小时前
Unity URP SRP Batcher 完全指南 URP/HDRP 下的核心批处理机制,大幅降低 CPU 开销
unity·游戏引擎·shader·单一职责原则