做列表组件这件事,我其实经历了一个很漫长的“真香”过程。半年前,我把一个叫RcList的列表组件列进项目计划时,团队里还有人问:系统自带 List 不好用吗?为什么非要自己再做一套?当时我答不上来,只觉得业务页面的重复代码已经多到让人烦躁。这半年过去,RcList 在 HarmonyOS6 生态下逐步成型,核心不再是“一个好看的滚动列表”,而是一整套全局配置系统加上清晰的API 接口示例。这篇文章不吹架构多漂亮,只把当时怎么拆、怎么设计、实际接入踩过的坑,原原本本说一遍。如果你也在折腾跨页面统一列表逻辑,这里面的取舍应该能帮你少走不少弯路。
更直接一点说,RcList 这半年解决的实际问题,是那批“每个页面都要写一遍却又长得差不多”的列表需求。从配置中心到渲染管线,从数据 diff 到复用池,从初始化的全局配置到业务页面的局部覆盖,我把整个链路完整跑通并稳定在内部多个模拟业务页面上。下面按我们真实的迭代顺序来记录,第一部分先聊聊最容易被忽略的立项逻辑。
1. 从“能不能少写一点重复代码”开始:RcList 立项动机与目标清单
1.1 重复造轮子的成本账:为什么不直接用系统 List
正常的研发流程里,我不会轻易动“自己写组件”的念头。系统列表组件能覆盖大部分场景,社区样例也一搜一大把。真正让我决定动手的现场,是某次参与一个内部中台应用的改造:十几个业务页面,涵盖订单卡片、消息列表、商品橱窗、搜索建议等形态,每个页面都在用不同的方式写列表。
有些页面用LazyForEach做懒加载,有些直接把整个数据源ForEach进去,滚动稍长就开始卡顿;下拉刷新、上拉加载、空数据占位、错误重试这几件事,几乎每个页面都有自己的一套实现。表面上看是代码风格不统一,实际上后面改一版主题色的时候,返工成本惊人。我不需要“再造一个 List”,我需要的是把一个列表在业务接入时必然遇到的组合逻辑收敛起来,对外暴露稳定统一的接口。
说白了,系统的列表组件给的是“零件”,而业务页面需要的是“带默认秩序的生产线”。RcList 定位于后者:默认帮你处理好数据变更、渲染调度、加载状态、复用回收,同时留出足够多的扩展点。
1.2 目标清单:一个列表组件需要承载的能力
立项当天,我列了一张很朴素的目标清单,之后的半年基本没有偏离过:
- 数据驱动优先:页面只负责给数据和通知数据变化,RcList 自己处理“哪些 item 需要刷新”。
- 全局配置与局部覆盖并存:主题、尺寸、加载动画、空页文案等内容走全局配置;单个页面可以在实例上覆盖。
- 自定义渲染器:列表不预设业务 UI,调用方传入 item 渲染器,RcList 只管理容器与调度。
- 内置加载刷新骨架:下拉刷新、上拉加载、首屏 Loading、空态、错误态,全部收敛进组件。
- 可扩展但不过度设计:拖拽排序、吸顶分组这类低频能力做成插件,不塞进核心。
这份清单最大的作用是“抗诱惑”。中途有好几次我想顺手加一个“万能卡片”、加一个“瀑布流模式”,都被清单拦住了。半年后回看,守住边界比添加功能更值钱。
2. 全局配置系统:RcList 的地基,不是简单全局变量
2.1 分层配置模型:从默认值到条目级覆盖
我最早的想法很天真:用一个static对象保存全局配置,页面使用的时候读一下不就行了?真正做下去才发现不行。全局配置至少要解决三个问题:如何初始化、如何动态更新、如何被已创建的列表实例感知到变化。尤其最后一条,如果更新全局主题后已经创建的老页面不响应,那这个“全局”就没有意义。
RcList 最终做成了五层配置模型,从低到高分别是:
| 层级 | 作用域 | 典型内容 | 优先级 |
|---|---|---|---|
| 内置默认值 | 所有实例 | 分割线、滚动条、默认动画时长 | 最低 |
| 全局默认配置 | 所有实例 | 主题色、统一字体、加载动画风格 | 次低 |
| 网络下发的动态配置 | 所有实例,支持远程开关 | 灰度项的默认值、运营位开关 | 中 |
| 页面实例配置 | 单个 RcList 实例 | 页面内空态文案、布局方向、缓存页数 | 高 |
| 数据条目配置 | 单条 item | 是否可点击、自定义高度、延时加载 | 最高 |
真正的实现里,我把后两层合并成“实例优先级”,即条目配置优先于实例配置,实例配置优先于全局合并结果。合并算法用“深合并”而不是简单赋值,因为配置里既有标量又有对象,简单覆盖会把某一层的子字段整个冲掉。
这里有个容易踩坑的点:深合并是递归执行的,性能敏感时不能每次渲染都全量递归。RcList 的做法是,在配置更新时完成一次合并,生成一个不可变配置快照,后续所有渲染逻辑只读快照,不在渲染路径上做合并。
2.2 配置加载链路与动态更新机制
整个配置加载链路分为三个阶段:
- 启动期:应用入口调用
ConfigManager.getInstance().initialize(defaultConfig),把内置默认值和本地存档合并,生成第一份全局快照。 - 异步期:如果有远程配置请求,返回后再次
mergeConfig(),生成新快照,同时向所有订阅者广播。 - 运行期:某个页面创建 RcList 实例时,传入的实例配置会和全局快照合并,生成该实例自己的快照。
动态更新的关键是“发布-订阅”。RcList 的每个实例都会在创建时向ConfigManager注册,订阅onGlobalSnapshotChanged事件。事件回调里不是直接改配置对象,而是触发一次“轻量重建”:
// ConfigManager 核心逻辑片段 import { Emitter } from '../emitter/Emitter'; class ConfigManager { private static instance_: ConfigManager | null = null; private snap_: RcListGlobalConfig | null = null; private emitter_ = new Emitter<RcListConfigChangeEvent>(); static getInstance(): ConfigManager { if (!ConfigManager.instance_) { ConfigManager.instance_ = new ConfigManager(); } return ConfigManager.instance_; } initialize(config: RcListGlobalConfig): void { this.snap_ = RcConfigMerger.merge(DEFAULT_RCLIST_CONFIG, config); this.emitter_.emit('globalChanged', { snapshot: this.snap_ }); } mergeConfig(partial: Partial<RcListGlobalConfig>): boolean { if (!this.snap_) { return false; } const next = RcConfigMerger.merge(this.snap_, partial, { immutable: true }); if (RcConfigMerger.isDeepEqual(this.snap_, next)) { return false; } this.snap_ = next; this.emitter_.emit('globalChanged', { snapshot: this.snap_ }); return true; } getSnapshot(): RcListGlobalConfig { return this.snap_; } subscribe(cb: (snapshot: RcListGlobalConfig) => void): () => void { return this.emitter_.on('globalChanged', (e) => cb(e.snapshot)); } }这段代码在半年里几乎没有大改,因为它把“全局配置要怎么更新”这件事彻底钉死了:外部只需要调用mergeConfig,中间层订阅快照变化,不需要关心配置是被谁改的。isDeepEqual的提前判断也很重要,避免远程配置轮询回来后做大量无意义的刷新。
2.3 为什么不直接用全局状态工具
前后对比过框架自带的全局状态能力。结论是:全局状态工具擅长“跨页面共享状态”,但它不负责“配置合并优先级”,也不提供“不可变快照”。配置系统天然比状态多两个能力,一是层级合并,二是订阅广播。如果硬用全局状态工具做,每一层覆盖关系都要业务自己维护,很容易出现 A 页面改了配置,B 页面跟着变,C 页面读到的还是旧值的混乱情况。
RcList 自己维护配置系统的另一个好处是,可以把“配置来源”和“配置用途”链路完整打日志。出问题时打开日志追踪,能明确看到是远程配置下发了错误默认值,还是某个页面实例传错了参数。自定义的配置中心本质上就是在系统能力之上,追加了一层业务视图。
3. 核心架构拆解:从数据变动到界面渲染的完整链路
3.1 数据到视图的五个阶段
RcList 每次收到数据变化,都会走一条固定管线,我把它们命名为:数据适配 → 差异计算 → 渲染计划 → 组件挂载/更新 → 回收入池。
数据适配器负责把业务传入的原始数组转换成RcListItemModel[]。转换过程中会为每个条目生成唯一key,这个key是后面差异计算的基础。没有key的列表只能全量刷新,有key之后才能精确到最小更新单位。
差异计算阶段采用经典的“同层比较”思路:先比较当前渲染快照与目标数据集的 key 集合差异,计算出新增、删除、移动三种操作。开发者最关心的“只刷新变化的 item”,就是在这个阶段实现的。HarmonyOS6 上LazyForEach自身也支持 key 差异更新,但它在复杂 item 结构下的表现并不稳定,业务侧自定义差异策略反而更可控。
渲染计划阶段会把差异操作排成一个队列,分批执行。这里有个细节:如果一帧内 diff 出 500 个 item 需要更新,全部立即执行会造成明显卡顿。RcList 会把更新拆成每帧最多 32 个操作的小块,配合requestAnimationFrame推进。
组件挂载阶段调用业务传入的itemRenderer,得到可渲染节点后挂载到对应位置;如果这次只是数据更新而 key 没变,则直接调用渲染器上的updateItem,复用旧节点。
最后的回收入池,强调所有离开可视区域的 item 不是直接销毁,而是进入复用池,等待下一次挂载。
3.2 复用池的双池策略与回收细节
复用池是列表滚动流畅度的重要保障。RcList 采用的是双池策略:常驻池与缓冲池。
| 池类型 | 容量策略 | 用途 |
|---|---|---|
| 常驻池 | 可视区 item 数 × 1.5 左右 | 正常滚动时优先从这里拿 item |
| 缓冲池 | 不超过常驻池 1 倍 | 快速滚动时临时存放被回收到的一半节点 |
双池的价值在于,慢速滚动和快速滚动的“回收节奏”完全不同。慢速滚动时,刚滚出去的 item 可能很快滚回来,直接从常驻池复用;快速滚动时,大量 item 瞬间离屏,如果全部塞进同一个常驻池,池子会被冲垮,回收和复用的顺序也难控制。缓冲池承接这一波冲击,滚动手速慢下来后再把缓冲池的内容逐步归并到常驻池。
回收策略上有两个容易被忽略的细节:
- 回收前必须还原节点状态:某些业务侧会在 item 上临时加高亮、改背景色,如果直接复用就会带着旧状态出现在新位置。RcList 在回收时强制调用渲染器的
detach(item),让业务侧有机会还原。 - 数据绑定的解绑与重绑:复用节点上的数据绑定失效是隐性 bug。RcList 要求每个渲染器实现
bindItemData(model)和unbindItemData(),绑定和解绑都成对出现。
3.3 核心模块的职责边界
整个 RcList 核心代码拆成了四个模块,目的就是防止“一个文件管所有事”:
ConfigManager:处理上层的全局配置与订阅广播。DataPipeline:数据适配、差异计算、渲染计划的调度器。ItemPoolManager:复用池、节点回收与复用。RendererHost:统一调用外部渲染器,管理 item 生命周期。
模块之间通过接口通信,互不依赖实现。这样拆最直接的收益是,后来单独给 Footer 加载组件做性能优化时,只改了DataPipeline,其他模块一行没动。
4. API 接口示例:从初始化到业务接入的代码走读
4.1 全局配置初始化示例
先看一个页面接入的完整流程。假设业务场景是“订单列表页”,需要统一的主题色、统一的加载状态、统一的错误重试文案。初始化代码通常写在应用入口:
// 应用入口初始化 RcList 全局配置 import { ConfigManager } from 'rclist/ConfigManager'; import { RcListGlobalConfig } from 'rclist/type'; const globalConfig: RcListGlobalConfig = { theme: { primaryColor: '#0A59F7', backgroundColor: '#F5F6F7', cardRadius: 12, }, loading: { indicatorType: 'circular', indicatorColor: '#0A59F7', autoLoadNextPage: true, }, empty: { icon: '/resource/empty/order.png', title: '暂无数据', showRetry: true, }, error: { title: '加载失败', retryText: '点击重试', }, animation: { durationMs: 200, useReduceMotion: true, }, }; ConfigManager.getInstance().initialize(globalConfig);这段初始化放在入口处有几个好处。第一个是确保任何页面创建 RcList 之前,快照已经生成,订阅者不会错过首批数据。第二个是它明确表达了“全局配置只在这里做一次”,后面远程配置通过mergeConfig增量变更,而不是再调用一次initialize,避免把默认值冲掉。
4.2 页面实例创建与局部覆盖
页面内部创建 RcList 时,传入页面的私有配置。比如订单列表页想要空态文案是“还没有订单,去逛逛吧”,错误重试走全局默认,同时指定列表方向为纵向:
import { RcList } from 'rclist/RcList'; import { OrderDataSource } from './OrderDataSource'; import { OrderItemRenderer } from './OrderItemRenderer'; const myList = new RcList({ dataSource: OrderDataSource.getInstance(), config: { empty: { title: '还没有订单,去逛逛吧', showRetry: false, }, layout: { orientation: 'vertical', cacheCount: 8, }, }, itemRenderer: OrderItemRenderer, });这里的config是局部配置,合并时会覆盖全局配置中的empty.title,但empty.showRetry仍然读取全局的true。因为局部配置只提供子字段,深层合并保留其他未提供的字段。这个局部覆盖能力,让“系统级统一”和“页面级差异化”同时存在,而不是只能二选一。
4.3 数据源接入与自定义渲染器绑定
接着要接入数据源。RcList 的数据源只需要实现一个简单的接口,核心是返回列表数据和变更通知:
import { RcListDataSource } from 'rclist/type/RcListDataSource'; class OrderDataSource implements RcListDataSource { private orders_: OrderModel[] = []; getItemCount(): number { return this.orders_.length; } getItemModel(index: number): OrderModel { return this.orders_[index]; } getItemKey(index: number): string { // 显式提供稳定 key 是 diff 性能的基础 return this.orders_[index]?.orderId ?? `order-${index}`; } notifyDataChanged(changedKeys?: string[]): void { // 常规数据变动,RcList 实例会订阅该事件 } }再来看渲染器。渲染器是一个含绑定和解绑方法的对象,业务 UI 放在createNode里:
import { RcListItemRenderer, RcListItemContext } from 'rclist/type'; class OrderItemRenderer implements RcListItemRenderer { createNode(): HTMLElement { // 创建 item 根节点,并缓存子节点引用 const root = document.createElement('div'); root.className = 'order-item'; return root; } bindItemData(node: HTMLElement, model: OrderModel, context: RcListItemContext): void { node.querySelector('.order-title')!.textContent = model.title; node.querySelector('.order-price')!.textContent = `¥${model.price}`; // 设置点击事件时注意先移除旧监听,避免复用节点叠加监听 node.onclick = () => context.emit('orderClick', model); } unbindItemData(node: HTMLElement): void { node.onclick = null; } }绑定和解绑成对出现,这是我在前面回收策略里强调过的要求。很多初次接 RcList 的同事以为bindItemData里直接赋值就完事了,结果出现列表快速滚动时部分 item 的点击事件触发两次甚至多次,根因就是复用节点上堆积了多个旧监听器。
4.4 事件回调与状态控制接口
RcList 对外暴露的事件回调包括加载状态、滚动状态、用户交互三类:
| 回调名称 | 触发时机 | 常见用途 |
|---|---|---|
onLoadStatusChange | 首次加载、加载更多、刷新完成 | 收起下拉刷新动画 |
onScrollPositionChange | 滚动过程中持续触发 | 更新回顶按钮显隐 |
onItemClick | 点击某个 item | 页面跳转、弹窗 |
onPullRefresh | 用户下拉到阈值 | 重新请求首屏数据 |
onReachBottom | 滚动接近底部 | 加载下一页 |
页面拿到实例后,可以通过四个核心控制方法操作列表状态:
// 示例代码 myList.refreshData(); // 触发一次下拉刷新逻辑 myList.appendItems(items); // 追加下一页数据 myList.scrollToIndex(0); // 回到顶部 myList.setLoading(false); // 手动结束 Loading 状态这一组 API 设计力求“少而稳”。半年里我反复删过一些看起来很有用但使用率极低的接口,最后只保留这些。接口越多,测试矩阵越复杂,后续维护成本是肉眼可见的。
5. 集成实测中的三个深坑:配置失效、内存抖动与滚动冲突
5.1 坑一:进程存活期间全局配置更新不生效
现象是:在应用运行过程中调用了mergeConfig修改主题色,日志显示配置快照已经更新,但页面上的列表没有变化。第一反应去查 RcList 实例的订阅逻辑,发现订阅确实注册了,回调也触发了,问题出在回调里只更新了ConfigManager内部的快照引用,没有通知已经创建出来的 item 节点重绘。
原因很好理解:全局配置影响的是 item 的样式、动画、文案,而 item 节点的实际样式在渲染器bindItemData里已经写死了。配置变化后,得让 RcList 重新执行一次“渲染计划”,把当前所有可见 item 的样式属性重新从快照里读取。
解法分两步走。第一步,RcList 在实例上维护一个configVersion_字段,配置更新时递增;第二步,渲染计划执行时,如果发现configVersion_变化,就对当前可见节点执行一次“样式重建”,重新触发bindItemData。这样既不需要重建数据节点,又保证所有可见 item 能拿到新配置。
提示:接入方在自定义渲染器里,尽量不要在
createNode阶段读取全局配置写死样式。要把配置读取放到bindItemData里,否则配置更新后样式重建时拿到的还是旧值。
5.2 坑二:快速滚动下内存抖动与白屏
某次压力测试中,2000 条数据的列表在快速滑动时出现了周期性的内存飙升和偶发白屏。用工具抓了一段内存曲线,发现每次内存飙升都发生在缓冲池扩容的时候。
排查过程是这样的:快速滚动会让大量 item 离开可视区,ItemPoolManager发现常驻池满了,就把新回收的节点丢进缓冲池。缓冲池容量不够时又扩容。这个逻辑本身没问题,问题是节点上的图片资源没有释放。item 绑定数据后,图片地址被设置到img标签,当节点进入缓冲池时,渲染的unbindItemData虽然把点击事件清掉了,却没有清掉图片资源。
修复方式就是在unbindItemData里显式清理大资源引用,同时给缓冲池加一条规则:缓冲池内的节点超过 5 秒未被复用,就主动回收图片并销毁节点,避免“池子长期吃满内存”。
这里想强调一个实操心得:复用池不是缓存池。缓存池可以放着资源不动,复用池的存在是为了“快速再次上屏”,如果资源占用太高,就要狠心把节点从池子里淘汰,否则低端机上体验会很差。
5.3 坑三:横竖嵌套列表的滚动“打架”
页面里出现一个横向滚动的商品卡片栏,外面套着一个纵向 RcList。现场表现是手指在横向卡片区域左右滑动时,纵向列表也会跟着轻微上下滚动,两个手势互相抢焦点,体验非常分裂。
这个问题最麻烦的点在于,它只在真机上有明显体感,模拟器里看不太出来。逐步排查后确认是父级 RcList 的滚动容器拦截了子横向容器的滑动事件。RcList 默认的策略是“任意位移都视为可滚动”,没有给子容器优先处理机会。
解决方式是给嵌套场景加一个触摸方向仲裁器。当手势事件的起始位移方向偏向水平时,标记为 child-first,把事件优先交给横向容器;只有超过一定时间或位移阈值还没被消费,再转交给纵向滚动。这个仲裁不依赖具体横向容器的实现,通用性足够。
顺带补充一个建议:横向嵌套场景比较多的页面,建议把横向卡片栏拆成独立的“轻量滚动单元”,不要硬依赖父级列表的滚动组件。组件之间解耦越彻底,手势冲突的调试成本越低。
6. 半年迭代后的取舍清单:哪些设计经住了考验
6.1 被验证有效的三个核心决策
第一,配置快照的不可变性。最开始实现配置合并时想过直接改对象引用,后来改成“新对象替换旧对象”。这一个小小的习惯,让测试和排查变得非常舒服:任何时间点你都能拿到一份完整的、不会被人偷偷改掉的配置对象。后续如果要加“配置历史回溯”功能,这套设计也能直接支持。
第二,显示 key 的强制要求。RcList 要求数据源提供getItemKey,而不是自动用数组下标兜底。虽然增加了接入方的两行代码,但换来了差异计算的高确定性和渲染性能的稳定。自动生成 key 永远快不过业务自己的唯一 ID。
第三,双池复用。这直接解决了快速滚动的内存抖动问题。如果你现在正在做类似组件,建议一开始就上双池,后面补的话,回收归并逻辑会很麻烦。
6.2 哪些方向我提前放弃掉
拖拽排序和侧滑删除,这两个早期呼声很高的能力,最终都做成了插件,没有进入 RcList 核心。原因很简单,拖拽子项的位置计算、落位动画、与其他手势的配合,是一个非常独立的交互子系统,如果内置,核心渲染逻辑会变得极其臃肿。
另一件主动放弃的事,是“所有页面强制使用全局配置”。强制统一虽然能减少差异,但业务个性化需求永远存在。更好的姿势是:默认值全部全局统一,特殊情况页面自己覆盖。半年下来,真正走到“必须覆盖”的页面占比不到 20%,说明全局配置把大部分页面都已经拉齐了。
6.3 如果你也想做类似组件,可以白嫖的三条经验
- 先用 3 到 5 个真实业务页面做需求输入源,收集共性,别拍脑袋设计 API。
- 配置系统和渲染管线分开写,哪怕早期配置少得可怜,也不要耦合在一起。布局和风格变化是高频需求,配置系统独立,后续改起来才不心痛。
- 每次改动先跑一遍滚动压测用例,把数据调到 5000 条以上。列表组件最大的敌人不是功能缺失,是长期运行后的状态残留,自动化用例比人肉测试靠谱得多。
半年磨一剑,磨出来的不只是一个 RcList,还有一套看待“通用能力”的方法论:先盘点重复代码,再抽象公共配置,最后用分层架构兜底。配置系统决定组件能覆盖多广,数据管线决定滚动多稳,而合理的 API 决定别人用得有多爽。这个思路放到任何平台的列表组件上,都成立。