做 Egret 游戏 UI 的朋友,大概率在 List 虚拟布局上踩过坑。项目前期数据量小怎么滚动都正常,等到上线前把真实内容一灌进去,开启useVirtualLayout = true的 List 就开始闹脾气:列表项高度不一致时,Item 对象一重用,整个布局直接乱掉,条目重叠、大片空白、滚动条长度飘忽不定,甚至快速滑动时能看到上一帧的旧内容闪现。这篇文章就围绕“egret 引擎 List 虚拟布局 + 不同高度 Item + 对象重用”这三件事,把问题成因、解决思路和能直接抄的改造方案完整梳理一遍,适合正在被不定高列表折磨的客户端同学参考。
1. 写在前面:一个典型的 List 布局翻车现场
1.1 场景还原:高度不均的消息列表
我最早遇到这个问题是在做游戏内邮件系统。邮件列表有两种模板:一种是纯文本邮件,一个 Label 撑起来,高度只有 70 像素左右;另一种带奖励附件,需要展示资源图标和数量,高度得到 160 像素。当时 List 配置很简单,itemRenderer指向一个实现两种模板的组件,useVirtualLayout = true,数据量也有几百封邮件。
测试同学第一次滑动就把问题反馈到我这里了:快速往下滑再滑回来,第二行的邮件居然顶到了第一行的位置,内容还是对的,但占据的实际高度完全错位;往下滚到底再滚回来,整个列表出现大段空白。之后我做了一个最小化 Demo,用一个数组塞进 50 条不同高度内容,滚动几次就能稳定复现。
这个现象的关键词就是标题里那三个:List 虚拟布局、不同高度 Item、对象重用。三者缺一个都不会出问题——等高列表下虚拟布局算位置是数学公式,永远不会错;不等高但关闭虚拟布局,Item 全部常驻也不会产生复用脏数据;有虚拟布局、有不等高、又触发了对象重用,布局异常就是大概率事件。
1.2 先说结论:根因只有一个
这个问题不是 Egre 的随机 Bug,而是虚拟布局机制本身对“所有 Item 等高”这个前提有强依赖。官方默认的VirtualLayout在计算“当前滚动位置应该显示哪些索引”时,高度是按统一值参与运算的;当实际 Item 高矮不一时,这个公式算出来的索引和位置就会和真实渲染状态不一致。
对象重用则会放大这个矛盾:被回收的 ItemRenderer 再次使用时,它身上残留着上一轮的尺寸、坐标等几何信息。如果你在dataChanged里只刷新了文本和图片,没有把显式尺寸清理干净、没有触发重新测量,那么布局系统拿到的仍然是一个“旧瓶子”。数据对了,几何信息错了,表现出来就是错位、重叠、空白。
所以全文围绕两件事展开:一是理解为什么虚拟布局在不等高时会算错;二是怎么从数据层、组件层、布局层把这个前提重新建立起来。
2. 虚拟布局在不等高场景下为什么一定会出问题
2.1 虚拟列表的“滑动窗口”模型
先理解虚拟布局的底层机制。Egret 的List内部有一个可视区域,这个区域对应游戏屏幕上的一个矩形范围。开启虚拟布局后,List 不会把数据源里所有条目都创建出来,而是只创建可视区域内可见的那几个 ItemRenderer,再额外多创建两三个作为缓冲。
当玩家滚动列表时,离开可视区域底部的 ItemRenderer 会被回收到一个缓存池里,滚动进来需要展示的新索引会从缓存池里取一个闲置 Renderer 来复用。这个过程和大部分游戏引擎的虚拟列表思路一样,本质是一个“滑动窗口 + 对象池”:窗口负责决定哪些索引可见,对象池负责复用渲染对象、减少创建销毁开销。
问题在于“窗口”需要回答两个问题:当前滚动位置对应的起始索引是多少?每个索引在列表内部的 Y 坐标是多少?这两个问题的答案都依赖高度信息。只有高度是稳定的,窗口才能通过简单乘除法精确换算。
2.2 等高映射:一个隐式且强依赖的前提
等高列表下,位置计算是 O(1) 的:假设统一 itemHeight 为 80,那么第 n 个条目的起始 Y 坐标就是 n * 80。滚动偏移量 offset,通过startIndex = Math.floor(offset / 80)就能立刻算出应该从哪个索引开始渲染。
Egret 官方VirtualLayout的默认实现,内部就保存着一个统一的 itemHeight(或者通过 measure 得出的平均高度),所有索引的位置都基于这个值推算。它不会为每个索引单独维护一个“高度字典”,因为设计初衷就是处理等高、滑动流畅的长列表。
一旦 Item 高度不同,这个默认实现就从根上失效了。比如索引 0 高度 160,索引 1 高度 70,索引 2 高度 160。滚动到 offset = 170 时,真实情况应该显示索引 1 和索引 2 的一部分,但等高公式会简单按统一高度计算出错误位置。这就是不等高列表用官方虚拟布局必然出现偏差的根本原因。
2.3 Item 对象重用时,残留的不只是尺寸
很多人以为回收复用的 ItemRenderer 只要重新赋值data就能恢复干净状态,实际上它有多个维度的“残留”。最容易踩的是尺寸残留:上一个数据把 Renderer 的内容撑到了 160 高度,组件自身的height属性被显式设置过或者通过内容撑开;下一个数据实际应该显示 70 高度,但如果渲染代码没有主动重置高度,这个 Renderer 在布局系统眼里仍然是 160。
第二个典型的残留是渲染状态残留。比如上一轮数据里某个按钮是visible = true,这一轮数据里没有按钮,但你没有处理可见性,按钮就会继续显示出来。还有touchEnabled、选中态、Label 的超长截断等,都会在复用时造成“旧内容掺进新内容”的观感。
第三个残留点是位置残留。虚拟布局里的 ItemRenderer 是通过设置x、y来摆位的,复用时如果新位置的 y 没有被重新赋值(或者赋的值是基于错误高度算出来的),Renderge 就会停留在上一轮的坐标上。叠加上尺寸残留,最终呈现的就是重叠、错位。
2.4 每次布局失效后的“累计误差”是怎么出现的
虚拟布局为了滚动顺畅,不会在每一帧都重新计算所有 Item 的位置,而是采用增量更新策略。滚动过程中,只有当某个 Item 完全滚出窗口、或者新索引进入窗口时,它才会触发一次回收和复用操作。
在这个增量更新机制下,一旦出现一次错误的高度判断,后续的索引偏移就会错位,而且错误会像滚雪球一样积累。比如某个 Item 真实高度是 70,但布局计算时按 160 处理,那么它后面的所有 Item 在逻辑上都往下多预留了 90 像素。玩家滑动时可能感觉“怎么滚了半天还没到底”,或者某个区域突然变成空白,因为布局系统以为那里有内容,但真实数据根本不在那个位置。
这个累计误差是肉眼可见的,也是“滚动越久越乱”的原因。它不像普通 Bug 那样只影响单帧渲染,而是会把整个列表的坐标体系带偏。
3. 四种解决思路,按投入产出排序
3.1 方案一:统一高度,内容自由设计(最推荐)
如果在产品需求阶段还有调整空间,这是我第一个建议的方案:所有列表项使用固定的 itemHeight,内部视觉差异通过子元素的显隐和布局来做。比如每条项固定 120 像素,纯文本邮件在垂直方向居中显示,带奖励的邮件在右侧用固定大小区域展示图标,高度依然是 120。视觉上看起来“内容不同”,但每个 Renderer 的尺寸完全一致。
这个方案最大的优势是让虚拟布局回到它最擅长的等高模式,性能最好,代码也最稳。所有和“复用脏数据”相关的问题直接消失,因为高度不会变。至于内容超出区域的情况,可以用Label的maxHeight加裁剪,或者设计时就把模板控制在不爆高的范围内。
代价是空间利用率低,每条都占据最大高度,列表的总长度会比真实高度需求长一些。但对于大多数游戏内列表(排行榜、邮件、任务、活动公告),这种空间浪费完全可接受,对比修 Bug 投入的时间成本,用空间换稳定非常划算。
3.2 方案二:关闭虚拟布局,用数量换稳定
如果列表数据量不大、每项内容也不算复杂,可以直接把虚拟布局关掉:myList.layout.useVirtualLayout = false。这是最粗暴的解法,但确实有效。
没有虚拟布局后,List 组件会一次性创建所有数据项对应的 ItemRenderer,所有 Item 常驻在显示列表里,滚动过程只是简单平移整个容器,不存在“回收对象再复用”的环节,也就没有任何残留问题。不同高度也不会影响定位,因为每个 Item 都真实存在,布局系统按实际高度一个个往下排。
什么场景适合?我个人的经验是数据量在 100 条以内、每条只有文本加一两个图片的静态列表,性能压力不大。超过 200 条、或者每条涉及复杂子组件,就会在创建阶段出现明显卡顿,移动端尤其明显。这个方案适合运营后台配置类页面、短排行榜这类体量可控的场景。
3.3 方案三:数据驱动的高度缓存与定向刷新(折中方案)
如果必须保留虚拟布局,同时列表项高度确实不一样,那就要自己动手,把“统一 itemHeight”的假设替换成“每个索引有独立高度缓存”的模型。核心思想是:高度信息不依赖 ItemRenderer 的当前测量值,而是提前记录在数据源里,一切布局计算走数据,ItemRenderer 只负责渲染。
具体做法是给每个数据源对象增加一个height字段,在数据准备阶段或 Item 首次渲染后回填真实高度。之后虚拟布局在计算位置时,直接读取缓存高度数组,而不是依赖默认 itemHeight。当 Item 内容导致高度变化时,把新高度写回数据缓存,并主动触发一次布局失效,让 List 重新计算所有受影响的索引位置。
这个方案是实际项目里用得最多的,因为它保留虚拟布局的性能优势,又能正确支持不同高度。难点在于“高度缓存”和“UI 真实高度”要保持同步,任何一方落后都会出问题。具体改造代码我在第 4 节展开。
3.4 方案四:自定义虚拟布局,彻底支持不等高
还有一个更彻底的方向:继承eui.VirtualLayout,自己实现一版支持不等高的虚拟布局。这个方案适合数据量特别大(比如上万条)、Item 高度普遍不可预测的动态内容场景,而且团队有足够时间做测试和适配。
自定义布局要处理的核心问题是:建立“索引 → 累计高度”的映射关系。等高布局用乘法,不等高布局改成遍历累计。实现时可以维护一个高度数组,在滚动的过程中逐步填充每一项的高度;初始阶段没填充到的项使用预估高度,填充后如果有偏差再纠正。这个“预估 + 纠正”的策略也是很多商业游戏引擎虚拟列表的做法。
这个方案工程量最大,而且 Egret 不同版本VirtualLayout内部方法签名可能有差异,必须针对项目实际版本阅读源码后改动。如果没有专门的时间预算,我更推荐先用方案三顶上,方案四作为后续技术优化项来排期。
4. 实操:一段能直接用的不等高列表改造记录
4.1 改造前的数据模型约定
我以消息列表为例,说清楚方案三怎么落地。先约定数据模型,每个条目至少包含两个字段:
interface IMessageData { id: number; type: "text" | "reward"; title: string; content: string; rewardList: Array<{ res: string; num: number }>; height: number; // 必须给一个默认值,比如 100 }关键点是height必须存在于数据源里,虚拟布局读的是这个字段,而不是等 Item Renderer 渲染后再去this.height。为什么要这样设计?因为虚拟布局计算索引和坐标时,它接触不到未来的 ItemRenderer,只能通过数据源提前知道每项多高。
我在实际项目里给默认值 100,这是一个平均预估高度。首屏渲染时,ItemRenderer 根据内容真实测量后把准确高度写回data.height,再触发一次布局刷新,这样后续滚动用的就是真实高度了。这个先预估、再校准的流程,能避免一开始高度都是 0 导致布局算出一堆问题。
4.2 ItemRenderer 里的尺寸重置与通知逻辑
ItemRenderer 是整个改造的核心。它的职责是:每次data变化时,清掉上一轮遗留的显式尺寸,让内容按新数据测量,然后把真实高度回写到数据源。
class MessageItemRenderer extends eui.ItemRenderer { protected dataChanged(): void { super.dataChanged(); // 关键步骤1:清掉显式尺寸,让布局重新测量 this.explicitHeight = NaN; this.explicitWidth = NaN; // 关键步骤2:刷新内容 const data: any = this.data; if (data) { this.titleLabel.text = data.title || ""; this.contentLabel.text = data.content || ""; if (data.rewardList && data.rewardList.length > 0) { this.rewardGroup.visible = true; // 这里统一处理奖励图标 } else { this.rewardGroup.visible = false; } } // 关键步骤3:立即测量,拿到真实高度 this.validateNow(); // 关键步骤4:高度回写数据源,并通知 List 重新布局 if (data) { const realHeight = this.height; if (data.height !== realHeight) { data.height = realHeight; this.notifyListLayoutDirty(); } } } private notifyListLayoutDirty(): void { let parent = this.parent; while (parent) { if (parent instanceof eui.List) { parent.invalidateLayout(); break; } parent = parent.parent; } } }这段代码里有几个关键点需要重点解释。
explicitHeight = NaN这一步很多人会漏掉。Egret 的测量系统里,如果一个组件被显式设置了height,后续内容变化不会自动改变它的尺寸。把explicitHeight置为NaN,等于告诉布局系统“我没有给它定高度,你来重新测”。如果不做这一步,ItemRenderer 会一直保持上一轮的尺寸。
validateNow()必须在回写高度之前调用。它强制执行一次属性、布局、显示列表的完整验算,保证this.height是当前内容测量出来的值。如果跳过这步,读取到的可能还是旧的尺寸。
notifyListLayoutDirty是在找到 List 实例后调用invalidateLayout()。注意这里不能用this.parent直接判断,因为虚拟列表的 ItemRenderer 在显示树里的父节点是一个内容容器,不是 List 本身。上面代码用 while 循环向上找是一种稳妥的通用写法。
4.3 让 List 重新测量:invalidateLayout 的正确姿势
就算 ItemRenderer 正确回写了高度,如果 List 不知道“我的项尺寸变了”,布局还是不会更新。所以必须主动通知布局失效。
Egret 的布局系统里面,invalidateLayout()会把当前布局标记为脏,在下一帧渲染前重新计算所有 Item 的位置。但它不是只要调用就一定重排,有几个细节要特别注意。
第一,通知时机要在数据回写之后。如果你在dataChanged一开始就触发失效,此时数据源的高度还没更新,重排拿到的还是旧值,等于白通知。所以我在代码里把回写和通知放在校验data.height !== realHeight之后。
第二,不要频繁调用。如果 100 个 Item 同时刷新,每个都触发一次invalidateLayout(),性能会很差。实际项目里可以通过帧标记合并:用一个布尔变量记录“本帧需要重排”,在egret.Ticker下一帧统一执行一次。更简单的做法是让 List 只调用一次,因为invalidateLayout本来就是帧内合并的标记,多次调用最终也只在下一帧处理一次,不会产生 100 次重排。
第三,要区分触发时机。只有“高度真的变了”才需要触发。我代码里先判断data.height !== realHeight再通知,避免每次无关刷新都让整个 List 重新排一遍。
4.4 图片异步加载引起的高度变化处理
不等高列表最容易出问题的其实是图片异步加载。文本内容的高度通常是同步就能确定的,但图片尺寸可能不固定——网络图片加载完成前高度是 0,加载完成后撑开一大块。这种场景如果不处理,Item 复用后会反复跳动。
我的处理方式是在图片的egret.Event.COMPLETE回调里重新拿到真实尺寸,回写到data.height,再通知 List 重排。
private onRewardIconLoaded(e: egret.Event): void { const icon: eui.Image = e.currentTarget; // 图片加载完成,重新测量 this.invalidateSize(); this.validateNow(); // 回写高度 const data: any = this.data; if (data) { const h = this.height; if (data.height !== h) { data.height = h; this.notifyListLayoutDirty(); } } }如果每张图都触发一次重排会有性能隐患,可以在加载回调里加一个节流:等这一帧内所有图片都加载完成后统一重排一次。具体做法是维护一个计数,加载完成的图片数量达到当前 Item 内图片总数后再通知。
还有一点经验:给带图片的 Item 设置一个“最小占位高度”,图片加载前不要让高度塌成 0。否则列表在图片加载过程里会像呼吸灯一样上下跳动,体验非常差。
5. 常见问题速查与避坑笔记
5.1 滚动条长度飘忽不定
滚动条的长度取决于 List 认为的“内容总高度”。如果高度缓存和真实 Item 高度不一致,滚动条就会随滚动过程不断跳动。排查时先确认:数据源每一项的height是不是都在渲染后回写正确。很多情况下是图片是异步回写,导致 List 以为总高度很小,于是滚动条被压缩得很短。
还有一点值得检查:ItemRenderer 的height有没有被显式设置过。如果上一轮数据设置了explicitHeight = 200,这一轮没有清掉,就算回写逻辑写了data.height = 70,布局系统测量时发现你的 Renderer 实际是 200,滚动条还是按大的算。
5.2 滚动后出现大块空白
出现大段空白,通常是某个 Item 的高度被缓存成了 0 或很小。常见原因有两个:一是内容还没渲染完就取了this.height,比如图片没加载完成、文本的textFlow还没展开;二是在childrenCreated阶段就执行了测量,此时子组件还没布局完,拿到的尺寸自然不对。
解决这类问题,把测量时机推迟到dataChanged并且调用validateNow()之后。如果还不行,试试放到egret.callLater里延迟一帧再测量。
5.3 Item 内容“串位”或闪一帧旧数据
快速滑动时看到旧内容闪现,这是对象重用的经典表现。上一轮数据的内容还没来得及清理,新的文本和图片赋值又没生效,于是出现了“旧瓶子装新酒”的画面。
最直接的应对是让数据更新过程更干净。在dataChanged里先把所有子组件的显示对象归位:文本置空、图片源清空、可见性重置,再赋新值。如果用了皮肤部件,确认skinName对应的组件在childrenCreated里已经初始化,不要在外部回调里直接访问还没创建的皮肤组件。
5.4 快速滑动时偶发重叠与卡顿
偶发重叠通常和布局的重排时机冲突有关。滚动过程中虚拟布局正在根据新旧高度缓存调整位置,此时如果你又触发了另一个invalidateLayout(),两套计算互相覆盖,就会出现瞬时重叠。滚动结束后再触发一次重排能恢复正常,但玩家的观感已经很差了。
卡顿则多是因为validateNow()在滚动过程里被频繁调用。validateNow()会强制同步验算,一旦在每帧的滚动回调里执行,就会让虚拟列表变成同步大开销,掉帧是必然的。建议把重测量逻辑放到egret.Event.RESIZE或者下一帧的callLater里,不要直接在滚动事件里做同步测量。
6. 我自己平时会注意的几个细节
最后分享几个我在实际工作中踩坑后养成的习惯。
第一个习惯是:新建列表时先问问“高度会不会变”,会变就提前设计数据高度字段,不要等到测试来报 Bug 再补。高度字段应该在数据模型里占一个位置,哪怕一开始全是默认值。
第二个习惯是:所有导致高度变化的逻辑,最终都要收敛到“回写数据源高度 + 主动 invalidateLayout”这一条通路。不管是文本变化、图片加载、皮肤切换还是显隐切换,路径越单一越不容易漏状态。我见过太多项目里高度更新散落在各种回调里,漏一处就重启一个诡异 Bug。
第三个习惯是做压力测试时专门写一个“快速滚动脚本”,利用Scroller的scrollTo循环快速切换滚动位置,用 500 条不同高度数据跑几轮,基本能筛出 90% 的复用脏数据问题。人工手滑测试效率太低,而且很容易因为路径固定漏掉深层的索引误差。
最后一个建议是心态上的:虚拟布局不等高这件事,本质上是用“空间换时间”的技术方案遇上了“空间不均匀”的业务场景。不要试图在等高方案的源码里修修补补,该上自定义高度缓存就上,该关虚拟布局就关,数据量一旦超阈值,就老老实实做自定义 Layout。列表是你游戏的交互地基,地基不牢,后面加再多动画和特效也是悬空的。