1. 项目缘起:为什么在 HarmonyOS6 上自己磨一个 RcList
先交代一下背景。去年年中我接到一个模拟项目X的 App 重构任务,技术栈锁定 HarmonyOS6 + ArkTS,要求做一个接近成熟资讯类产品形态的信息流页面。需求倒是不复杂:首页支持多种卡片混排、支持下拉刷新和上拉加载、需要丝滑滑动、内存还不能炸。但真正动手时,我发现官方原生 List 组件在遇到“动态高度 + 多类型条目 + 高性能要求”这个组合时,很多细节需要自己补,而且补起来很拧巴。
不是官方组件不好,而是它的定位偏向通用容器,把很多控制权收敛到了框架内部。比如想要精确控制 item 的复用时机、想做基于内容预判的懒加载、想针对不同卡片类型分配不同的缓存策略——这些在原生 List 上要么做不了,要么做起来非常别扭。所以我在团队内部立项了一个轻量级封装,代号就叫 RcList,半年里踩了无数坑,重点攻坚的就是“尺寸计算”这块硬骨头。今天把完整思路、代码结构、遇到的实际问题和调优经验一次性写出来,希望给正在 HarmonyOS6 上做列表需求的开发者省点时间。
这篇文章适合谁看?三类人:第一,正在用 ArkUI 开发复杂列表、被动态高度折腾到想骂人的开发者;第二,需要深度掌握列表组件性能调优思路的客户端工程师;第三,打算封装自己的通用列表组件的朋友。我不会只贴代码,更重要的是把每个设计决策背后的为什么讲清楚。
2. 组件定位与整体设计思路
2.1 RcList 要解决的本质问题
列表组件表面上是“把数据一个个渲染出来”,但真正复杂的是三个维度交叉造成的组合爆炸:
第一,数据形态不确定。真实业务里的列表绝不是同构数据,而是文本、图片、视频、动态懒加载卡片混在一起。每种卡片的高度计算规则完全不一样。
第二,视图状态复杂。item 内部可能有展开/收起、点赞状态、加载中状态、错误占位态。状态变更是局部刷新还是整体刷新?刷新会不会导致高度变化?高度变化如何影响滚动位置?这些都需要组件层给出明确策略。
第三,滚动性能目标苛刻。现代 App 对列表的要求是 60 帧,甚至 120 帧。在低端真机上,如果列表不做复用、不做尺寸缓存、不做异步预加载,跟手度会明显下降。
RcList 的设计初衷就是把这三维复杂度收拢到一个可配置的组件壳里,让业务侧只需要关心“我提供什么数据”和“我长什么样”,把复用策略、尺寸计算、滚动对齐这些问题全部内部消化掉。
2.2 整体架构分层
我按照“数据层 - 尺寸层 - 渲染层 - 交互层”四个层次搭建了 RcList:
- 数据层:负责接收业务侧传入的列表数组,按 type 字段分类,交给对应渲染器工厂。
- 尺寸层:这是整个组件的核心。它负责维护一个“条目预估尺寸缓存”,对外提供
getMeasuredSize(index)、updateSize(index, size)、clearCache()三个接口。 - 渲染层:基于 Scroll + Stack 或者 ForEach 实现真实渲染,只渲染可视区加缓冲区的 item,其余位置的 item 用占位容器填充。
- 交互层:封装触摸事件、滚动监听、下拉刷新、上拉加载。
这种分层最大的好处是每一层都能独立测试。尺寸层不关心 UI 细节,渲染层不关心数据来源。平时我调试性能问题时,可以直接在尺寸层打点,看每条 item 的测量耗时和命中率,不需要去抓界面线程。
2.3 为什么尺寸计算是胜负手
这一步想重点说清楚。很多人觉得列表性能问题主要出在渲染和图片加载上,但在纯 ArkUI 框架下,布局计算的开销往往被严重低估。一个包含 200 条混合卡片的列表,如果你不做任何优化地让框架自行测量所有 item,一次布局就可能产生上千次递归测量,每次测量还会触发子组件的 build 和 layout。而实际可视区一次只能看到 8 到 10 条,另外 190 条全部白算了。
尺寸计算的价值在于:让列表容器提前知道每条 item 的精确尺寸,就可以用“绝对定位 + 占位块”的方式渲染可视区,其他区域只填充固定高度的空白占位。这样整个页面只测量真实可见的那十几个组件,滚动过程中再按需增量测量新进入视口的 item。这里也解释了我为什么最终选择自己维护一套尺寸缓存,而不是完全依赖框架的懒加载——因为框架的懒加载粒度是“条目是否可见”,但不会帮你缓存“每条尺寸”。如果每条可见时都要重新测量,滑动时依然会有明显的布局抖动。
3. 尺寸计算的完整实现方案
3.1 尺寸模式与类型定义
RcList 支持三种尺寸模式,对应的枚举如下:
export enum RcListItemSizeMode { Fixed = 0, // 固定高度,所有 item 高度相同 Dynamic = 1, // 动态高度,每条 item 需要单独测量 Mixed = 2, // 混合模式,部分固定部分动态,常见于列表头部插入轮播图 }实际业务中 90% 的场景是 Dynamic。因为即使是同一种卡片,文本长度不同、图片加载成功与否,都会导致高度变化。
顺便定义一下统一的数据结构:
export interface RcListItemModel { key: string; type: string; data: any; }key 用于复用追踪,type 用于匹配渲染器,data 是业务原始数据。所有业务数据都必须走这个统一入口。
3.2 预估一版高度:启发式估算函数
在真实滚动前,组件还不知道某条 item 的高度,但必须先在布局数组里预留一个位置。所以我们需要一个快速的启发式估算函数estimateHeight(item)。
它的核心逻辑分三步。
第一步,判断 type 是否有“模板基础高度”。比如文本卡片,绝大多数情况下基准高度在 80~120vp 之间,图片卡片通常在 300vp 左右,视频卡片需要考虑封面比例,通常在 220vp 左右。这里可以给每个 type 设置一个默认值。
第二步,根据数据特征修正。比如文本超过 100 字且不带图片,预估高度 = 基准高度 + 每行高度 × 估算行数(行数 = ceil(字数 / 每行最大字符数))。再比如图片宽高比已知,可以用宽度除以宽高比得到图片显示高度,再加上卡片边距和文案区高度。
第三步,加上安全余量。估算值偏小会导致占位不足,出现滚动跳位;偏大会导致底部有一段空白,但视觉上问题不大。所以我会对估算结果做Math.max(estimated, minHeight + padding)处理。
这个阶段不需要精确,但必须快。我的实测是单次估算耗时不超过 0.1ms,对整体帧率影响可以忽略。
3.3 精确测量与缓存更新
当条目真正进入可视区时,RcList 会创建一个真实的 item 组件,然后通过onSizeChange或者onLayout回调拿到实际高度。这一步会产生测量开销,但只对可视区的条目发生,所以整体成本可控。
关键点在于缓存的更新策略。我用了一个对象数组sizeCache: Map<string, RcItemSize>,key 是 item 的key字段,value 长这样:
interface RcItemSize { height: number; lastMeasured: number; // 时间戳,用于 LRU 淘汰 layoutVersion: number; // 对应列表数据版本号,版本变化时失效 }滚动时组件会根据缓存决定当前需要渲染哪些 item。缓存命中直接使用,否则先按估算高度占位,再异步触发精确测量并回填。
这里有个很有意思的问题:如果用户滑动太快,某条 item 刚测完高度,用户已经划过去了,这条缓存还有价值吗?答案是很有价值,因为用户很可能回看。而且后续如果这条数据再次进入可视区,就可以直接用缓存高度,省掉一次测量。这就是我坚持维护缓存而不是每次重新测量的原因。
3.4 滚动位置对齐的特殊处理
动态高度列表最容易翻车的场景是:用户正在滑动,顶部一个条目因为图片加载完成突然从 300vp 变成 450vp,导致当前位置下方的所有条目都往下移动,视觉上出现“整体跳变”。
RcList 的解决方案是在交互层维护一个“视觉锚点”。具体逻辑是:滚动时持续记录当前视口顶部第一个完整可见的 item 的 key,以及该 item 上边缘与视口顶部的偏移量。当某个 item 高度变化时,不直接重新计算全量偏移,而是以锚点为基准,只修正锚点之后条目的偏移量。锚点之前的 item 高度变化(可能性很小)则在下次滚动到其附近时再修正。
这样做的好处是:单次高度变化只影响局部偏移,不会引发连锁抖动。实际体验上用户几乎感知不到跳动。
3.5 尺寸计算的代码骨架
放一段简化版的尺寸计算核心类,方便大家理解整体结构:
export class RcSizeManager { private cacheMap = new Map<string, RcItemSize>(); private estimates: number[] = []; constructor(private data: RcListItemModel[]) { this.buildEstimates(); } private buildEstimates() { this.estimates = this.data.map((item) => this.estimateHeight(item)); } public getHeightByIndex(index: number): number { const item = this.data[index]; if (!item) return 0; const cached = this.cacheMap.get(item.key); if (cached && cached.layoutVersion === this.layoutVersion) { return cached.height; } return this.estimates[index]; } public setMeasuredHeight(index: number, height: number) { const item = this.data[index]; if (!item) return; this.cacheMap.set(item.key, { height, lastMeasured: Date.now(), layoutVersion: this.layoutVersion, }); this.estimates[index] = height; } public invalidate(index: number) { const item = this.data[index]; if (item) this.cacheMap.delete(item.key); } }实际工程中还要加上对超长列表的 LRU 淘汰,防止 cacheMap 无限膨胀。这里为了可读性做了精简。
4. 综合示例:仿资讯信息流的完整实现
4.1 场景需求拆解
我拿模拟项目X的需求来举例。页面是一个类资讯 Feed,需要支持三种卡片:
- 文本卡片:标题 + 摘要 + 时间标签,最多两行摘要。
- 图文卡片:左图右文,图固定 120vp 宽,高度按 4:3 比例。
- 视频卡片:封面图 + 时长角标 + 标题,封面宽度充满卡片,高度按 16:9 比例。
另外,列表顶部有一个运营位轮播图,高度会随图片比例变化;列表底部有一个“正在加载更多”的尾块。
需求看起来不算复杂,但细节很磨人:文本卡片摘要超过两行要省略,但省略前的高度估算不能差太远;图片加载成功前没有宽高比,需要用占位图保持卡片高度稳定;视频封面的比例也可能因为服务端返回的图源不同而改变。
4.2 数据模型与渲染器注册
在项目里我按照 RcList 的统一模型来定义数据:
interface FeedData { key: string; type: 'text' | 'image-text' | 'video' | 'carousel' | 'footer'; data: any; }然后在组件初始化时注册渲染器:
rcList.registerRenderer('text', TextCardRenderer); rcList.registerRenderer('image-text', ImageTextCardRenderer); rcList.registerRenderer('video', VideoCardRenderer); rcList.registerRenderer('carousel', CarouselRenderer); rcList.registerRenderer('footer', FooterRenderer);渲染器本质是一个函数,输入 item 数据和上下文,输出可挂载到滚动容器上的子组件。注册制的好处是业务侧可以任意扩展新卡片,不需要改动核心组件。
4.3 动态高度的具体计算规则
这部分可以完美体现前面尺寸计算的应用。
文本卡片:基准高度 96vp(上间距 16 + 标题行高 24 × 2 + 时间标签 24 + 下间距 16),如果摘要超过两行的高度,就用估算行数乘 22vp 追加高度;如果摘要只有一行或没有,则截断高度。注意标题本身如果太长,换行会占用第二行,要合并计算——我这个版本用的是“标题可能行数 × 标题行高 + 摘要行数 × 摘要行高”的粗粒度算法,精度在 10vp 以内,对列表布局来说足够。
图文卡片:左侧图片固定宽 120vp,比例 4:3,因此图片高是 90vp。右侧文案区根据内容高度变化,最后整体高度取Math.max(110vp, 文本区高度 + 固定内边距)。这里的技巧是先按最大可能高度预设,让图片懒加载完成后,高度只可能缩小,视觉不会出现向下跳变。
视频卡片:封面宽度是屏宽减去卡片左右间距,高度 = 封面宽 × 9 / 16。这个值在数据下发时就能通过屏幕宽度离线计算出来,不需要等图片加载。所以视频卡片的高度基本是准确的,也是唯一一种可以预知精确高度的卡片。
4.4 缓存与复用的联动
在渲染层,RcList 维护了一个可见范围映射。简单来说就是根据滚动偏移量、视口高度和缓存高度数组,算出当前需要渲染的条目区间[startIndex, endIndex]。然后只对这个区间执行 ForEach,其余位置用占位视图填充。
复用池的逻辑是:当某个 item 滑出缓冲范围,就把它的节点拆解并缓存到一个 Map<type, View[]>中;当新的同类型 item 进入可视区时,优先从复用池取出节点,更新数据后重新挂载。这个回收复用的过程也需要尺寸管理器的配合——复用的 item 如果数据变化导致高度变化,必须先调用invalidate(index)` 清缓存,再走一次估算和精确测量链路。
如果复用后高度没有变,那么组件可以直接沿用旧缓存,连布局都不用重新计算,这是性能提升里非常可观的一部分。
4.5 下拉刷新和上拉加载的实现细节
交互层通过手势识别和滚动偏移量来触发状态变化。下拉刷新的关键参数是触发位移阈值。RcList 默认设为 80vp,并提供一个“阻尼系数”,也就是下拉时实际位移和手势位移的比例。阻尼系数我设置为 0.6,这样手感和原生列表接近。
上拉加载的触发则依靠底部缓冲判定:当滚动位置距离内容底部小于 200vp 时,自动触发加载回调,同时把 footer 状态从“空闲”切换为“加载中”。如果没有更多数据,则把 footer 切换为“没有更多了”,并且不再触发加载。
这个场景中,RcList 对 footer 的高度进行了特殊处理:加载中时高度固定为 80vp,没有更多时高度固定为 60vp。因为 footer 始终在列表尾部,不会影响滚动定位,所以不需要走动态测量逻辑。
5. 性能调优与实测数据
5.1 两个版本的基准对比
我在测试机上跑了两个版本:一个直接用官方 List 组件,另一个用 RcList。测试条件一致:同样的 500 条混合数据、同样的图片占位策略、滑动 15 秒,记录平均帧率和内存占用。
结果如下:
| 指标 | 官方 List | RcList |
|---|---|---|
| 平均帧率 | 47.3 FPS | 57.8 FPS |
| 滑动中的掉帧次数 | 128 次 | 23 次 |
| 峰值内存 | 182 MB | 141 MB |
| 首屏渲染耗时 | 145 ms | 98 ms |
官方 List 的性能其实不算差,但掉帧场景集中在“新 item 进入视口时的高度测量阶段”,而 RcList 因为有估算缓存,滑动中真正需要做精确测量的 item 数量减少很多,所以更稳定。需要说明的是,不同项目的数据结构差异会影响具体数值,这里的对比更值得关注的是趋势而不是绝对值。
5.2 三个关键调优点
第一个是bufferCount(可视区外上下两侧额外渲染的条目数)。我之前设置成 3,快速滑动时还是偶发白板,后来改成 5,同时把测量优先级提高,滑动流畅度立刻翻上来。但要小心 buffer 太大,内存和布局开销也会明显上升。推荐结合真机测试,从 5 起步做减法。
第二个是maxReusePoolSize(每个类型的最大复用节点数)。我按“可视区条目数 × 1.5”来设置,比如一屏能看到 8 条,那每个类型最多保留 12 个节点。太大会造成资源空闲占用,太小会导致频繁创建销毁。
第三个是图片加载的并发策略。我封装了一个LazyImage子组件,配合 RcList 的可见性回调,只有 item 真正进入可视区时才开始加载图片,并且优先进加载可视区中间的图片。这个策略对内存的改善非常明显,实测峰值内存下降约 22%。
5.3 日志与性能打点技巧
调优阶段不能靠肉眼感知,我给 RcList 加了一套轻量级打点工具。主要监控三个指标:
measureCountPerSecond:每秒发生精确测量次数。超过 30 次就说明缓存命中率低,需要检查估算偏差。cacheHitRate:缓存命中率。正常应该在 85% 以上,低于这个数要排查 key 是否稳定或 layoutVersion 是否频繁更新。reuseHitRate:复用节点命中率。低于 60% 说明复用池容量或策略有问题。
我在组件内部用hilog按 tag 输出,开发期开启,发布期关闭。这套打点帮我定位了好几个问题,最典型的是在滑动初期 cacheHitRate 很低,原因是首屏 item 还没校准过高度,后来我加了“首屏预测量”逻辑:在数据源加载完成后,立即对前 3 屏的条目做一次后台测量,这样用户开始滑动时,缓存基本是热的。
6. 常见问题与避坑实录
6.1 item 高度测量不准导致滚动跳动
这是动态列表最大的坑,症状表现为滑动过程中页面像“呼吸”一样上下起伏。根本原因是估算高度偏小,占位不够,真实 item 渲染后被撑高,导致下方所有条目瞬间下移。
我的排查路径:第一步检查估算函数是否漏算了 margin、padding、border 等额外占位;第二步检查 item 内部是否有未固定高度的图片或动态加载组件;第三步检查是否有异步数据回填导致高度二次变化。
最典型的隐蔽案例是图片加载成功后,因图片实际尺寸比例和占位比例不一致,导致高度从预计的 120vp 变成 180vp。解决办法是给图片组件设置固定的占位宽高,且占位比例要和大多数图片接近,而不是等到图片加载完才布局。
6.2 快速滑动时出现短暂白屏
白屏一般有两个原因。第一是渲染速度跟不上滑动速度,可视区条目已经滑出但新条目还未构建出来,解决办法就是加大bufferCount。第二是复用池里没有对应类型的节点,导致新条目必须走完整创建流程,耗时变长,解决方式是提前预创建 2~3 个常用类型节点放在池子里。
另外,白屏也可能是ForEach的 key 不唯一导致的。RcList 强制要求每条 item 的 key 全局唯一,业务侧如果用过index当 key,一旦数据插入或删除,整个列表的组件状态会错乱。这个问题排查起来非常隐蔽,因为页面看起来只是闪了一下,但实际内部状态已经完全重置了。
6.3 复用后的状态残留
一个图文卡片被滑出后,再从复用池拿出来渲染新数据时,上一张图片的加载状态、上次点击的阴影效果、展开的文字段,都可能残留。这是复用机制最容易出现的“业务污染”。
RcList 的约定是:所有渲染器内部对可变状态组件,比如图片、按钮、文本,必须实现reset()方法。在节点入池时调用reset(),逻辑上把数据清空、状态置为默认、图片源置空。出池时再通过render(data)重新赋值。
我一直建议不要在渲染器内部保存过多状态,能用数据驱动就数据驱动。这不仅是架构洁癖,更多是为了避免复用残留这类棘手 bug。
6.4 滚动位置在刷新后错乱
下拉刷新拿到新数据后,列表数据源更新,如果直接替换整个数组,之前记录的滚动位置全部失效。最稳妥的做法是:保留第一页数据,用新数据做「增量追加或替换」,并且把当前滚动位置锚定到第一条 key 不变的 item 上。
RcList 的refreshCompleted()方法内部会做一件事:记录当前视口顶部 item 的 key,然后刷新数据,再尝试在旧数据里找到这个 key,如果存在就滚回该位置。如果 key 已经消失,则回退到列表顶部。
这种方式在资讯类列表中表现不错,但有一点要留意:如果数据刷新后高度变化较大,无法精确恢复原位置,最多只能恢复到相近位置。用户对这种轻微偏移通常可接受,比直接回到顶部体验好得多。
6.5 实测给新手的调参建议
第一次上手 RcList,不要一上来就堆配置。我建议按这个顺序:
- 先把所有 item 类型跑通,确认高度估算和精确测量都正常。
- 再打开打点工具,观察缓存命中率和复用命中率。
- 最后才去调 bufferCount 和 maxReusePoolSize。
如果你一上来就把 buffer 调得很大,帧率反而可能下降,然后你会误以为组件有问题。很多时候性能问题不是某个参数不够,而是尺寸估算不准导致缓存失效频繁,再大的 buffer 也兜不住。
7. 半年来积累的几个关键认知
最后聊几句比较虚但很重要的体会。
列表组件本质上是一个「预测性系统」。它要预测用户下一步看到哪些数据,提前准备;预测哪些高度会发生变化,提前缓存;预测哪些节点可以复用,提前回收。每一个预测逻辑都可能出错,而尺寸计算是所有预测中优先级最高的底仓。底仓不稳,上层再好也白搭。这也是我把整个项目的迭代重心压在尺寸层的原因。
还有一点经验是:遇到动态高度列表性能问题,别急着怀疑渲染引擎,先检查你的尺寸数据来源。我见过很多同事在列表性能调优时拼命优化图片加载,结果真正的问题只是 item 的测量回调被触发了多次,每次都会导致局部重新布局。这种情况下的最优解往往不是减少图片,而是给 item 加一层稳定的缓存策略,让布局只发生在数据真正变化的时候。
如果你也在 HarmonyOS6 上做类似的列表需求,可以从这套尺寸计算思路兜底,再根据自己业务的卡片类型逐步扩展。最后再分享一个小技巧:在线调试时,把RcSizeManager暴露到全局变量,然后在命令行里手动调用getHeightByIndex(i),可以快速验证某条数据高度是否合理,比反复滑动界面调试高效太多了。