1. RcList 项目概述:为什么是它,而不是 List?
HarmonyOS 开发半年多,列表这块我折腾过不少方案。系统自带的 List 组件够用,但一旦业务复杂起来——瀑布流、吸顶、编辑模式、上拉加载、下拉刷新、滚动节流——你会发现要么自己造轮子造到怀疑人生,要么就是性能上不去,滑动稍微快一点就开始白屏闪烁。这半年我几乎把所有列表场景都用 RcList 重写了一遍,把坑踩了个遍,也把它的脾气摸清了。
这个组件是开源的 HarmonyOS 列表解决方案,核心思路和 RecyclerView 那套很接近:用容器组件做布局,通过数据源驱动渲染,按需创建和回收列表项。它最大的价值不是“多了一个 List”,而是解决了原生 List 在复杂场景下的几个硬伤:大数据量卡顿、瀑布流支持不友好、编辑操作繁琐、下拉刷新和加载更多需要自己拼。
这篇“上篇”先讲清楚三件事:基础接入怎么玩、三种高频使用场景的配置要点(瀑布流、吸顶分组、自定义刷新),还有复杂交互(编辑多选、左滑操作、分页加载)的实战写法。适合已经在 HarmonyOS 开发里摸过一遍、对 ArkTS 有点基础、想提升列表开发效率和体验的朋友。RcList 官方术语叫“高性能列表容器”,实际项目里它的定位就是:一个能扛住复杂业务场景的列表基础设施。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RcList 基础接入:这玩意儿到底怎么跑起来的
2.1 核心机制:为什么它不卡
先别急着写代码,你得先理解它的底层逻辑。RcList 不是一个“大而全的自带 UI 的列表”,它更像一个布局引擎 + 数据源管理器的组合体。它内置了多种布局策略,每种布局都对应一个 LayoutManager,比如 ListLayoutManager 负责线性布局、StaggeredLayoutManager 负责瀑布流。你告诉它用哪种布局,它就按照对应规则去摆放列表项。
关键来了:它不是一次性把所有列表项都渲染出来,而是根据可视区域的滚动位置,动态计算需要创建多少个 item。滚出去了就回收,滚进来了就创建,再加上 item 缓存复用机制,所以即使有几千条数据,真正在视图树上的也就十几二十个组件。
这一点在 ArkUI 里特别重要。ArkUI 的组件树一旦节点过多,状态管理和布局计算的开销会成倍上涨。我实测过:同样 2000 条数据,用原生 List 加上各种装饰(分隔线、索引、吸顶)后,滑动帧率掉到 30 到 40 fps,换成 RcList 基本稳定在 60 fps 上下。差距就在这个“按需创建”上。
2.2 三步接入:工程配置与最小 Demo
接入 RcList 的步骤不复杂,但有一个细节很多人第一次会卡住:模块依赖要加在 entry 模块的 oh-package.json5 里,不是工程根目录。命令是:
bash复制ohpm install @ohos/rc-list
装完之后,最小可运行代码长这样:
typescript复制// Index.ets
import { RcList, RcDataSource, ListConfig, ListLayoutManager } from '@ohos/rc-list';
class MyDataSource extends RcDataSource<string> {
private items: string[] = [];
setData(data: string[]) {
this.items = data;
this.notifyDataChange();
}
totalCount(): number {
return this.items.length;
}
getData(index: number): string {
return this.items[index];
}
}
@Entry
@Component
struct RcListDemo {
private dataSource = new MyDataSource();
private layoutManager: ListLayoutManager = new ListLayoutManager();
private config: ListConfig = new ListConfig();
aboutToAppear(): void {
const data: string[] = [];
for (let i = 0; i < 1000; i++) {
data.push(`列表项 ${i}`);
}
this.dataSource.setData(data);
}
build() {
Column() {
RcList({ dataSource: this.dataSource, layoutManager: this.layoutManager, config: this.config })
.itemRenderer((index: number) => {
return this.itemBuilder(index);
})
.width('100%')
.height('100%')
}
}
@Builder
itemBuilder(index: number) {
Row() {
Text(this.dataSource.getData(index))
.fontSize(16)
.padding(16)
}
.width('100%')
.height(56)
.backgroundColor(Color.White)
}
}
几个关键点解释一下:
RcDataSource<T>:数据源适配器。核心是totalCount()和getData(index),组件渲染 list 时通过它们拿数量、拿数据。注意改完数据后要调notifyDataChange()通知刷新。ListLayoutManager:线性布局管理器。默认从上到下排布,这是最常用的布局方式。ListConfig:配置项。可以设置内置列表项间距、预加载数量、是否启用回收优化等。非常建议设置itemCount的预估高度,这样滚动条长度计算会更准确,否则大数据量时滚动会有一点点“跳”。
2.3 一个容易忽略的配置:cachedCount 到底该设多少
ListConfig 里有个 cachedCount(预加载个数)参数,很多新人不知道怎么调。它的作用是:在可视区域之外,预先多创建多少个 item,让滑动时不至于现创建现渲染。
我的经验是:不要把 cachedCount 设太大,默认值(3 个)其实就够了。设太大会让初始渲染变慢,因为架构上预加载的 item 也会参与测量和布局。如果你做的列表 item 内部有复杂计算(比如图片懒加载、Canvas 绘制),那最多设到 5。再大就是得不偿失。
3. 三大高频业务场景实战:瀑布流、吸顶、自定义刷新
3.1 瀑布流场景:双列布局的高级玩法
做商城类 App 或者内容社区,瀑布流基本是标配。RcList 对瀑布流的支持不是简单套一个 Grid,而是通过 StaggeredLayoutManager 实现的。它和图片类 App 的瀑布流效果一样,item 高度参差不齐,每列的高度独立增长,视觉上错落有致。
配置方式:
typescript复制import { RcList, RcDataSource, ListConfig, StaggeredLayoutManager } from '@ohos/rc-list';
// 瀑布流布局管理器
private layoutManager: StaggeredLayoutManager = new StaggeredLayoutManager();
private config: ListConfig = new ListConfig();
aboutToAppear(): void {
this.layoutManager.setColumns(2); // 设置两列
this.layoutManager.setGap(12); // 列与列之间的间距
this.layoutManager.setMargin({
left: 12, right: 12, top: 12, bottom: 12
});
this.config.setEnableRecycle(true); // 开启回收复用
}
这里面有个细节我得单独拎出来说:自定义 item 时必须根据内容动态计算高度。瀑布流和线性列表最大的区别就在这——列表项高度不能写死,否则每行对齐了,就没有瀑布流的“参差感”了。
我有个习惯做法:准备一个数据模型,包含 imageHeight 和 textHeight 两个字段,在数据源里先把每个 item 的最终高度算出来,然后在 itemRenderer 里直接按字段设置高度。
typescript复制class WaterfallItem {
title: string;
imageHeight: number; // 基于图片宽高比算出来的展示高度
textHeight: number; // 文本高度
}
这样做的好处是:帧率稳定,因为布局引擎不需要在滑动过程中反复测量 item 的实际高度。任何高阶列表组件最怕的就是“滑动中动态测量高度”,那基本宣告掉帧了。
3.2 吸顶分组列表:让分组头部乖乖待在最上面
通讯录、分类菜单、商品分组,这类场景需要分组吸顶——滑上去的分组头部“粘”在列表顶部,直到下一组把它顶走。
RcList 里实现吸顶的思路和原生 List 的 sticky 属性不一样。因为它走的是数据源模式,所以吸顶能力也是通过数据源返回的 item 类型来驱动的。我实现了一套模板:
- 数据模型上区分分组头部和内容项:定义
isHeader字段。 - 在
itemRenderer里根据isHeader渲染不同 UI。 - 通过
config.setSticky(true)开启吸顶,组件内部自动处理吸顶逻辑。
typescript复制class GroupData {
isHeader: boolean;
title: string;
items: string[];
}
// 在 itemRenderer 里判断
@Builder
itemBuilder(index: number) {
if (this.dataSource.getData(index).isHeader) {
// 渲染分组头部
Text(this.dataSource.getData(index).title)
.width('100%')
.height(40)
.backgroundColor('#F5F5F5')
.fontSize(14)
.fontColor('#666')
.padding({ left: 16 });
} else {
// 渲染普通条目
Text(this.dataSource.getData(index).items[0])
.width('100%')
.height(50)
.backgroundColor(Color.White)
.padding({ left: 16 });
}
}
等等,这看起来和普通列表没啥区别对吧?关键在于 config.setSticky(true) 之后,组件会识别出 isHeader 类型的 item,并在滚动时将其固定在顶部。但要注意:这种模式下,数据源里的分组头部也要参与索引计算,也就是说 totalCount() 要把分组头部的个数也算进去。
我在做通讯录时的实际套路是:在数据源初始化时,就把“首字母 + 该分组下联系人”展开成一个线性数组,头部 index 位置放分组对象,其余位置放联系人对象,这样 totalCount() 就是数组长度,不用额外处理复杂索引了。
3.3 自定义下拉刷新:不用再羡慕别人家的 Refresh
原生 Refresh 组件的样式太固定了,想改成和 App 品牌一致的 Loading 动画,或者加上一句随状态变化的文案,就得很费劲地去写自定义布局。RcList 的刷新调用机制很灵活——它把刷新状态回调暴露出来,让你自己决定要渲染什么。
我来说说实践中的配置方法:
typescript复制private config: ListConfig = new ListConfig();
aboutToAppear(): void {
this.config.setEnableRefresh(true); // 开启下拉刷新
this.config.setEnableLoadMore(true); // 开启上拉加载
this.config.setRefreshHandler({
onRefresh: () => {
// 处理刷新逻辑,结束后调用 finishRefresh
setTimeout(() => {
this.dataSource.notifyDataChange();
this.refreshController.finishRefresh();
}, 1500);
},
onLoadMore: () => {
// 加载更多逻辑,结束后调用 finishLoadMore
}
});
}
设计上 RcList 不会替你做请求网络、更新数据、关闭刷新动画这些事——它只负责告诉你“用户下拉了,你来处理数据”,以及“处理完记得告诉我”。这种思路我挺喜欢,因为网络请求、数据合并、状态重置这些业务逻辑千变万化,框架限制了反而难用。
自定义刷新头部的话,继承 RefreshHeaderComponent,重写 onStateChanged 方法,根据不同的刷新状态(下拉、松手、刷新中、结束)切换 UI 即可。
有个经典坑我必须提醒:刷新完成后,如果你的数据条数变了,一定要调用 notifyDataChange(),否则 RcList 不知道数据源变了,滚动位置和总数量对不上,会出现内容显示不全或越界崩溃。
4. 复杂交互实战:编辑模式、左滑操作与分页加载
4.1 编辑模式:长按进入多选状态的处理技巧
电商购物车、文件管理器这种场景,长按列表项进入编辑模式是常见交互。以前用原生 List 做多选,需要自己维护选中状态数组、控制选择框显隐、动态更新选中数量。RcList 没有单独提供“编辑模式”的 API,但它的数据源刷新机制很适合做这件事。
我的做法是:维护一个 isEditMode 布尔值和一个 selectedSet: Set<number>,长按列表项时切换 isEditMode,同时调用 dataSource.notifyDataChange() 触发全量刷新(数据量不大时没事,量大时下面会说优化方案)。在 itemBuilder 里根据这两个状态渲染:
typescript复制@Builder
itemBuilder(index: number) {
Row() {
// 编辑模式才显示的选择框
if (this.isEditMode) {
Checkbox()
.select(this.selectedSet.has(index))
.onChange((value: boolean) => {
if (value) {
this.selectedSet.add(index);
} else {
this.selectedSet.delete(index);
}
})
.width(24)
.height(24)
.margin({ left: 12 });
}
Text(this.dataSource.getData(index))
.fontSize(16)
.margin({ left: this.isEditMode ? 8 : 16 })
}
.width('100%')
.height(56)
.backgroundColor(this.selectedSet.has(index) ? '#E6F0FF' : Color.White)
.onLongPress(() => {
this.isEditMode = true;
this.dataSource.notifyDataChange();
});
}
代码很简单,但这里的性能问题值得讲一下:全量 notifyDataChange() 在大列表下会有明显的闪烁感。解决思路是给 RcDataSource 增加增量更新接口,把数据源基类继承下来自己实现部分刷新逻辑:
typescript复制class EditableDataSource extends RcDataSource<string> {
// ...基础实现...
// 局部更新某个 index 的 UI
notifyItemChanged(index: number) {
this.notifyDataChange(); // RcList 内部会 diff,但如果数据量大建议重写
}
}
RcList 的 notifyDataChange() 内部有 diff 机制,但它毕竟不是 DiffUtil,全量 diff 在几千条数据时还是有开销。我的经验是:如果列表超过 500 条,尽量避免频繁全量刷新;如果不超过 500,直接全量刷就好,别过早优化。
4.2 左滑操作:删除、置顶等快捷操作的实现思路
左滑露出操作按钮,这个是列表交互里的经典设计。RcList 官方没有直接把左滑做成一等公民,但利用它的布局机制可以实现。我当时用的是“内容 + 蒙层手势”方案:每个 item 内部放置两层——底层是按钮层(删除、置顶),上层是可滑动的操作层。监听 PanGesture 手势,控制操作层的 translate 偏移量。
typescript复制@Builder
itemBuilder(index: number) {
Stack({ alignContent: Alignment.End }) {
// 底层操作按钮
Row() {
Button('删除')
.onClick(() => this.deleteItem(index))
.width(80)
.height('100%')
.backgroundColor('#FF4D4F')
.borderRadius(0);
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.End);
// 可滑动的内容层
Row() {
Text(this.dataSource.getData(index))
.fontSize(16)
}
.width('100%')
.height('100%')
.backgroundColor(Color.White)
.translate({ x: this.currentOffset });
.gesture(
PanGesture()
.onActionUpdate((event: GestureEvent) => {
// 限制只能在 -80 到 0 之间滑动
this.currentOffset = Math.max(-80, Math.min(0, this.currentOffset + event.offsetX));
})
.onActionEnd(() => {
// 松手后自动吸附
if (this.currentOffset < -40) {
this.currentOffset = -80;
} else {
this.currentOffset = 0;
}
})
);
}
.width('100%')
.height(56)
.clip(true) // 关键:裁剪掉超出部分
}
这个方案有几个细节要注意:
clip(true)必须加,否则内容层滑动时会溢出 item 边界,视觉上会穿帮。- 手势里用
event.offsetX累加而不是每次用event.offsetX直接赋值,因为回调给的是相对上次回调的位移,不是总位移。 - 同时只能有一个 item 处于展开状态。我的处理是在控制层维护一个
currentOpenIndex,打开新的就把旧的关掉,联动刷新。
RcList 在这种场景下没有拖后腿,因为 item 本身是独立组件,手势处理都在 item 内部完成,不需要列表层做特殊配合。这一点值得给组件点赞。
4.3 分页加载:配合后端接口的正确姿势
上拉加载更多是列表最常用的功能之一。RcList 的 setEnableLoadMore(true) 开启后,会在滚动到底部前一段距离时触发 onLoadMore 回调。这个“提前触发”的设计很关键——用户的滑动惯性还在,加载数据的感觉是“无缝衔接”,而不是滑到底等半天。
实际对接后端的正确姿势:
typescript复制let currentPage = 1;
const PAGE_SIZE = 20;
onLoadMore: () => {
if (this.isLoading) return; // 防止重复触发
this.isLoading = true;
// 请求第 currentPage + 1 页
requestData(currentPage + 1, PAGE_SIZE).then((newItems: string[]) => {
// 追加到数据源
this.dataSource.append(newItems);
currentPage++;
this.isLoading = false;
this.refreshController.finishLoadMore();
// 如果返回的数据不足一页,说明没有更多了
if (newItems.length < PAGE_SIZE) {
this.hasMore = false;
// 可以通过 config 关闭加载更多,或者显示“没有更多了”
}
}).catch(() => {
this.isLoading = false;
this.refreshController.finishLoadMore();
// 加载失败 toast 提示
});
}
关于分页加载,有几个经验值得分享:
第一,防重复触发。RcList 的 onLoadMore 在底部预加载区域会频繁触发,如果用 isLoading 标志做拦截,就能避免重复请求。
第二,加载完成的回调必须调用。finishLoadMore() 不只是关动画,还负责重置内部的状态机。不调用的话下次触发加载更多会失效。
第三,区分“没有更多了”和“加载失败”。我习惯在 RcDataSource 里维护一个 hasMore 字段,在 itemRenderer 的最后一个 item 位置渲染“加载中/没有更多了/加载失败点击重试”三种状态。如果只做简单的 Toast,用户会一头雾水。
5. 半年实战避坑手册:RcList 性能与常见问题排查
5.1 性能调优的正确思路
用 RcList 半年,性能问题遇到不少,总结下来核心就三点:数据源管理、布局配置、item 复杂度。
先说数据源管理。RcList 性能好的前提是数据源方法要轻量。getData(index) 这个接口在滚动过程中会被频繁调用,如果里面做了复杂运算(比如字符串拼接、JSON 解析、数组查找),帧率一定掉。正确做法是:数据在 set 进来之前就加工好,getData 里只做数组下标访问。
再说布局配置。ListConfig 里有两个参数要配合:cachedCount 我前面提过设 3 到 5 就好;还有一个 setEnableRecycle(true),这个是回收复用总开关,一定要开。它决定了 item 在滚出屏幕后是直接销毁还是缓存复用,差距非常大。我做过一个对比:开回收后,500 条数据的列表滑动帧率从 45 提升到 58 左右,这是最值钱的一个开关。
最后说 item 复杂度。item 里的组件层级越深、数量越多,单次创建和回收的成本就越高,即使有回收机制也会卡顿。我的原则是:列表项的 UI 层级控制在四层以内,能用 Row/Column 解决的不用 Stack,能用文字属性控制的不额外套 Text。
5.2 一个排查了三天的问题:notifyDataChange 与 UI 不同步
这里必须分享一个我踩过的大坑。有段时间我的列表数据更新后 UI 不刷新,排查了很久,最后发现是我在数据源 Set 数据后立即调用了 notifyDataChange(),但数据源内部还在遍历期间,导致内部状态错乱。
RcList 的 notifyDataChange() 实现里有一个保护机制:在数据遍历过程中禁止变更。我当时的调用时机恰好在一个 for 循环的中间,直接触发越界。
解决办法:把 notifyDataChange() 放到下一个事件循环执行:
typescript复制setData(data: string[]) {
this.items = data;
setTimeout(() => {
this.notifyDataChange();
}, 0);
}
或者更稳妥的方案:统一在业务流程结束后再刷新,不要边遍历边改。
还有一个类似的坑:totalCount() 返回的值和 getData(index) 实际能取到数据不一致时,会直接崩溃。凡是数据源状态变更,一定要保证这两个方法的返回基于同一份快照。我后来把所有数据源类统一维护了一个 version 字段,变更时 version 加一,totalCount() 和 getData() 都基于版本判断,避免读到中间状态。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 滑动到底部不触发加载更多 | 未调 finishLoadMore() 导致状态机卡住 |
确保回调完成后调用 finishLoadMore() |
同时触发多个 onLoadMore |
缺少请求中标志位 | 增加 isLoading 拦截 |
| 数据源更新后 UI 不刷新 | notifyDataChange() 调用时机不对 |
使用 setTimeout 延迟调用或在数据变更完成后调用 |
| 瀑布流 item 高度错乱 | item 高度未在数据源中预先计算 | 数据前置处理,动态高度字段化 |
| 左滑时 item 内容溢出 | 未设置 clip(true) |
容器添加裁剪 |
| 编辑模式全量刷新闪烁 | 全量 notifyDataChange() 开销大 |
数据量超过 500 时实现增量更新 |
| 首次进入页面白屏时间长 | cachedCount 设置过大 |
调整为 3 到 5 |
| 滑动时偶发崩溃 | totalCount() 和 getData() 数据不一致 |
保证数据源基于同一快照返回 |
5.4 上篇总结之外的三个实操心得
半年用下来,我对 RcList 最大的感受是:它在架构层面做对了选择。数据源驱动 + 布局管理器分离的设计,让它能同时兼容普通列表、瀑布流、宫格等不同布局,而且切换布局只改一行代码,业务代码几乎不用动。
第二个感受是:RcList 更适合已经想清楚业务模式的中大型项目。它不像原生 List 那样拿来就写,需要你对数据源、布局、配置先有一个整体认知。但一旦跑起来,后续迭代的效率提升非常明显,特别是新增交互(编辑、左滑、吸顶)时,现有的架构消化得很干净。
第三个心得是:别被它的名字里的“高性能”迷惑,性能是设计出来的,不是白送的。用对数据源(轻量 getData)、用对配置(合理的 cachedCount 和回收开关)、用对数据结构(预先计算高度),才能真正发挥它的价值。这半年踩的坑,绝大多数不是组件的 bug,而是我没有遵守它的设计假设。
下篇我准备写 RcList 的进阶玩法,包括:配合 LazyForEach 的懒加载细节、多维表头列表的实现、列表项拖拽排序、以及如何把 RcList 封装成业务通用组件。手里还有几个压箱底的案例没写,等整理好了发出来,保证比这篇还有料。
