1. 为什么我花了半年时间自研一套 List 组件
先说结论:官方列表组件在复杂业务场景下,配置管理与样式复用的成本会随着页面数量线性膨胀,最终变成一个没人敢动的巨型状态集合。
我接手某跨平台系统的核心列表页时,页面数量接近 40 个,每个页面都独立维护自己的一套下拉刷新、加载更多、空态、异常态逻辑。官方 List 组件给的是能力,不是约束——同一个功能,十个人写出十种写法。有人用 Scroll 套 Column 模拟,有人用 List 的懒加载,有人直接堆 LazyForEach。表面看都能跑,但一旦涉及全局主题切换、深色模式适配、统一 loading 样式调整,就得一个页面一个页面改,改完这个漏那个。
RcList 就是在这个背景下立项的。目标不是替代官方组件,而是在官方组件之上封装一套带全局配置系统、统一状态管理、可插拔 API 接口的组件体系。标题里写的"半年磨一剑",一点不夸张——前两个月几乎都在设计配置系统的数据结构,真正写 UI 逻辑反而只用了三周。
这篇文章把 RcList 的核心架构拆开讲清楚:全局配置系统怎么设计、API 接口怎么暴露、业务侧怎么接入、以及我在半年迭代里踩过的坑。适合正在做鸿蒙应用架构设计、或者被列表页重复代码折磨的开发同学参考。
2. 全局配置系统:为什么需要三层级联配置
2.1 配置维度拆分:从环境到单实例
RcList 的全局配置系统设计之初,我定了一个原则:配置必须支持覆盖,且覆盖方向要单一清晰。
整个配置体系分三层:
- 第一层是全局默认配置,通过
RcListGlobalConfig.setDefault()注入,作用于所有 RcList 实例。 - 第二层是业务模块配置,通过
RcListModuleConfig按模块维度覆盖,解决"这个模块的列表风格跟全局不一样"的问题。 - 第三层是单实例配置,通过组件构造参数传入,优先级最高。
这三层的关系,可以类比操作系统里的环境变量、项目配置文件和命令行参数——越靠近业务侧,优先级越高。
优先级排序:单实例配置 > 模块配置 > 全局默认配置这个设计最直接的好处是:通用逻辑只需要写一遍,特殊逻辑按需覆盖,不需要在组件内部写一堆 if/else 去判断当前是什么场景。
2.2 配置合并的实现思路
配置合并不能简单做 Object.assign 式的浅合并,因为配置里存在嵌套对象,比如 loading 样式包含背景色、指示器类型、文案颜色等子属性。浅合并会导致局部修改时把整组配置覆盖掉。
RcList 的配置合并基于一个mergeConfig工具函数实现,核心逻辑是递归遍历配置对象,如果当前层级两侧都是对象,就继续深入合并,否则用高优先级一侧的值覆盖。
function mergeConfig(highPriority: Record<string, Object>, lowPriority: Record<string, Object>): Record<string, Object> { const result: Record<string, Object> = {}; const keys = new Set([...Object.keys(highPriority), ...Object.keys(lowPriority)]); keys.forEach((key) => { const highValue = highPriority[key]; const lowValue = lowPriority[key]; if (highValue === undefined) { result[key] = lowValue; } else if (lowValue === undefined) { result[key] = highValue; } else if (isPlainObject(highValue) && isPlainObject(lowValue)) { result[key] = mergeConfig(highValue as Record<string, Object>, lowValue as Record<string, Object>); } else { result[key] = highValue; } }); return result; }注意这里用isPlainObject而不是typeof === 'object',是因为 Array、Date 这类特殊对象不应该被递归合并,直接整体覆盖更符合直觉。
配置合并的时机也很关键。RcList 在aboutToAppear生命周期里完成一次合并,缓存为this.finalConfig,后续所有渲染逻辑只读这份缓存,不再做动态合并。动态合并虽然灵活,但会在每次属性访问时产生性能损耗,在列表滚动场景下尤其明显。
2.3 配置热更新与主题切换
全局配置系统还支持运行期热更新。业务侧调用RcListGlobalConfig.update()修改全局配置后,需要通知所有已创建的 RcList 实例刷新配置。
这里我用了一个订阅发布模型:全局配置中心维护一个监听器列表,配置变更时逐个通知实例重新合并配置并触发重渲染。
class RcListConfigCenter { private static listeners: Array<() => void> = []; static update(config: RcListGlobalConfigModel) { this.globalConfig = config; this.listeners.forEach((listener) => listener()); } static subscribe(listener: () => void) { this.listeners.push(listener); } }主题切换的场景下,这个机制避免了重新创建页面的代价。实测在 30 个实例同时订阅的场景下,全量刷新耗时约 80ms,对用户无感。
但要注意,订阅监听器后必须在页面销毁时解除订阅,否则会造成内存泄漏。RcList 在aboutToDisappear里自动处理了解绑逻辑,外部接入时无需额外操作。
3. API 接口设计:把复杂度关在组件内部
3.1 组件对外暴露的接口
RcList 的 API 设计原则是:核心字段少、扩展字段按需取、方法接口命名直白。
组件对外暴露的属性接口分为三组:
- 数据配置组:
dataSource、renderItem、listLayout - 状态控制组:
loadingState、isRefreshing、loadMoreState - 回调事件组:
onRefresh、onLoadMore、onItemClick
interface RcListProps { // 数据配置 dataSource: Array<any>; renderItem: (item: any, index: number) => void; listLayout?: RcListLayout; // 状态控制 loadingState?: RcLoadingState; isRefreshing?: boolean; loadMoreState?: RcLoadMoreState; // 回调事件 onRefresh?: () => void; onLoadMore?: () => void; onItemClick?: (item: any, index: number) => void; }这里特意把dataSource设计为普通数组而非LazyForEach的数据源接口,是因为内部封装了数据源桥接逻辑。业务侧只需要保证传给dataSource的数组引用变化时能触发刷新——RcList 内部通过@Prop监听数组引用变化并通知LazyForEach刷新。
3.2 状态枚举的设计细节
列表组件最常见的状态流转是:初始加载 -> 加载成功(有数据/空数据) -> 加载失败 -> 下拉刷新 -> 上拉加载更多。
RcList 把状态定义为枚举而非布尔值组合,核心原因是可以穷举状态空间,避免出现"既是加载中又是空态"这种非法组合。
enum RcLoadingState { Initial, // 初始态 Loading, // 加载中 Success, // 加载成功 Failure // 加载失败 }loadMoreState则用另一个枚举表达:
enum RcLoadMoreState { Idle, // 空闲,不显示加载更多 Loading, // 加载更多中 NoMore, // 没有更多数据 Error // 加载更多失败 }状态机的转换逻辑放在组件内部,外部通过修改属性驱动状态迁移。组件收到loadingState变化后,自动判断当前应该渲染骨架屏、空态、错误态还是正常列表内容。
3.3 方法接口与控制器模式
除了属性驱动,RcList 还提供控制器模式,支持业务侧主动调用方法。控制器通过RcListController实例注入,暴露的方法包括:
interface RcListController { scrollToIndex(index: number, smooth?: boolean): void; scrollToTop(): void; refresh(): void; reload(): void; setLoadingState(state: RcLoadingState): void; }控制器模式最大的价值在于解耦。业务组件不需要持有列表组件的状态变量,只需要调用控制器方法即可完成操作。比如退出页面时想清空列表数据,只需controller.reload(),底层会根据配置决定是清空后重新加载还是保留数据静默刷新。
控制器内部实现是通过@Link与组件实例状态建立绑定,调用方法时修改内部状态变量,驱动 UI 更新。这种方法链清晰且易于单元测试——不需要渲染真实组件就能验证控制器逻辑。
4. 集成实操:一个完整的最小示例
4.1 全局配置注入
接入 RcList 的第一步,是在应用入口注入全局配置。以某跨平台系统的首页为例,需要在EntryAbility的onCreate或模块入口处调用:
// 全局配置注入 RcListGlobalConfig.setDefault({ listLayout: { mode: 'list', // 列表模式:list/waterfall/grid columns: 1, spacing: 12, padding: { left: 16, right: 16, top: 8, bottom: 8 } }, loadingConfig: { firstLoadType: 'skeleton', // 首屏骨架屏 firstLoadSkeletonCount: 6, indicatorType: 'circular', indicatorColor: '#1B6EF3' }, emptyConfig: { icon: '/resources/empty.png', title: '暂无数据', subtitle: '下拉刷新试试', showAction: false }, errorConfig: { icon: '/resources/error.png', title: '加载失败', subtitle: '点击重试', showRetry: true }, refreshConfig: { enable: true, style: 'default', threshold: 80 }, loadMoreConfig: { enable: true, style: 'auto', // auto:滚动到底自动加载 loadingText: '加载中...', noMoreText: '已经到底了' } });我建议把这段配置单独放到一个RcListConfigProvider.ts文件里,方便后续维护和版本对比。
4.2 页面中引入 RcList
配置注入完成后,普通页面的接入只需要几十行代码。以产品列表页为例:
@Entry @Component struct ProductListPage { private controller: RcListController = new RcListController(); @State private productList: Array<ProductModel> = []; @State private loadingState: RcLoadingState = RcLoadingState.Initial; @State private loadMoreState: RcLoadMoreState = RcLoadMoreState.Idle; private pageIndex: number = 1; build() { Column() { RcList({ dataSource: this.productList, controller: this.controller, loadingState: this.loadingState, loadMoreState: this.loadMoreState, onRefresh: () => this.refreshData(), onLoadMore: () => this.loadMoreData(), onItemClick: (item, index) => this.handleClick(item) }) } .width('100%') .height('100%') } // 数据加载逻辑 private refreshData() { this.pageIndex = 1; this.loadProducts(true); } private loadMoreData() { this.pageIndex++; this.loadProducts(false); } private loadProducts(isRefresh: boolean) { // 模拟接口请求 if (isRefresh) { this.loadingState = RcLoadingState.Loading; } this.loadMoreState = RcLoadMoreState.Loading; // 请求接口... } }注意这里没有在页面里写任何关于空态、错误态、loading 态的 UI 判断逻辑,这些全部由 RcList 根据状态自动渲染。页面只关心数据加载本身,这是这套架构的核心收益。
4.3 自定义单项渲染
单项渲染通过renderItem传入,需要注意 RcList 内部使用 LazyForEach 按需渲染,不符合懒加载条件的组件会被回收。因此单项组件必须定义aboutToReuse生命周期来重置状态,否则会出现复用组件时数据显示错乱的问题。
@Component struct ProductItem { @Prop item: ProductModel; aboutToReuse(params: Record<string, Object>) { // 重置内部状态,避免复用导致的数据残留 this.item = params.item as ProductModel; this.selected = false; } build() { Row() { // 商品信息展示 } } }这个aboutToReuse是鸿蒙组件复用的关键生命周期,我在接入前没太在意,结果在快速滚动列表时出现了"图片闪烁、文字残影"的诡异问题,排查了大半天才定位到是复用时状态没有重置。
5. 半年迭代中的问题与排查实录
5.1 配置并发修改导致渲染错乱
第一版全局配置系统上线后,遇到一个线上反馈:切换账户后,部分列表页的 loading 风格没有跟随新配置刷新。
排查发现,业务侧在切换账户时调用了RcListGlobalConfig.update(),此时旧的配置订阅实例还没有触发合并流程,但新的全局配置已经覆盖了旧的全局配置。部分实例持有的还是旧配置缓存。
解决方案是引入配置版本号机制。每次update()时自增版本号,实例在aboutToAppear时对比版本号,不一致则重新合并。
class RcListConfigCenter { private static version: number = 0; static update(config: RcListGlobalConfigModel) { this.globalConfig = config; this.version++; this.listeners.forEach((listener) => listener()); } static getVersion(): number { return this.version; } }实例侧缓存lastAppliedVersion,在aboutToAppear和订阅回调中都做版本对比,避免重复合并。
5.2 LazyForEach 数据源刷新丢失索引
另一个高频问题:删除列表中的某一项后,列表出现空白项或数据错位。
原因是 RcList 内部采用了LazyForEach的数据源类,默认的totalCount和getData方法是基于数组索引的。数组删除一个元素后,索引关系变化,但 LazyForEach 的缓存没有完全失效。
我的处理是在数据源类里维护一个稳定的唯一 key 映射表,所有操作基于 key 而非索引:
class RcListDataSource extends BasicDataSource { private dataArray: Array<any> = []; private keyMap: Map<string, number> = new Map(); getData(index: number): any { const key = Array.from(this.keyMap.keys())[index]; return this.dataArray[this.keyMap.get(key)]; } pushData(item: any) { const key = item.id; this.keyMap.set(key, this.dataArray.length); this.dataArray.push(item); this.notifyDataAdd(this.dataArray.length - 1); } removeData(key: string) { const index = this.keyMap.get(key); this.dataArray.splice(index, 1); this.rebuildKeyMap(); this.notifyDataDelete(index); } }这套映射机制增加了少量内存开销,但换来了删除、插入操作的时间复杂度稳定。实测在 10000 条数据的场景下,删除中间某条耗时不到 1ms。
5.3 骨架屏闪烁问题
首屏加载骨架屏组件时,出现了明显的闪烁问题。原因有两种叠加:
- 骨架屏组件本身在加载完成后没有平滑过渡,直接瞬时消失,产生闪烁感。
- 骨架屏数据源和真实列表数据源切换时,组件复用导致旧数据残留了一帧。
修复方案:骨架屏使用独立的加载动画组件并设置显隐转换动画;同时在切换数据源前先清空列表 DOM 树,再插入真实列表。
if (this.isShowSkeleton) { animateTo({ duration: 200 }, () => { this.isShowSkeleton = false; }); this.dataSource.clearData(); }这个 200ms 的渐变过渡在视觉上基本无感,但极大提升了首屏加载的质感。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 快速滚动列表出现数据残影 | 组件复用时未重置状态 | 定义aboutToReuse生命周期,重置内部状态 |
| 删除列表项后数据错位 | 数据源基于索引而非稳定 key | 改用 key 映射表维护数据源 |
| 全局配置更新后部分页面未生效 | 实例持有旧配置缓存 | 引入配置版本号对比机制 |
| 首屏骨架屏闪烁 | 加载态切换无过渡动画 | 增加渐隐动画,先清数据再插入列表 |
| 下拉刷新和上拉加载同时触发 | 状态机未加互斥 | 在刷新中禁止触发加载更多,反之亦然 |
| 内存持续增长 | 配置订阅未解绑 | 确保aboutToDisappear中调用解绑方法 |
5.5 状态机互斥与防抖
还有一个容易忽略的细节:下拉刷新和上拉加载不能同时触发。用户在下拉刷新过程中滑动到底部,理论上应该忽略加载更多请求。但如果状态管理不到位,两个请求会同时发出。
RcList 的状态机设计在onRefresh触发时,将loadMoreState强制置为Idle,拦截所有加载更多事件。同时在加载更多的回调里增加节流判断,防止连续滚动触发多次请求。
private handleLoadMore() { if (this.isRefreshing) return; if (this.loadMoreState === RcLoadMoreState.Loading) return; if (this.loadMoreState === RcLoadMoreState.NoMore) return; // 发起加载更多 }这套互斥逻辑的代码量不大,但能避免大量莫名其妙的数据重复问题。
6. 关于 Api 接口的演进思考
RcList 的 API 在半年迭代里经历过三次大的调整。
第一版全部用属性驱动,页面里布满了状态变量,调用方法时需要同时修改多个变量,容易漏改。
第二版引入控制器模式,把"修改属性"收敛为"调用方法",页面侧的复杂度明显下降。但控制器和属性混合使用时,偶尔会出现状态不同步的隐患。
第三版确定了现在的规则:页面侧通过控制器调用命令式接口,通过组件属性声明式绑定状态。两条路径严格分离,不交叉。属性只负责渲染状态的声明,控制器只负责事件触发。
这个规则带来了一个额外收益:单元测试可以直接 mock 控制器验证逻辑,不需要挂载真实组件。RcList 的刷新、加载更多、状态切换逻辑都可以写成独立的测试用例,回归成本大幅下降。
如果你打算在鸿蒙项目中引入类似的组件封装方案,我的建议是:设计 API 时优先考虑使用者视角,每个接口都要能回答"用这个接口的人需要关心什么"——如果答案是"不需要关心",那这个接口就不应该暴露出来。配置系统从三层做起,先保证全局可用再加覆盖能力,不要一上来就做极其灵活的配置模型,灵活往往意味着复杂度,复杂度是 bug 的温床。
RcList 后续还会继续迭代,目前正在考虑的方向是支持自定义动画插拔和分布式节点列表同步,但这些能力都是在全局配置系统稳定运行后的锦上添花。核心价值依然是:让业务侧写更少的代码,视觉侧改更少的配置,状态侧出更少的 bug。