自研RcList:HarmonyOS信息流列表尺寸计算与性能优化实战

发布时间:2026/10/21 6:13:39
自研RcList:HarmonyOS信息流列表尺寸计算与性能优化实战 列表组件这东西不少朋友的第一反应是不就用 List 加 LazyForEach 吗有什么可折腾的。但当你真正做一个混合图文、数据量几千条的信息流列表尤其是在 HarmonyOS 6 这类声明式框架下面就会知道列表性能四个字背后全是活儿。这篇想分享的是我打磨了大半年的 RcList 组件——一个基于 Scroll 加缓存池自研的列表容器。重点不是讲 API 怎么用而是把最核心的尺寸计算方案拆开配合一个完整的图片信息流示例说清楚每一步为什么这么设计以及我在真实设备上踩过的那些坑。如果此刻你正要处理长列表卡顿、Item 高度不确定、或者想搞懂虚拟列表到底怎么算高度这篇应该能对得上你的胃口。内容偏实战代码是 ArkTS 风格但核心思路不绑平台换个声明式 UI 框架照样能抄。1. 半年只做一件事我是怎么和列表组件较上劲的1.1 官方 List 解决不了的问题恰恰是性能要命的地方一开始我用的也是官方 List 组件数据量小的时候没什么问题。但信息流场景里Item 内部往往是一行标题 几个标签 一张网络图的组合数据总数轻松破千。问题就开始冒头了首先是首帧渲染时间不可控。List 的懒加载机制虽然不要求一次性创建全部 Item但在快速滑动时新 Item 的创建、测量、布局会密集发生如果 Item 里面还有异步加载图片的节点主线程会被反复打断。表现在用户眼里就是滑动不跟手、偶发掉帧。其次是对动态高度支持不够精细。官方 List 里可以通过cachedCount扩大缓存但如果 Item 高度差异很大比如有的纯文本 80 高有的带三张图 600 高它的缓存策略没法按像素来计算经常会缓存过少或过多。缓存过少滚动时频繁创建缓存过多内存和布局压力又上去了。真正让我下决心自研的是一个当时没法绕过的需求需要在列表滚动过程中做埋点准确知道用户当前看到了哪几条数据。官方 List 的onScrollIndex只能给到首尾索引而我的业务要的是屏幕中心那个 Item 是谁。这必须自己算知道滚动偏移量知道每条 Item 的高度和位置才能精确反查。这件事绕不开尺寸计算。1.2 RcList 的定位Scroll 缓存池 尺寸计算管线所以 RcList 不是一个官方组件而是我自己维护的一个容器组件。它的定位可以总结成一句话用 Scroll 自己管理可见区域用缓存池控制渲染节点数量用一套尺寸计算管线决定什么时候渲染谁、放在哪、占多高。结构上就三层底层是 Scroll负责滚动事件和滚动偏移量。中间放一个任意高度的 Stack 容器高度等于所有 Item 的累计高度。上层是多个槽位Slot每个槽位绑定一个数据项通过 offset 绝对定位到 Stack 里的正确位置。为什么要用 Scroll Stack 而不是直接用 List因为我要的是完全可控。Scroll 不主动创建子节点Stack 只是个画板能不能看见内容完全由我决定。这样渲染节点数可以压到很小内存里始终只保留当前可见区域 前后缓冲那几十个槽位。举个例子一份 1000 条数据的列表纯 ForEach 写法会创建 1000 个 Item 节点。RcList 在静止状态下只会创建大约 20 个左右槽位节点即便快速滚动也只是回收复用这 20 个槽位而不是重建。这种思路大家应该不陌生Flutter 里的 SliverList 也是类似逻辑只是落到 ArkUI 里很多细节要自己重新实现。1.3 全文阅读路径这篇文章不是按步骤一、步骤二写的我的建议是这样读如果你对虚拟列表和尺寸算法不熟老老实实从第 2 节开始看先理解高度字典和预测量管线如果你已经写过一些列表组件可以直接跳到第 3 节看综合示例代码骨架可以直接搬如果你是想避坑重点看第 4 节那四个坑每一个我都花了好几天才爬出来。2. 尺寸计算虚拟列表的第一性原理2.1 为什么测量每个 Item 的高度这么难很多人以为 Item 高度嘛拿到数据算一下不就行了。实际上难点在三个地方第一数据驱动的完型Completion是不确定的。一段文本你不知道它会折成几行因为行数依赖屏宽、字号、行高、左右 padding一张网络图片你拿到 URL 时并不知道它的宽高比除非先请求一次拿头信息但那样就得等一个卡片里同时有文本和图片总高度更是组合出来的。第二真实渲染出来的高度和预估高度永远有误差。字体渲染在某些设备上会有 1-2 像素的偏差图片加载完成后实际展示高度和占位高度差异可能达到几十像素。这些误差如果不处理累积起来会让整个列表的滚动位置越来越不准。第三宽高存在联动关系。同一个 Item在不同屏幕宽度下表现完全不同。宽度一变文本折行数量变图片等比缩放后的高度也变。所以任何一套尺寸计算方案都必须把容器宽度作为第一输入参数。这也是为什么很多列表框架强制要求 Item 定高——因为定高可以直接用乘法算出总高度完全绕开测量问题。但代价是视觉上没法支持灵活的图文卡片所以我在 RcList 里保留了三种策略按场景选择。2.2 三种高度策略定高、测量、预估我把高度策略分成三类在同一个列表里可以混用。定高适合头像、色块、纯图标等固定形态的 Item。直接给一个常量高度开销几乎为零。测量适合本地已经能拿到完整内容的 Item比如本地图片、绘制好的 Canvas。做法是用一个隐藏节点真实渲染一次通过onAreaChange拿到真实高度然后回填高度字典。准确度最高但开销也最大首次渲染会有一次额外的布局成本。预估适合网络图片、长文本、动态卡片。先用规则算一个估计值比如文本按行数乘行高图片按固定占位高度等真实内容加载完成后通过回调修正。这是大多数场景的主力。三种策略的对比可以看这张表策略准确度成本适用场景定高恒定极低头像、图标、固定卡片测量精确高本地图片、完整本地内容预估有误差低文本流、网络图、异步内容实际信息流列表基本都是文本预估 图片预估 真实高度修正的组合。没有哪个策略是银弹重要的是让它们在一个框架里共存预估器算出初始高度真实渲染后覆盖。2.3 高度字典 offsetMap怎么维护和修正光有高度还不够虚拟列表的核心数据结构是高度字典 前缀和数组。我管它叫 offsetMap。offsetMap 的定义是offsetMap[i] 前 i 条 Item 的高度累加值也就是第 i 条 Item 的顶部在滚动内容中的绝对 Y 坐标。有了这个数组任意时刻都能回答两个问题当前滚动偏移量 offset 下哪些 Item 在屏幕内用二分查找就能在 O(log n) 时间内找到 startIndex 和 endIndex。某个 Item 应该画在哪里直接查 offsetMap得到它的 Y 坐标。但 offsetMap 是动态的。Item 的真实高度可能在渲染之后才被更新。比如我预估一张高度 240 的图片网络加载后实际高度是 400那么这条 Item 高度变大了 160它后面的所有 Item 的顶部坐标都要跟着往下挪 160。如果直接全局重算列表内容高度会突然增加用户会看到明显的跳动甚至滚动位置错乱。我用的办法是差分修正每个 Item 维护一个heightOffset数组记录真实高度与预估高度的差值。滚动时offsetMap[i]的值由基准前缀和 前面所有 Item 的差值累加得到。这样即使某个 Item 被回收它的高度贡献也依然存在。等到滚动停止、渲染稳定之后再把这些差值合并回基准前缀和里一次性更新最大限度避免视觉跳变。2.4 一条滚动流程里的尺寸联动把尺寸计算串起来一次滑动的事件序列是这样的用户滑动Scroll 回调onScroll拿到当前偏移量currentOffset。根据 offsetMap 用二分查找算出当前应该显示的数据区间[start, end]。把区间前后各扩展一段缓冲距离我默认设为视口高度的 1.5 倍得到需要渲染的槽位区间。槽位区间内如果有空闲槽位从缓存池里取出并绑定到对应数据项超出区间的槽位回收并解除绑定。新绑定的数据项如果已经有真实高度记录直接用真实高度布局如果没有先按预估高度布局。如果某个槽位内的组件因为内容变化触发了onAreaChange更新真实高度并修正后续 Item 的差分前缀和。如果修正量累计超过阈值而且当前处于静止状态就执行一次滚动位置补偿让视觉内容保持稳定。这七步里最繁琐的是第 6 步和第 7 步。如果不是为了准确埋点和视觉平滑这一步可以直接砍掉——但那就回到会跳的虚拟列表了。3. 综合示例做一个图片信息流的自适应列表3.1 场景定义混合卡片信息流我拿一个典型的信息流场景来演示列表数据是混合卡片一部分是标题 摘要的文本卡片一部分是标题 摘要 单张网络图的图文卡片两种卡片高度都完全不确定。这个场景非常适合演示尺寸计算因为它把前面说的三种策略都用上了标题和摘要走文本预估图片走占位预估加真实高度修正整卡高度是各块高度的拼装结果。数据模型大概长这样interface FeedItem { id: string; type: text | imageCard; title: string; summary: string; imageUrl?: string; imageWidth?: number; // 可能为空 imageHeight?: number; // 可能为空 estimatedHeight: number; // 预估高度由估算器填充 }业务层的数据源不需要关心布局细节预估高度由 RcList 的尺寸估算器统一计算。3.2 预估器实现文本用测量图片用占位预估器的核心是一个注册表每种 Item 类型对应一个估算函数interface SizeEstimator { estimate(item: FeedItem, width: number): number; }纯文本卡片先得到文本可用宽度contentWidth listWidth - paddingLeft - paddingRight再用文本测量接口算出标题和摘要各自的高度最后加上间距和 paddingclass TextCardEstimator implements SizeEstimator { estimate(item: FeedItem, width: number): number { const padding 32; // 上下各16 const gap 8; const titleHeight measureTextHeight(item.title, width - 32, 17, 24); const summaryHeight measureTextHeight(item.summary, width - 32, 15, 22); return titleHeight summaryHeight gap padding; } }这里的measureTextHeight是封装的文本测量工具底层用的是系统提供的文本测量能力传入文本、约束宽度、字号、行高返回实际排版高度。注意文本测量一定要带上约束宽度否则拿到的是一整行的高度和真实布局完全对不上。图文卡片更复杂一点如果数据源已经给出了图片原始宽高可以直接按显示宽度等比缩放得到高度如果没给就先用一个默认高度占位等图片加载完成后再做真实高度修正。class ImageCardEstimator implements SizeEstimator { estimate(item: FeedItem, width: number): number { const textHeight new TextCardEstimator().estimate(item, width); let imageHeight 240; // 兜底占位 if (item.imageWidth item.imageHeight) { const displayWidth width - 32; imageHeight displayWidth * item.imageHeight / item.imageWidth; } return textHeight imageHeight 12; // 图片与文本间距 } }这套估算器的好处是所有 Item 在进入渲染管线之前就能拿到初始高度offsetMap 可以直接建立首屏可以直接定位。3.3 容器核心逻辑可见区间、复用池和占位节点容器部分我用 Scroll Stack 搭骨架核心是三个状态State totalHeight: number 0; // Stack 总高度 State slots: SlotInfo[] []; // 当前活跃槽位 offsetMap: number[] []; // 高度前缀和 cachedData: Mapstring, FeedItem []; // 数据键值表Scroll 的滚动事件拿到偏移量后会调用一个updateVisibleRange方法。这个方法的职责是根据currentOffset计算可见区间再从缓存池里分配槽位updateVisibleRange(scrollOffset: number) { const viewportHeight this.viewportHeight; const buffer viewportHeight * 1.5; const start this.findIndexByOffset(scrollOffset); const end this.findIndexByOffset(scrollOffset viewportHeight buffer); const activeKeys new Setnumber(); for (let i start; i end; i) { const key this.sortedKeys[i]; activeKeys.add(key); if (!this.slotMap.has(key)) { const slot this.pickFreeSlot(key, i); // 从自由池取一个槽位 this.slotMap.set(key, slot); } } // 回收滑出缓冲区的槽位 this.slotMap.forEach((slot, key) { if (!activeKeys.has(key)) { this.freeSlot(slot); this.slotMap.delete(key); } }); this.slots Array.from(this.slotMap.values()); this.refreshTotalHeight(); }被渲染的槽位在 Stack 里通过 offset 锁定坐标Stack() { ForEach(this.slots, (slot: SlotInfo) { FeedCardView({ item: slot.data }) .height(slot.estimatedHeight) .offset({ y: slot.y }) .key(slot.slotKey) .onAreaChange(...) }, (slot: SlotInfo) slot.slotKey) } .height(this.totalHeight)这里有一个特别容易被忽略的点ForEach的 key 必须用槽位自己的稳定 key比如slot_0、slot_1不能用数据项的 id。我一开始用数据 id 做 key结果滚动时同一 id 的节点在 Stack 里到处乱跑状态错乱到没法看。用槽位 key 的好处是每个槽位是一个稳定存在的渲染节点它内部绑定哪个数据可以随时换节点本身不重建。3.4 高度上报与微调机制槽位里的 FeedCardView 在图片加载完成后要向容器报告自己的真实高度。实现方式是在组件内部拿到图片的真实像素后计算布局高度然后回调给槽位槽位再触发容器的updateRealHeightupdateRealHeight(dataId: string, realHeight: number) { const index this.indexMap.get(dataId); const oldHeight this.realHeights[index] ?? this.estimatedHeights[index]; const diff realHeight - oldHeight; this.realHeights[index] realHeight; this.diffAccum diff; // 记录累计误差 // 当修正值较大且当前处于静止状态时做一次平滑补偿 if (Math.abs(this.diffAccum) 24 !this.isScrolling) { this.scroller.scrollBy({ yOffset: -this.diffAccum }); this.diffAccum 0; } }为什么补偿要乘以 -1因为某个 Item 变高了后面的内容整体往下走用户视觉上应该往下滚一点才能对准同一块内容。滚动偏移需要同步调整才能抵消掉高度变化带来的漂移。这套机制不完美但足够实用。慢速滚动场景下用户几乎感知不到尺寸修正快速滚动时由于缓冲区域很大高度变化发生在屏幕之外不会造成可见的跳动。4. 跑通之后才看见的坑这半年的大半时间都耗在这4.1 坑一图片高度变化引发的滚动跳动第一个大坑出现在图片占位高度和真实高度差异很悬殊的时候。我设计的占位高是 240但实际图片可能只有 160 高也可能有 500 高。第一次修正还算正常但列表滚动到中间位置时用户只要稍作停留就会看到内容跳了一下。查了很久才定位到根因onAreaChange触发的时机比我想象的晚。图片是异步加载的加载完成后高度变化触发布局更新再触发onAreaChange最后才回调到我的updateRealHeight。这个时延在滚动过程中非常致命——用户手指还在屏幕上底下的坐标却突然变了。解决办法是加了两道保险只有isScrolling为 false 时才允许执行scrollBy补偿如果滚动中检测到高度变化只更新 offsetMap 和 totalHeight不触发视觉补偿等滚动完全停止后再做一次统一修正。这一改快速滑动时的跳动消失了只有停顿时可能有一次轻微的微调影响小到可以忽略。4.2 坑二复用节点没清干净列表串台了第二个坑是复用机制的经典问题——内容串台。现象是上一屏显示的是标题 A 图片 A滚出屏幕再滚回来某条 Item 变成标题 B 图片 A而且图片永远是之前那个换了数据也不会刷新。排查链路是这样的先怀疑图片加载组件没有生效看了网络请求发现 URL 每次都变了但 UI 上显示的还是旧图。然后怀疑是懒加载的if分支被缓存了最后才想到是槽位复用时子组件里的图片状态没有重置。问题出在复用的槽位没有强制清空再填充。我后来在绑定新数据之前会先调用一个resetForReuse()方法把图片源置空、占位图显示出来、所有文本置为默认值下一次布局再写入新数据。这个重置动作必须在同一个帧内完成否则用户能瞥到旧内容一闪而过。复用从来不只是把组件拿来用而是把组件状态彻底初始化后再一步到位地填充新数据。4.3 坑三嵌套滚动内层列表测量失效第三个坑出现在把 RcList 放进外层滚动容器的场景。外层是一个纵向 Scroll内层 RcList 的滑动事件被外层消费掉内层偏移量永远为零导致可见区间一直停留在开头后面的内容全部渲染不出来。这个问题本质上是滚动事件的归属冲突。官方 List 遇到嵌套滚动时有一套内部手势协商机制我自研的 Scroll 版没有。当时我在内层 Scroll 上设置了edgeEffect和scrollBar等属性都不管用。最终的解法是内层 RcList 不自己做滚动而是监听外层 Scroll 的偏移量变化然后把自己当成滚动视窗里的一个绝对定位内容块根据外层偏移量计算内部的可见区间。也就是说内层 Scroll 退化成普通 Stack滚动状态完全由外层驱动。这个调整带来一个额外好处内层 Item 的高度修正不会和上层滚动抢手势整体体验反而更跟手。但代价是 RcList 不能直接用在独立滚动场景里必须让外层显式注入一个scrollOffsetProvider。如果你要把自研列表放到滚动容器里这个设计要提前考虑不要像我一样跑到嵌套场景才推倒重来。4.4 坑四滚动条与实际内容对不上累计误差的治理第四个坑比较隐蔽是在处理自定义滚动条时发现的。我为了隐藏系统滚动条自己画了一个细条的进度条高度也绑定了 totalHeight。但滚动后发现滚动条已经到底了内容却还有一大截没显示反过来有时内容滚到一半滚动条提示已经到底。根因是预估高度和真实高度的累计误差没有传导到滚动条上。totalHeight 是基准前缀和 差分累加值而我画滚动条时只用了基准前缀和忘了加差分值。修复很直接滚动条的比例和位置统一用一个contentTotalHeight()方法获取这个方法内部包含所有修正。凡是和内容到底多高相关的地方都用它不再各自读取裸状态。这条经验也让我养成了一个习惯所有涉及高度计算的模块只允许通过一个统一入口取总高度禁止直接读内部数组。不然一个模块漏掉差分另一个模块没漏就会出现滚动位置和滚动条不一致这种灵异现象。5. 实测数据、可复用的沉淀与免费建议5.1 我本地测出来的几个数字在部分本地设备上我用一千条混合类型的数据做了滑动测试。静止状态下的渲染槽位数维持在 18 到 22 个对比普通的 List 一千个 Item 节点内存和布局压力是两个量级。首屏从数据就绪到画面稳定实测在 60 到 80 毫秒之间滚动过程中主线程的帧耗时基本平稳很少出现超过 16 毫秒的尖峰。需要说明的是这个数据没有做极限压测也没有特意调优到最好看只是给一个量级参考。真正有价值的是滚动时的平稳性而不是一个惊艳的单个数字。平稳性的来源就是前面讲的高度差分修正和缓冲机制——它让滚动期间几乎没有突发的布局重排。5.2 可以直接抄作业的四个模块如果把这段实践沉淀成可以复用的东西我会建议你重点抽取四块高度估算器SizeEstimator职责单一输入数据和可用宽度输出预估高度值。业务侧只需要注册不同 Item 类型的估算规则即可。高度字典模块offsetMap管理基准高度、真实高度、差分累加值对外提供按偏移找索引和按索引算位置两个接口。槽位复用池SlotPool负责分配、回收、重置槽位保证同一时刻渲染节点数量有上限并处理组件复用前的状态清理。滚动平滑器ScrollSmoother负责在静止状态下统一执行高度修正补偿避免滚动过程中出现视觉跳动。这四块互相之间只通过数据接口通信不直接操作 UI。如果哪天你要迁移到别的框架这四块逻辑几乎可以原样搬走只要改写 UI 绑定层就行。5.3 关于尺寸计算我给自己的下一步计划现在 RcList 已经稳定在业务线里跑了两个月下一步我准备做三件事第一把文本预估的工具方法再完善支持多字号混排和 emoji 行高差异第二把图片区域的预加载策略独立出来让图片在进入缓冲区域时就提前请求进一步缩短首屏等待第三给 RcList 补一个调试面板可以在线看到当前每个槽位绑定的数据 id、预估高度、真实高度和修正量排查问题会方便很多。最后说一点个人体会。如果你也要做一个列表组件别从复用开始做先死磕尺寸。虚拟列表的复用逻辑本质上是建立在每一个 Item 的高度都可知的基础之上的。高度算不准复用做得再好滚动也是飘的反过来只要高度计算稳复用只是水到渠成的事情。我花在这四类问题上的时间顺序也基本就是这篇的顺序——先算得准再造得稳。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询