ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Egret List虚拟布局遇不定高Item:复用错乱根因与改造

Egret List虚拟布局遇不定高Item:复用错乱根因与改造 做 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。列表是你游戏的交互地基地基不牢后面加再多动画和特效也是悬空的。
返回列表