Unity UGUI ScrollView 动态内容布局与性能优化实战

Unity UGUI ScrollView 动态内容布局与性能优化实战 1. ScrollView 在动态内容面前为什么会直接失灵接手一个界面工程的时候能在五分钟内暴露作者对 UGUI 理解深度的往往就是 ScrollView 里那点内容。静态的、条目数量写死的列表谁都能搭出来一旦条目数量会随数据变化问题就全冒出来了条目一多内容直接顶出屏幕外滚动条抓住也拖不动条目一少底部留出一大片空背景连底图都对不齐。更糟的是某些情况下你会看到条目在屏幕里疯狂抖动或者打开界面的一瞬间列表整体闪一下才归位。这些现象背后其实是同一件事Content 这个节点的尺寸没有随着子节点的变化重新计算。ScrollView 本身并不懂内容有多高它只是读取Content的RectTransform尺寸来决定可滚动范围。你往 Content 里塞了两个条目Content 的高度还是上次那个值ScrollRect 自然就按错误的范围去限制滚动位置。我见过最常见的错误写法是创建一个 ScrollView然后把 Content 的 Height 手动改成 200再往里面拖两个 Item发现显示不全就把 Height 改成 400。这种手调尺寸的做法在编辑器里看是好的一进真机、一换分辨率、一改语言立刻崩盘。正确思路是让布局系统自己算Content 的高度等于所有子节点高度加间距加内边距的总和这个总和由布局组件推导而不是由你手动填。下面这三件事是判断一个 ScrollView 是否动态自洽的最低标准条目增删之后Content 的高度自动变成新的总和不需要你写任何一行改尺寸的代码。条目数量少到撑不满一屏时Content 不会被拉伸填满 Viewport也不会出现可以上下拖动的空白。单个条目的内容变高变矮比如展开一段长文本、换行变多列表总高度跟着变滚动范围跟着变。做到这三点核心只有两个组件的配合布局组LayoutGroup比如VerticalLayoutGroup和尺寸适配器ContentSizeFitter。很多人只加了其中一个然后开始怀疑人生。1.1 Viewport、Content、ScrollRect 三者的分工先把职责划清楚后面所有问题都好定位。ScrollRect挂在根节点上它是个控制器手里握着content、viewport两个引用以及一组关于滚动行为的参数惯性、阻尼、弹性、灵敏度。它每帧做的事情大致是读取content的尺寸和viewport的尺寸算出可滚动的范围把用户的拖拽和滚轮输入换算成content.anchoredPosition的偏移再夹在这个范围里。Viewport是窗口它负责裁剪。它身上通常有一个Mask或者RectMask2D还有一个ImageMask需要RectMask2D不需要。Viewport 的尺寸决定了你能看到多大一块内容。Content是纸所有条目都贴在它上面。它的尺寸决定了这张纸有多长。ScrollView 能不能滚滚多远全看这张纸的长度和窗口的长度之比。理解了这个分工你会发现一个反直觉的结论ScrollRect 本身几乎不参与内容该多高的决策。它默认 Content 的尺寸是有人替你算好的。所以内容多少大小可以动态自动调整这个需求本质上是给 Content 算尺寸而不是调 ScrollRect。1.2 ContentSizeFitter 与 LayoutGroup 到底谁在干活这两个组件的分工很容易搞混我用一句话概括LayoutGroup 负责算出一个期望尺寸ContentSizeFitter 负责把这个期望尺寸写回 RectTransform。VerticalLayoutGroup实现了ILayoutElement接口它会遍历所有子节点只统计 active 的、且没有被LayoutElement.ignoreLayout忽略的算出三个值最小高度、期望高度、弹性高度。它的计算依据是子节点各自的preferredHeight、间距spacing、内边距padding以及childForceExpandHeight这类开关。ContentSizeFitter同样实现ILayoutElement但它的行为模式是反过来的它读取自己所在 GameObject 上从布局系统拿到的期望尺寸然后直接设置RectTransform.sizeDelta。它有两个枚举参数Horizontal Fit和Vertical Fit取值是Unconstrained、MinSize、PreferredSize。对于竖向滚动列表标准配置是Vertical Fit PreferredSizeHorizontal Fit Unconstrained。这里有个新手最容易踩的细节如果你把Horizontal Fit也设成PreferredSize同时 Content 的水平锚点是拉伸的那水平方向的尺寸会被算出来的期望宽度覆盖掉横屏、宽屏下的表现会非常奇怪。竖向列表原则上只管竖向横向交给锚点拉伸去适配父级宽度。还有一个高频疑问为什么两个都加了还是不生效答案通常藏在锚点和 Pivot 里下一节展开。1.3 一眼判断组合是否正确的检查表在动手搭之前先记住这张表搭完之后对着核一遍检查项竖向滚动列表的正确取值出问题时的典型表现Content 锚点左上角对齐水平拉伸宽度不跟随 Viewport换分辨率后错位Content Pivot(0.5, 1)Y 轴在顶部内容增加时列表整体往上长视觉上跳ContentSizeFitterVertical PreferredSize高度永远是旧值滚不到底部LayoutGroupVerticalLayoutGroup间距与 padding 和设计稿一致条目贴边、重叠、间距不均Viewport 裁剪RectMask2D 或 Mask 至少有一个内容溢出到 ScrollView 外面ScrollRect 引用content 指向 Contentviewport 指向 Viewport拖拽没反应或整块 UI 被拖走Pivot 那一项值得多说两句。ScrollRect 在夹紧滚动位置时用的是content.anchoredPosition和 Pivot 的组合。如果 Pivot 的 Y 是 0.5居中那么当 Content 高度变化时它会以中心为基准上下同时扩张视觉上就是列表从中间往两头长用户会觉得内容在抖。把 Pivot 设成 (0.5, 1)扩张方向就只有向下符合直觉。2. 从零搭一个会长高的滚动列表概念说清楚了我们真正搭一遍。整个过程我会把每一步为什么这么做讲明白而不是给你一堆勾选项。因为这一步的配置如果只是照抄等你遇到 GridLayoutGroup 或者横向列表时还是会懵。2.1 层级结构每一层都在承担什么标准层级是这样ScrollView (RectTransform Image ScrollRect Mask?) └── Viewport (RectTransform RectMask2D) └── Content (RectTransform VerticalLayoutGroup ContentSizeFitter) ├── Item_0 (RectTransform LayoutElement) ├── Item_1 └── ...有人喜欢在 Viewport 外面再包一层用来做边缘渐隐或者圆角遮罩这是可以的但只要保证 ScrollRect 的viewport引用指向真正的裁剪层就行。Viewport 我强烈建议用RectMask2D而不是Mask。原因很实际Mask依赖模板缓冲stencil会给整个 Canvas 增加额外的 draw call 和状态切换RectMask2D只做矩形裁剪纯 CPU 端的裁剪判断开销小得多而且在移动端上表现明显更稳。唯一需要注意的是RectMask2D只能裁矩形做不了圆形或者异形裁剪如果你的设计需要圆形头像列表的滚动那就只能退回Mask并给它一个圆形 Sprite。Content 上的组件顺序不影响结果但必须两个都在。VerticalLayoutGroup的参数建议Padding上下左右内边距直接对应设计稿里内容区到边框的距离别用给第一个和最后一个 Item 加空节点这种土办法来模拟。Spacing条目间距。这一项直接影响最终总高度的计算务必和设计稿对齐。Child Alignment一般Upper Center。Control Child Size的 Width 勾上、Height 不勾Child Force Expand的 Width 勾上、Height 不勾。最后这一组开关特别容易搞错。Control Child Size的 Width 勾上意思是由父级布局组统一管理子节点宽度子节点的宽度被拉满 Content 宽度减去 paddingForce Expand的 Width 勾上则是让内容不足时也平均拉伸。而 Height 相关的一律不勾因为我们要让每个条目自己决定高度。2.2 锚点、Pivot、SizeDelta 的具体填法这一节直接把数值列出来。假设 Viewport 是 800x600Content 的RectTransformAnchor Min (0, 1)Anchor Max (1, 1)Pivot (0.5, 1)Pos X 0Pos Y 0Width 0因为水平是拉伸的这个值由锚点决定改它没用Height 任意值会被 ContentSizeFitter 覆盖解释一下为什么锚点要设成 (0,1) 到 (1,1)这是一种顶部横向拉伸的锚定Anchor Min.x ! Anchor Max.x意味着水平方向跟随父级宽度Anchor Min.y Anchor Max.y 1意味着垂直方向固定在顶部高度由sizeDelta.y表达。这样一来Viewport 宽度变了Content 宽度自动跟着变条目宽度也就跟着变不需要任何代码。Pos Y设成 0 是有讲究的。当 Pivot 的 Y 是 1 时anchoredPosition.y表示的是 Content 顶部相对锚点Viewport 顶部的偏移。滚动到底部时这个值会变大因为内容往上推。2.3 为什么在 Awake 里改尺寸会没反应现在你已经有了一个自动增高的 Content接下来大概率会遇到这个经典现象你在Awake或者Start里往 Content 里 Instantiate 了十个条目然后马上打印content.rect.height结果是 0 或者一个明显不对的旧值。原因在于 UGUI 的布局更新是延迟的。布局系统的更新流程大致是标记为 dirty 的布局节点被塞进一个队列在Canvas.willRenderCanvases这个时机之前统一重建。也就是说你在同一帧里改了子节点的结构布局还没算rect.height还是上一次的结果。解决办法有两个方向第一个是逼它立刻算// 数据装填完成之后 LayoutRebuilder.ForceRebuildLayoutImmediate(contentRect); float realHeight contentRect.rect.height;ForceRebuildLayoutImmediate会递归地强制重建这棵布局子树代价是同步执行、跳过缓存频繁调用会掉帧。它的参数要传 Content 自己的RectTransform。第二个是等它自己算把依赖尺寸的逻辑放到下一帧或者用协程yield return null之后再读。但如果你需要在一帧内把滚动位置也设对比如打开界面就定位到某一条那必须走第一个方案。还有一个更隐蔽的坑LayoutRebuilder.ForceRebuildLayoutImmediate只重建它这一棵子树如果 Content 的父级布局也影响 Content 的尺寸比如 Viewport 上挂了布局组那得从更高的节点开始重建。我一般的习惯是在不确定层级影响范围时先调Canvas.ForceUpdateCanvases()再对 Content 调ForceRebuildLayoutImmediate。多花的这点开销换来的是行为可预测。3. 动态增删条目的代码与刷新时机搭好之后日常业务代码就是在做三件事加条目、删条目、改条目。这三件事每一个都会让 Content 高度失效但失效之后什么时候重新算、算完之后要不要补偿滚动位置才是真正考验人的地方。3.1 用数据驱动生成条目我习惯把列表封装成一个组件外部只喂数据public class DynamicList : MonoBehaviour { [SerializeField] private ScrollRect scrollRect; [SerializeField] private RectTransform content; [SerializeField] private GameObject itemPrefab; private readonly ListGameObject _spawned new ListGameObject(); private RectTransform ContentRect content; public void SetData(IListstring data) { Clear(); for (int i 0; i data.Count; i) { var go Instantiate(itemPrefab, content); go.SetActive(true); var item go.GetComponentListItem(); item.Bind(data[i], i); _spawned.Add(go); } RefreshLayout(keepPosition: false); } private void Clear() { for (int i 0; i _spawned.Count; i) { Destroy(_spawned[i]); } _spawned.Clear(); } }注意Instantiate(itemPrefab, content)这个重载第二个参数会把新对象的父级直接设成content并且保留 prefab 自身的 localScale、localRotation 和 anchoredPosition。而如果你用的是Instantiate(prefab)再手动SetParent(content)会用到worldPositionStays true的默认行为UI 元素的位置会被世界坐标保持搞乱出现条目跑到屏幕外的情况。这个坑我踩过不止一次最稳妥的写法就是始终用带父级参数的重载或者显式传SetParent(content, false)。另外要强调的是Sprite 和 Text 的布局不是立即生效的。比如条目里的Text内容是运行时填进去的那这个 Text 的preferredHeight要等它的排版完成之后才能查。所以条目绑定完数据、布局重建完你才算真正拿到正确的总高度。3.2 增删之后必须做的那几件事一个可靠的刷新方法长这样private void RefreshLayout(bool keepPosition) { float prevScroll ContentRect.anchoredPosition.y; Canvas.ForceUpdateCanvases(); LayoutRebuilder.ForceRebuildLayoutImmediate(ContentRect); if (keepPosition) { var pos ContentRect.anchoredPosition; pos.y prevScroll; ContentRect.anchoredPosition pos; } scrollRect.StopMovement(); }拆一下这几步的意图Canvas.ForceUpdateCanvases()是为了让所有挂起的 Canvas 更新先落地尤其是你刚改了 Text 内容、刚 SetActive 了一批对象的情况。ForceRebuildLayoutImmediate(ContentRect)让 Content 的高度重新算出来。这一步之后ContentRect.rect.height才是有效值。keepPosition那段是位置补偿。这个下面单独展开。StopMovement()看着可有可无实际上很有用。ScrollRect 在惯性滚动时会持续改变anchoredPosition如果你在它滚动的过程中改了内容高度惯性速度和位置会基于旧范围继续算出现松手后内容猛地弹一下的观感。提示如果你的列表在刷新时会有明显的可见跳变可以在刷新前后各加一次scrollRect.enabled false / true或者短暂地把 Content 移出可见区域再移回来用它来掩盖一帧的布局跳变。3.3 往列表头部插入内容时别让视图跳回顶部这是聊天记录往上翻加载更多的典型场景。你往 Content 的头部插入了 20 条历史消息Content 高度增加了 1000 像素但用户希望视线里原来那条消息还停在原处。因为 Content 的 Pivot 是 (0.5, 1)顶部是原点所以往头部插入会让所有已有内容整体下移 1000 像素。而 ScrollRect 记录的anchoredPosition.y没变结果是视觉上原本看的那条消息往下跑了 1000 像素。补偿的算法很直接public void AddItemsToTop(IListstring data) { float insertedHeight 0f; for (int i data.Count - 1; i 0; i--) { var go Instantiate(itemPrefab, content); go.transform.SetSiblingIndex(0); // 插到最前面 var item go.GetComponentListItem(); item.Bind(data[i], i); // 插完后立刻量一下这一条有多高 LayoutRebuilder.ForceRebuildLayoutImmediate(go.GetComponentRectTransform()); insertedHeight go.GetComponentRectTransform().rect.height; } // 加上间距如果有的话 insertedHeight _spacing * data.Count; Canvas.ForceUpdateCanvases(); LayoutRebuilder.ForceRebuildLayoutImmediate(ContentRect); var pos ContentRect.anchoredPosition; pos.y insertedHeight; ContentRect.anchoredPosition pos; }这里的核心是先算清楚插入了多少高度再把 Content 往下推同样的距离。反过来删除头部内容时pos.y - removedHeight。有个容易忽略的点insertedHeight要把spacing也算进去。20 条内容、间距 10那就是 200 像素的额外高度漏了就会产生一个稳定的、每次加载都累积的偏移用户翻几次之后位置就全乱了。3.4 单条内容自身变高展开长文本的处理比整条增删更常见的是单条内容高度变化比如点击展开一段简介、切换语言导致文案换行变多。这种情况不需要手动补偿因为 Pivot 在顶部、变化的是某一条内部的尺寸布局重建之后 ScrollRect 的范围会自动更新。但有两个前提第一条目内部的 Text 必须能被布局系统正确测量高度。这意味着Text的Horizontal Overflow要设成WrapVertical Overflow要设成Overflow而且 Text 所在的节点宽度要受父级约束不能是固定宽度否则换行后宽度不变、高度也就不变。如果是用HorizontalLayoutGroup横向排列图文Text 的那一项最好是勾上Flexible Width或者给它一个LayoutElement的flexibleWidth 1让它吃掉剩余宽度。第二如果展开动作是通过SetActive控制某个子对象来实现的SetActive(false)的对象默认会被布局组忽略除非你在布局组上关了Child Control的忽略或者用LayoutElement.ignoreLayout反向控制。这点其实正合我们意——隐藏的对象不占高度布局组自动少算它的高度。如果你想保留占位比如折叠时也要留一行的高度就别用 SetActive而是改LayoutElement.preferredHeightvar le detailRoot.GetComponentLayoutElement(); le.preferredHeight expanded ? 200f : 40f; LayoutRebuilder.MarkLayoutForRebuild(ContentRect);MarkLayoutForRebuild只是打个脏标记等下一帧布局系统自己算。它比ForceRebuildLayoutImmediate温和适合那些不需要立即知道结果的场景。能打标记就别强制重建这是我给自己定的一条规矩。4. 排查链路尺寸不动、抖动、位置错乱前面讲的是应该怎么写这一节讲写错了怎么查。我按实际排查顺序排了四站遇到问题就从头往下走。4.1 第一站谁在压着 Content 的尺寸症状不管怎么增删条目Content 高度就是不变。第一个要看的是 Content 上有没有挂LayoutElement。LayoutElement的preferredHeight一旦填了非 -1 的值它会参与ILayoutElement的优先级竞争——而LayoutElement.layoutPriority的默认值是 1比 LayoutGroup 的 0 高。也就是说只要 Content 身上挂了 LayoutElement 并且填了高度ContentSizeFitter 读到的期望值就是那个固定值而不是布局组算出来的动态值。很多人是在调试时随手加了一个 LayoutElement 想看看效果结果忘了删。第二个要看的是有没有隐藏的父级布局组。Viewport 上如果挂了VerticalLayoutGroup并且勾了Control Child Size的 Height它会反过来控制 Content 的高度ContentSizeFitter 就被架空了。标准 ScrollView 的 Viewport 上不该有任何布局组。第三个可能是ContentSizeFitter的Horizontal Fit被设成了PreferredSize导致水平方向的期望宽度被算成子节点的固有宽度在某些 GridLayoutGroup 场景下会连锁影响竖向计算。竖向列表就老老实实Unconstrained。排查手法很简单在 Content 上点右键看 Inspector 里挂的组件列表逐个确认有没有多余的东西。4.2 第二站是不是一帧内改了多次症状列表在刷新时明显抖一下或者内容闪一下才归位。UGUI 的布局系统有个特性当一个布局节点被标记 dirty 时它会向上找到最近的一个布局根layout root也就是有 ContentSizeFitter 或者不在布局组内的节点把它整体标记为需要重建。如果你在一帧里对 Content 做了 10 次SetSiblingIndex理论上会产生 10 次脏标记虽然最终只重建一次但每次Instantiate都可能触发布局组的OnTransformChildrenChanged。更麻烦的是在循环里调用ForceRebuildLayoutImmediate。那个方法会同步走完整个重建循环 20 次就是同步跑 20 遍全量布局中低端机上直接就是肉眼可见的卡顿。正确的做法是批量改结构最后一次重建。把循环里的ForceRebuildLayoutImmediate全部拿掉循环结束之后再调一次。如果确实需要每次插入就拿到高度比如做位置补偿那就退一步对单个条目节点做重建而不是对整个 Content 做。还有一种抖来自 ScrollRect 的Movement Type。设成Elastic时内容超出边界会有弹性回弹如果内容高度在回弹过程中发生变化回弹目标点会重算看起来就像抖动。这种场景下把Movement Type临时切成Clamped刷新完再切回去观感会干净很多。4.3 第三站嵌套布局组的递归与性能症状界面打开变慢Profiler 里LayoutRebuilder.Rebuild占用大头。UGUI 的布局计算是自底向上算期望尺寸、自顶向下分配实际尺寸的两趟过程。当你嵌套了多层布局组——比如 Content 上有 VerticalLayoutGroup每条 Item 上有 HorizontalLayoutGroupItem 里的某块区域又有 VerticalLayoutGroup——每一层都要参与这两趟计算而且是全量重算。一个真实案例某次优化前一个 200 条目的列表在数据刷新时需要 18ms 的布局重建时间直接吃掉了 60fps 的整个帧预算。优化手段有几个方向第一个是减少布局层数。很多嵌套是不必要的。比如图标 两行文字这种条目用两层 HorizontalLayoutGroup 嵌套完全可以压成一层图标用LayoutElement.preferredWidth固定宽度右侧文本区用一个LayoutElement.flexibleWidth 1撑开文本内部的两行用普通的锚点定位而不是再套一个 VerticalLayoutGroup。第二个是用 LayoutElement 替代 ContentSizeFitter。条目内部如果用了 ContentSizeFitter 去自适应文本高度那每一条都是一个独立的布局根重建成本会翻倍。更好的做法是在条目根节点上放一个LayoutElement在代码里根据文本的实际排版高度算出preferredHeight写进去。第三个是避免 Text 的 preferredHeight 反复查询。Text 的preferredHeight每次被查询时都可能触发一次文本排版缓存重建。如果条目数量大把算好的高度缓存起来别让布局系统每次都去问。下面这张表是我自己总结的布局开销来源与替代方案开销来源为什么贵替代方案ContentSizeFitter 在条目内部每条都成为布局根改用 LayoutElement 代码计算高度多层嵌套 LayoutGroup每层两趟全量计算拍平层级用锚点和 LayoutElement 替代GridLayoutGroup 动态条目每次变更全量重排条目固定尺寸时手写 anchoredPositionText 频繁查询高度触发文本排版缓存重建缓存文本高度文本内容不变时不重查频繁 ForceRebuildLayoutImmediate同步全量重建改为 MarkLayoutForRebuild 延迟重建4.4 第四站SetActive 与 Canvas 重建的连锁反应症状列表刷新时整个界面包括不相关的 UI都跟着重绘。UGUI 里有一个共享的 Canvas 概念一个Canvas下的所有 UI 元素只要有一个发生几何变化整个 Canvas 会重新合批rebatch。所以如果你的列表和血条、按钮、弹窗在同一个 Canvas 下每次列表刷新都会把整个界面的网格重新生成一遍。解决思路是把动态列表单独放在一个子 Canvas 下。子 Canvas 有独立的合批范围列表刷新只影响它自己。做法是给 ScrollView 根节点加一个Canvas组件再配一个Graphic Raycaster如果列表内条目需要点击然后把父 Canvas 的合批隔离开。代价是多一个 draw call 批次但换来的是刷新时其他 UI 不再重绘通常划算。5. 条目上千之后自动布局的性能天花板到这里为止第 1 到第 4 节讲的方案对绝大多数业务场景几十到一两百条是完全够用的。但如果你做的是排行榜、背包、聊天记录这种动辄上千条的列表VerticalLayoutGroup ContentSizeFitter这套组合就会撞到天花板。5.1 什么时候该放弃自动布局判断标准不是条目总数而是在需要重建布局的那一刻Content 下有多少个 active 的子节点。只要 Content 下有一千个 active 的条目那么每次增删一条布局系统都要遍历这一千个节点逐个读取它们的ILayoutElement。哪怕你只是改了一条文本这一趟也得跑完。叠加 Text 的排版查询之后单次刷新的耗时很容易跑到几十毫秒。经验阈值大概是200 条以内随便用200 到 500 条要注意减少嵌套和强制重建超过 500 条就该考虑虚拟化按需生成了。5.2 手写一套简易条目回收的骨架虚拟化的核心思想很朴素屏幕能放下多少条就只生成多少条。剩下的用占位高度表达。实现要点有三个第一Content 的高度不能再依赖 ContentSizeFitter而是手动计算总高度 条目数 × 单条高度 (条目数 - 1) × 间距 上下 padding。因为虚拟化通常要求条目等高这个公式几乎是白送的。private void RebuildContentSize(int totalCount) { float total _paddingTop _paddingBottom totalCount * _itemHeight Mathf.Max(0, totalCount - 1) * _spacing; var size ContentRect.sizeDelta; size.y total; ContentRect.sizeDelta size; // 让滚动条知道内容变了 scrollRect.StopMovement(); }第二滚动时根据ContentRect.anchoredPosition.y反算当前可见的起始索引int startIndex Mathf.FloorToInt( (ContentRect.anchoredPosition.y - _paddingTop) / (_itemHeight _spacing)); startIndex Mathf.Clamp(startIndex, 0, Mathf.Max(0, totalCount - visibleCount));第三把复用的条目重新定位到对应位置for (int i 0; i _pool.Count; i) { int dataIndex startIndex i; bool visible dataIndex totalCount; _pool[i].gameObject.SetActive(visible); if (!visible) continue; var rt _pool[i].rect; var pos rt.anchoredPosition; pos.y -(_paddingTop dataIndex * (_itemHeight _spacing)); rt.anchoredPosition pos; _pool[i].Bind(dataIndex); }因为条目是手动定位的Content 上不能挂VerticalLayoutGroup它会覆盖你的anchoredPositionContentSizeFitter也要去掉。取而代之的是自己维护尺寸。这是虚拟化方案的固有代价你放弃了自动布局的便利换来了可控的性能。5.3 变高条目的虚拟化与折中方案上面的方案前提是条目等高。但真实业务里常常有高度不一致的情况比如评论列表里有的是纯文本、有的带图。完全通用的变高虚拟化实现起来相当复杂你需要一个前缀和数组来快速算出第 N 条内容的顶部偏移还要处理某条内容高度是懒加载的、滚动到它附近才知道真实高度的问题。我实际用过的折中方案有两种。一种是高度分档把可能的高度归成三档比如 80 / 140 / 220用前缀和数组来做定位误差用填充补齐视觉上完全可接受。另一种是只对长列表虚拟化短列表照旧比如聊天记录只保留最近 200 条走自动布局更早的历史走分页加载用户翻到边界时才去请求上一页。这样既保证了常用场景的正确性又避开了通用变高虚拟化这个深坑。6. 让预览效果更接近成品的几个细节功能跑通只是及格线。一个让人一眼觉得这个列表做得很讲究的 ScrollView差别往往在几个很细的地方。6.1 惯性、阻尼、灵敏度的参数搭配ScrollRect 上那几个参数默认值其实不太适合移动端参数默认值我的常用值说明Movement TypeElasticElastic保留回弹手感但内容不满一屏时不会有拖拽感Elasticity0.10.1 ~ 0.15越大回弹越远超过 0.2 会显得很飘Inertiatruetrue关掉之后滑动手感会很硬Deceleration Rate0.1350.05 ~ 0.1越小滑得越远长列表建议偏小Scroll Sensitivity115 ~ 30滚轮场景这个值对鼠标滚轮影响极大默认值滚一格几乎不动Scroll Sensitivity是桌面上最容易被吐槽的一项。在编辑器里预览时你用的是滚轮默认灵敏度下滚一格只移动几个像素会让人误以为滚轮失效了。做 PC 端的时候一定要把它调大。Deceleration Rate的取值有个经验公式如果你希望列表滑过大约两屏后停下取值在 0.05 到 0.08 之间比较舒服。它越小指头离屏后内容滑得越远、停得越慢。6.2 遮罩、圆角与边缘渐隐RectMask2D裁出来的是硬边。列表内容从边缘切过去视觉上会比较生硬。有两个改善手段最简单的是在 Viewport 的上下边缘各叠一张渐变图用Image 从上到下 alpha 递减的 Sprite颜色取列表背景色。这样内容在接近边缘时会被淡出而不是被切断。注意这张图要在 Viewport 之外作为 ScrollView 的子节点不然它自己也被裁掉了。另一个是做一些内容与边缘的间距补偿在VerticalLayoutGroup的 padding 里多留出渐隐图高度的一半让内容即使贴到边缘也不会一半在渐隐区、一半在可见区显得很不自然。还有一个细节是弹窗类列表的Viewport高度最好是条目高度的整数倍否则底部会露出半条内容。这个在视觉上给人没做完的感觉虽然技术上完全正常。6.3 分辨率适配与安全区因为 Content 是横向拉伸的锚点Viewport 变宽变窄它都跟着变条目宽度自动适配这部分是自动的。真正需要额外处理的是竖向列表在超长屏上会显示更多条目看起来没问题但在很矮的横屏比如某些平板分屏状态下Viewport 可能只有两百像素高一屏只显示一条半内容滚动体验会很差。这时候通常需要在代码里判断视口高度动态调整条目间距或者切换成横向滚动。刘海屏、挖孔屏的安全区问题对滚动列表来说影响的是 Viewport 的边界。Screen.safeArea拿到安全区之后需要把它换算到 Canvas 的坐标系再去调整 Viewport 的offsetMin/offsetMax。因为 Viewport 一变Content 的横向尺寸也跟着变锚点拉伸整条链路是自动联动的不需要额外处理 Content。6.4 编辑器里的预览技巧最后一个很实用的小技巧在编辑器里调列表布局时每次改数据都要点运行才能看到效果效率很低。可以给列表组件加一个[ExecuteAlways]然后在OnValidate里调用重建#if UNITY_EDITOR [ExecuteAlways] public class DynamicListPreview : MonoBehaviour { private void OnValidate() { if (Application.isPlaying) return; // 延迟到编辑器空闲时再执行避免在 OnValidate 里直接操作布局 UnityEditor.EditorApplication.delayCall RefreshInEditor; } private void RefreshInEditor() { if (this null) return; Canvas.ForceUpdateCanvases(); LayoutRebuilder.ForceRebuildLayoutImmediate(GetComponentRectTransform()); } } #endif用delayCall而不是直接在OnValidate里做布局是因为OnValidate的执行时机很特殊期间做一些资源加载、SetActive 之类的操作编辑器会抛警告甚至行为不确定。延迟一个编辑器帧之后再处理稳得多。另外提醒一句[ExecuteAlways]挂上去之后Awake、Update在编辑器里也会跑如果里面有不加Application.isPlaying判断的逻辑比如网络请求、对象池初始化会引发一堆奇怪的问题。这个脚本建议只挂在临时的预览对象上正式的资源上不要带。7. 我个人在这套方案上的一些取舍做了几年 UI 之后我对 ScrollView 的默认态度是能不用布局组就不用能手动算就手动算。原因不是布局组不好而是布局组的行为是隐式的——你写下一行代码改了内容布局系统在另一个时机、以另一种顺序去响应中间隔着一层看不见的调度。业务简单的时候这层抽象很棒一旦出现性能问题或者视觉抖动调试成本会成倍增加。但反过来讲对于几百条以内、结构相对规整的列表VerticalLayoutGroup ContentSizeFitter的组合能省下的代码量是巨大的没必要为了性能洁癖提前上虚拟化。我的判断标准很土但很有效在目标机型上跑一遍看刷新时的帧时间有没有超过 8ms。没超就用自动布局超了再考虑回收。还有一点是关于ForceRebuildLayoutImmediate的使用态度。我见过有人把它当成万能刷新键到处调用。这个方法的开销是同步全量重建在列表条目多的时候一调用就是几毫秒。我给自己定的规则是只有在同一帧内必须拿到正确尺寸的时候才用它其余一律用MarkLayoutForRebuild延迟到下一帧。需要在一帧内拿尺寸的典型场景只有两个——界面打开时的初始定位以及头部插入时的位置补偿。其余的刷新让布局系统自己安排节奏就好。最后分享一个排查思路UI 的布局问题九成以上出在层级和组件上而不是代码上。遇到尺寸不对先别急着断点调代码先选中出问题的节点看 Inspector 里挂了什么、锚点是什么、父级挂了什么布局组。这个动作花三十秒往往能省下半小时的断点调试。我现在的习惯是每次新建一个列表预制体就在场景里配好一遍并保存后面所有列表都从它复制从源头上避免配置漂移。