@Reusable 组件复用实践:从原理到踩坑,一篇讲透
基于 HarmonyOS NEXT (API 12+),结合实战 Demo 深度解析 @Reusable 装饰器的组件回收与复用机制
写在前面
做鸿蒙开发的同学,大概都经历过这种场景:一个长列表,数据量一上去,快速滑动就开始掉帧、卡顿,甚至白屏闪烁。打开 DevEco 的 Profiler 一看,好家伙,组件创建和销毁的调用堆叠成山。问题的根源在哪?每次列表项滑出屏幕,组件实例就被彻底销毁;滑回来,又从零开始创建一个新的。这个"销毁-重建"的开销,在简单组件上感知不明显,但一旦组件内部有复杂布局、图片加载、甚至视频播放器,每一帧的创建成本就非常可观了。
HarmonyOS 给出的解法就是 @Reusable------组件复用机制。这篇文章我会从底层原理讲到实战踩坑,把 @Reusable 的机制、生命周期、缓存策略、使用限制掰开了揉碎了讲清楚。所有代码示例均来自我的实战 Demo 项目,不是伪代码,不是阉割版,是真正能跑起来的东西。

一、@Reusable 到底干了什么
1.1 没有复用的世界
先看普通组件在 LazyForEach 列表中的生命周期:
滑入屏幕 → aboutToAppear() → 创建组件实例 → 创建组件树节点 → 绑定数据 → 渲染
滑出屏幕 → aboutToDisappear() → 销毁组件实例 → 销毁组件树节点 → 释放内存
再次滑入 → aboutToAppear() → 重新创建组件实例 → 重新创建组件树节点 → 重新绑定数据 → 重新渲染
注意看,"再次滑入"这一步,所有工作都是从头来的。JS 对象要重新 new,组件树要重新构建,布局要重新计算,数据要重新绑定。如果组件里有 20 层嵌套,这个重建过程的耗时就是指数级的。
1.2 加上 @Reusable 之后
@Reusable 的核心思想特别简单:组件从组件树上摘下来的时候,不销毁,而是缓存起来;下次需要同类型组件的时候,从缓存池里取出来复用,只更新数据。
滑入屏幕 → aboutToAppear() → 创建组件实例 → 渲染(首次,和普通组件一样)
滑出屏幕 → aboutToRecycle() → 组件实例 + 组件树节点 存入缓存池(不销毁!)
再次滑入 → 从缓存池取出 → aboutToReuse(params) → 更新数据 → 渲染
关键区别在第二步和第三步:
- 滑出时不销毁,组件实例(包括 JSView 对象和视图节点)被完整保留
- 滑入时不创建 ,直接从缓存池拿一个现成的实例,只需调用
aboutToReuse更新数据
这意味着什么?意味着组件树不用重建了,布局不用重新计算了,JS 对象不用重新创建了。省下来的就是实打实的帧时间。
1.3 缓存池存的是什么
准确地说,被缓存的是三样东西:
- 组件实例(Component Instance):ArkTS 层面的 struct 实例,包含状态变量、私有变量
- 视图节点(CustomNode/JSView):框架层面的组件树节点,包含布局信息
- 状态上下文 (State Context):
@State、@Prop等状态变量的上下文环境
这三样东西一起存入父组件的缓存池,复用时一起取出。所以复用的不是"空壳",而是一个完整保留了结构和状态上下文的组件实例。
二、aboutToRecycle / aboutToReuse 生命周期详解
@Reusable 新增了两个生命周期回调,这是复用机制的核心入口。
2.1 aboutToRecycle:回收前调用
typescript
aboutToRecycle(): void {
hilog.info(0x0000, 'Reusable', 'aboutToRecycle: %s', this.itemTitle);
}
触发时机:组件从组件树上移除,即将进入缓存池之前。
这个回调的典型用途:
- 释放资源:如果组件持有定时器、动画控制器、媒体播放器等,在这里暂停或释放
- 清理状态:重置一些不需要保留到下次复用的临时状态
- 打日志:追踪回收行为,用于性能分析
注意,aboutToRecycle 之后组件并没有被销毁,只是从组件树上"摘"下来了。所以不要在这里做"最终清理"的操作,组件实例还在内存里。
2.2 aboutToReuse:复用前调用
typescript
aboutToReuse(params: Record<string, string>): void {
this.itemTitle = params.itemTitle;
this.itemDesc = params.itemDesc;
this.bgColor = params.bgColor;
this.recycleCount++;
hilog.info(0x0000, 'Reusable', 'aboutToReuse: %s, reuseCount=%d', this.itemTitle, this.recycleCount);
}
触发时机:组件从缓存池取出,即将重新挂载到组件树之前。
参数 params 就是创建组件时传入的属性值,和 build() 里传参的方式一致。框架会自动把属性参数包装成 Record<string, Object> 传给 aboutToReuse。
这个回调的核心用途:
- 更新数据:把新的列表项数据绑定到组件的状态变量上
- 重置状态:如果上次使用时组件内部有临时状态(比如选中态、展开态),要在这里重置
- 重新初始化:如果组件持有需要随数据变化的资源(比如图片 URL),在这里重新设置
2.3 完整生命周期对比
我 Demo 里做了一个对比表格,一目了然:
| 阶段 | 普通组件 | @Reusable 组件 |
|---|---|---|
| 首次创建 | aboutToAppear() |
aboutToAppear() |
| 移出屏幕 | aboutToDisappear() → 销毁 |
aboutToRecycle() → 缓存 |
| 再次进入 | 重新创建 + aboutToAppear() |
从缓存取出 + aboutToReuse(params) |
| 性能差异 | 每次重建 JS 对象 + 组件树 | 复用 JS 对象 + 组件树 |
第一次创建时,@Reusable 组件和普通组件没有区别,都走 aboutToAppear。差异从第二次开始------普通组件走"销毁-重建"的完整流程,而 @Reusable 组件走"回收-复用"的轻量流程。
bash
[MppCapture] 采集线程已启动 (Level3 零GLES)
[MppCapture] === 采集主循环开始 (Level3 零GLES 模式) ===
[MppCapture] ===== 首帧诊断 (Level3) =====
[MppCapture] DmaBufVideoFrameBuffer: 1088x2344 (对齐后), fd=...
[VideoEncoder] ===== DMABUF 诊断 (Level1 零拷贝) =====
[VideoEncoder] ✅ mpp_buffer_import 成功: enc_buf=...
[VideoEncoder] 📤 发送编码包#1: size=... bytes, keyframe=1
2.4 递归调用子组件的生命周期
这是一个容易被忽略的点:@Reusable 装饰的自定义组件下有子组件时,回收和复用操作会递归调用所有子组件 的 aboutToRecycle 和 aboutToReuse,无论子组件是否被 @Reusable 标记。
typescript
@Reusable
@Component
struct ParentCard {
build() {
Column() {
ChildA() // 没有 @Reusable,但 aboutToRecycle/aboutToReuse 也会被调用
ChildB() // 同上
}
}
}
框架会从 ParentCard 开始,深度遍历整棵子树,对每个子组件实例调用对应的生命周期回调。这确保了整棵组件树的状态都能在复用时正确更新。
三、LRU 缓存池机制
3.1 默认容量为 1
这是很多人会踩的坑。@Reusable 的缓存池默认容量只有 1。也就是说,对于同一种类型的 @Reusable 组件,父组件的缓存池最多只保留 1 个实例。
考虑一个场景:列表在屏幕上同时显示了 8 个列表项,用户快速向下滑动,8 个项同时滑出。缓存池只会保留最后被回收的那 1 个,其余 7 个直接销毁。等新数据滑入需要 8 个组件时,1 个从缓存池取,7 个从头创建。
这就解释了一个现象:明明加了 @Reusable,但快速滑动时还是能看到大量的 aboutToAppear 日志------因为缓存池容量太小,大部分组件还是走的创建路径。
3.2 LRU 淘汰策略
缓存池采用 LRU(Least Recently Used,最近最少使用)策略。当缓存池满了,又有新的组件需要回收时,最早进入缓存池的那个实例会被淘汰销毁,腾出位置给新回收的实例。
缓存池状态变化示例(容量=2):
1. Item-A 滑出 → 缓存池: [Item-A]
2. Item-B 滑出 → 缓存池: [Item-A, Item-B]
3. Item-C 滑出 → LRU淘汰 Item-A → 缓存池: [Item-B, Item-C]
4. 需要新组件 → 取出 Item-B(最近最少用的)→ 缓存池: [Item-C]
理解了 LRU 策略,你就知道为什么默认容量为 1 的情况下复用率不高------只有 1 个坑位,两个组件交替滑入滑出时还行,一旦有 3 个以上组件竞争同一个缓存池,必然有组件被淘汰后需要重新创建。
3.3 缓存池绑定在父组件上
这是一个关键的设计:缓存池是绑定在父自定义组件上的,不是全局的,也不是绑定在列表容器上的。
typescript
@Entry
@Component
struct PageA {
build() {
Column() {
// 这里创建的 ReusableItem 实例,回收后存入 PageA 的缓存池
ReusableItem({ ... })
}
}
}
@Entry
@Component
struct PageB {
build() {
Column() {
// 这里创建的 ReusableItem 实例,回收后存入 PageB 的缓存池
// 和 PageA 的缓存池互不相通!
ReusableItem({ ... })
}
}
}
不同父组件之间的缓存池是隔离的。PageA 缓存的组件实例不会被 PageB 复用。如果你有跨页面/跨组件复用的需求,API 26 引入了全局复用池(reusePool)机制,但这超出了本文的讨论范围。
3.4 无子组件不触发回收
还有一个细节:当 @Reusable 装饰的自定义组件没有子组件 时,不会触发回收和复用。也就是说,如果一个 @Reusable 组件的 build() 方法返回的是原生组件(Text、Image 等),没有任何自定义子组件,框架可能会跳过复用逻辑。这个限制在设计组件结构时需要注意------确保你的 @Reusable 组件至少包含一个自定义子组件,或者本身结构足够复杂使得复用有意义。
四、reuseId:给不同布局分组
4.1 为什么需要 reuseId
@Reusable 的复用有一个前提:组件复用前后结构必须保持一致。框架复用组件实例时,不会重新 build 组件树,只会更新数据。如果同一个 @Reusable 组件在不同场景下 build 出不同的结构,复用就会出问题。
典型场景:一个列表里有多种卡片类型------文字卡片、图文卡片、视频卡片。它们都叫 CardItem,但布局完全不同:
typescript
@Reusable
@Component
struct CardItem {
@State type: string = 'text'
build() {
if (this.type === 'text') {
// 文字卡片布局:纯文本
Text(this.content).fontSize(14)
} else if (this.type === 'image') {
// 图文卡片布局:图片 + 文字
Row() {
Image(this.imageUrl).width(60)
Text(this.content).fontSize(14)
}
}
}
}
如果一个文字卡片被回收,然后被复用为图文卡片,框架不会重新执行 build,组件树还是 Text 节点,但 aboutToReuse 试图更新 imageUrl,这时候就炸了------树上根本没有 Image 节点。
4.2 reuseId 的用法
reuseId 就是解决这个问题的。它给不同布局类型的复用组件打上标签,让框架在缓存池里按标签分组:
typescript
LazyForEach(this.dataSource, (item: DataItem) => {
ListItem() {
CardItem({ type: item.type, ... })
.reuseId(item.type) // 关键!按类型分组缓存
}
}, (item: DataItem) => item.id)
reuseId('text')的组件只和reuseId('text')的实例复用reuseId('image')的组件只和reuseId('image')的实例复用- 不同 reuseId 的实例之间不会交叉复用
本质上,reuseId 相当于在缓存池里又细分了子池。每个 reuseId 值对应一个独立的子池,各有自己的 LRU 策略和容量限制。
4.3 reuseId 的最佳实践
- 布局不同就加 reuseId:只要组件的 build 函数可能产出不同结构,就必须用 reuseId 区分
- reuseId 值要稳定:不要用随机值或索引,用能代表布局类型的固定字符串
- 不用 reuseId 就别加:如果组件结构始终一致,不加 reuseId 反而更好,因为所有实例共享同一个缓存池,复用率更高
五、为什么 ForEach 不触发复用
这是一个高频面试题,也是很多人实际开发中容易犯的错:加了 @Reusable,用了 ForEach,结果复用根本没生效。
5.1 根本原因
ForEach 和 LazyForEach 的渲染策略完全不同:
- ForEach:一次性为数据源的所有元素创建组件,全部挂载到组件树上。不管元素在不在可见区域,组件都存在。因为所有组件始终在树上,不存在"滑出-滑入"的场景,自然就没有回收和复用的触发条件。
- LazyForEach:按需创建,只为可见区域的数据项创建组件。滑出屏幕的组件会被从树上移除,滑入时重新创建。这个"移除-创建"的过程正是 @Reusable 的用武之地。
简单说:没有"移除",就没有"回收";没有"回收",就没有"复用"。 ForEach 全展开的模式从根本上杜绝了复用的可能性。
5.2 实际影响
看数据:根据官方的测试,10000 条数据的场景下:
| 指标 | ForEach | LazyForEach + @Reusable |
|---|---|---|
| 内存占用 | 全量驻留 | 降低约 86% |
| 首屏加载 | 创建 10000 个组件 | 仅创建可见区域组件 |
| 滑动丢帧率 | 58.2% | 接近 0% |
如果你的列表数据超过 100 条,毫不犹豫地选择 LazyForEach。ForEach 只适合数据量小(50 条以内)且不需要复用的静态场景。
5.3 Demo 中的实现
在我的 Demo 里,数据源是 500 条数据,用的是 LazyForEach + IDataSource 标准实现:
typescript
class RecycleDataSource implements IDataSource {
private dataList: string[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number { return this.dataList.length; }
getData(index: number): string { return this.dataList[index]; }
pushDataList(items: string[]): void {
items.forEach((item: string) => { this.dataList.push(item); });
this.listeners.forEach((listener: DataChangeListener) => { listener.onDataReloaded(); });
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); }
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos: number = this.listeners.indexOf(listener);
if (pos >= 0) { this.listeners.splice(pos, 1); }
}
}
List 容器配置了 cachedCount(3),预加载可见区域外 3 个组件,配合 LazyForEach 的按需创建,确保滑动流畅:
typescript
List({ space: 6 }) {
LazyForEach(this.dataSource, (item: string, index: number) => {
ListItem() {
ReusableItemCard({
itemTitle: item,
itemDesc: '索引: ' + index.toString() + ' | 复用组件实例',
bgColor: this.colors[index % this.colors.length]
})
}
}, (item: string, index: number) => item)
}
.cachedCount(3)
5.4 IDataSource 的 keyGenerator
注意 LazyForEach 的第三个参数------keyGenerator:
typescript
(item: string, index: number) => item
这个函数为每个数据项生成唯一的 key。框架根据 key 判断数据项是否发生变化,key 不变则不刷新组件。用数组下标 index 作为 key 是常见的坑------数据插入或删除后下标偏移,框架误判导致渲染错乱。正确做法是用数据本身的唯一标识,比如 item 的 ID。

六、嵌套 @Reusable 的限制
6.1 不支持嵌套使用
这是一个硬性限制:@Reusable 不支持嵌套。你不能在一个 @Reusable 组件内部再放一个 @Reusable 组件并期望两层都走复用逻辑。
typescript
// ❌ 反模式:嵌套 @Reusable
@Reusable
@Component
struct OuterCard {
build() {
Column() {
InnerTag() // InnerTag 也是 @Reusable 的
}
}
}
@Reusable
@Component
struct InnerTag {
build() {
Text('tag')
}
}
这种写法语法没问题,但运行时会有问题。当 OuterCard 被回收时,InnerTag 也会被递归调用 aboutToRecycle,但 InnerTag 的缓存池管理会和 OuterCard 的产生冲突,导致复用行为不可预测。嵌套会导致双缓存池,内层组件的回收/复用和外层组件的回收/复用互相干扰,框架无法正确管理。
6.2 正确做法
如果你的组件确实需要嵌套,只在最外层加 @Reusable,内层组件用普通的 @Component:
typescript
@Reusable
@Component
struct OuterCard {
@State title: string = ''
@State tag: string = ''
aboutToReuse(params: Record<string, Object>): void {
this.title = params.title as string;
this.tag = params.tag as string;
}
build() {
Column() {
InnerTag({ tag: this.tag }) // 普通 @Component,不用 @Reusable
Text(this.title)
}
}
}
@Component
struct InnerTag {
@Prop tag: string = ''
build() {
Text(this.tag).fontSize(12).fontColor('#999999')
}
}
这样,OuterCard 走复用逻辑,InnerTag 作为 OuterCard 组件树的一部分,在复用时被递归调用 aboutToReuse 来更新数据。整棵树只有一层复用,逻辑清晰,行为可控。
七、aboutToReuse 中修改父组件状态的坑
7.1 问题描述
这是 @Reusable 最隐蔽的坑之一:在子组件的 aboutToReuse 回调中,修改父组件的状态变量,修改不会生效。
typescript
@Reusable
@Component
struct ParentCard {
@State num: number = 0;
build() {
Column() {
Text('num: ' + this.num.toString())
ChildComp({ num: this.num })
}
}
}
@Component
struct ChildComp {
@Link num: number;
aboutToReuse(params: ESObject): void {
this.num = -1 * params.num; // ❌ 修改父组件的 @State,不会生效!
}
build() {
Text('child num: ' + this.num.toString())
}
}
为什么?因为框架在组件复用过程中,递归调用子组件的 aboutToReuse 时,这个调用发生在框架的复用作用域内。在这个作用域内对父组件状态变量的修改,会被框架静默丢弃。
7.2 setTimeout 解决方案
官方给出的正确做法是用 setTimeout 把修改操作抛到复用作用域之外:
typescript
@Component
struct ChildComp {
@Link num: number;
aboutToReuse(params: ESObject): void {
setTimeout(() => {
this.num = -1 * params.num; // ✅ 延迟执行,移出复用作用域,修改生效
}, 1)
}
build() {
Text('child num: ' + this.num.toString())
}
}
setTimeout 的回调会在当前事件循环结束后执行,此时复用流程已经完成,修改操作在正常的更新作用域内,所以能生效。
7.3 更深层的理解
这个坑的本质是:框架在复用流程中需要保持组件树状态的确定性。aboutToReuse 的递归调用是一个批量操作,如果允许中间某个子组件随意修改父组件状态,会导致后续子组件的复用行为变得不可预测。所以框架选择了一个保守策略:复用作用域内的状态修改一律不生效。
理解了这个设计意图,你就知道 setTimeout 不是什么 hack,而是一个合理的异步边界声明------告诉框架"这个修改和复用流程无关,请在复用完成后再执行"。
7.4 onDataReloaded 也有同样的问题
一个相关的坑:如果在 aboutToReuse 中调用 IDataSource 的 onDataReloaded() 通知数据重新加载,同样会出问题。框架会判定当前子节点状态不稳定,中断 aboutToReuse 的递归过程。解决方案一样------用 setTimeout 延迟执行 onDataReloaded()。
八、完整代码走读
下面逐段拆解我的 Demo 项目代码,看看一个完整的 @Reusable 实战 Demo 是怎么写的。完整源码在 entry/src/main/ets/pages/ReusableDemo.ets。
8.1 数据源实现
typescript
import { router } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
class RecycleDataSource implements IDataSource {
private dataList: string[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number { return this.dataList.length; }
getData(index: number): string { return this.dataList[index]; }
pushDataList(items: string[]): void {
items.forEach((item: string) => { this.dataList.push(item); });
this.listeners.forEach((listener: DataChangeListener) => { listener.onDataReloaded(); });
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); }
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos: number = this.listeners.indexOf(listener);
if (pos >= 0) { this.listeners.splice(pos, 1); }
}
}
这是标准的 IDataSource 实现,四个方法缺一不可:
totalCount()和getData(index)提供数据访问能力registerDataChangeListener()和unregisterDataChangeListener()管理数据变更监听器pushDataList()是自定义的批量添加方法,添加完数据后调用onDataReloaded()通知框架刷新
这个实现比较精简,生产环境建议增加 notifyDataAdd(index)、notifyDataChange(index) 等增量更新方法,避免每次都全量刷新。
8.2 @Reusable 组件实现
这是整个 Demo 的核心------ReusableItemCard:
typescript
@Reusable
@Component
struct ReusableItemCard {
@State itemTitle: string = '';
@State itemDesc: string = '';
@State bgColor: string = '#ffffff';
private recycleCount: number = 0;
aboutToReuse(params: Record<string, string>): void {
this.itemTitle = params.itemTitle;
this.itemDesc = params.itemDesc;
this.bgColor = params.bgColor;
this.recycleCount++;
hilog.info(0x0000, 'Reusable', 'aboutToReuse: %s, reuseCount=%d', this.itemTitle, this.recycleCount);
}
aboutToRecycle(): void {
hilog.info(0x0000, 'Reusable', 'aboutToRecycle: %s', this.itemTitle);
}
build() {
Row() {
Column() {
Text(this.itemTitle.charAt(0))
.fontSize(18)
.fontColor('#ffffff')
.fontWeight(FontWeight.Bold)
}
.width(40)
.height(40)
.borderRadius(20)
.backgroundColor(this.bgColor)
.justifyContent(FlexAlign.Center)
.margin({ right: 12 })
Column() {
Text(this.itemTitle)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.itemDesc)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(8)
.backgroundColor('#ffffff')
}
}
逐行解读几个关键点:
-
@Reusable和@Component一起使用 :@Reusable必须配合@Component,不能单独用,也不能和@Builder或@ComponentV2(V2 用@ReusableV2)搭配。 -
三个 @State 变量 :
itemTitle、itemDesc、bgColor都是可变数据,在aboutToReuse中被更新。组件首次创建时走默认值,复用时走aboutToReuse的赋值。 -
private recycleCount:这是一个非状态变量,用来记录组件被复用了多少次。注意它是private而不是@State,因为复用次数不需要驱动 UI 更新,只是用来打日志做分析。每次aboutToReuse时自增,通过 hilog 输出,你就能在 Log 面板看到每个组件实例的复用次数。 -
aboutToReuse的参数类型 :Record<string, string>是 Demo 中的简化写法。官方签名是Record<string, Object>,因为参数值可以是任意类型。如果你的数据包含 number、boolean 等类型,参数类型要相应调整,取值时做类型转换。 -
build 结构:左侧圆形头像区(显示首字母)+ 右侧标题描述区,典型的列表项双栏布局。注意 build 结构在复用前后必须一致,这里没有任何条件分支,所以不需要 reuseId。
8.3 页面入口实现
typescript
@Entry
@Component
struct ReusableDemo {
@State activeTab: number = 0;
@State totalRecycle: number = 0;
@State totalCreate: number = 0;
@State logMessages: string[] = [];
private dataSource: RecycleDataSource = new RecycleDataSource();
private colors: string[] = [
'#1a73e8', '#e65100', '#34a853', '#9c27b0',
'#d32f2f', '#00897b', '#558b2f', '#0277bd'
];
aboutToAppear(): void {
const items: string[] = [];
for (let i = 0; i < 500; i++) {
items.push('Item-' + (i + 1).toString().padStart(3, '0'));
}
this.dataSource.pushDataList(items);
}
关键点:
- 500 条测试数据:数据量足够大,能明显观察到复用效果。太少的话组件还没滑出就滑回来了,看不出区别。
- 8 色循环 :
colors数组 8 种颜色,通过index % colors.length循环赋值,让列表项视觉上有区分度。 - 数据初始化放在
aboutToAppear:在组件即将出现时填充数据源,确保 LazyForEach 首次渲染有数据可用。
8.4 LazyForEach 列表配置
typescript
List({ space: 6 }) {
LazyForEach(this.dataSource, (item: string, index: number) => {
ListItem() {
ReusableItemCard({
itemTitle: item,
itemDesc: '索引: ' + index.toString() + ' | 复用组件实例',
bgColor: this.colors[index % this.colors.length]
})
}
}, (item: string, index: number) => item)
}
.width('92%')
.cachedCount(3)
.layoutWeight(1)
逐个参数解析:
LazyForEach第一个参数 :this.dataSource,实现了 IDataSource 接口的数据源对象- 第二个参数 :迭代回调,为每个数据项创建
ReusableItemCard组件,传入标题、描述、背景色 - 第三个参数 :keyGenerator,用
item(数据本身的值)作为唯一 key,而不是 index .cachedCount(3):预加载可见区域外 3 个组件。默认值是 1,对于快速滑动场景偏小,3 是个比较平衡的选择.layoutWeight(1):列表占满剩余空间
8.5 机制详解 Tab
Demo 的第二个 Tab 是一个纯展示页面,包含生命周期对比表和使用规则说明:
typescript
Column() {
Text('1. @Reusable必须与@Component一起使用')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('2. 复用仅发生在同一父组件下')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('3. 缓存池默认容量为1,采用LRU策略')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('4. reuseId可区分不同布局类型的复用组')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('5. ForEach全展开不触发复用,必须用LazyForEach')
.fontSize(12).fontColor('#d32f2f').fontWeight(FontWeight.Medium).lineHeight(22).width('100%')
Text('6. aboutToReuse中修改父组件状态无效,需用setTimeout')
.fontSize(12).fontColor('#d32f2f').fontWeight(FontWeight.Medium).lineHeight(22).width('100%')
Text('7. @Reusable不支持嵌套使用')
.fontSize(12).fontColor('#d32f2f').fontWeight(FontWeight.Medium).lineHeight(22).width('100%')
}
红色标注的 5、6、7 条是踩坑高频项,也是面试最爱考的三个点。
九、避坑清单
把前面散落在各章节的坑点汇总一下,方便快速查阅:
| 坑 | 表现 | 解法 |
|---|---|---|
| 用了 ForEach 而非 LazyForEach | @Reusable 完全不生效 | 改用 LazyForEach |
| 缓存池默认容量为 1 | 快速滑动时复用率低 | 这是框架限制,只能接受 |
| 布局不同但没加 reuseId | 复用后渲染错乱 | 用 reuseId 按布局类型分组 |
| 嵌套 @Reusable | 复用行为不可预测 | 只在最外层加 @Reusable |
| aboutToReuse 中修改父组件状态 | 修改静默失效 | 用 setTimeout 延迟执行 |
| aboutToReuse 中调用 onDataReloaded | 递归被中断 | 用 setTimeout 延迟执行 |
| @Reusable 组件无子组件 | 不触发回收和复用 | 确保组件有子组件或结构足够复杂 |
| keyGenerator 用 index | 数据增删后渲染错乱 | 用数据本身的唯一标识 |
十、总结
@Reusable 的组件复用机制是 HarmonyOS 长列表性能优化的核心手段之一。它的原理并不复杂------把滑出的组件缓存起来,滑入时复用而非重建。但要把它用好,需要理解以下几个关键点:
- 复用的触发条件:只有 LazyForEach(和 Repeat)能触发组件复用,ForEach 不行
- 两个生命周期 :
aboutToRecycle做资源释放,aboutToReuse做数据更新 - 缓存池的 LRU 策略:默认容量 1,理解了这个就知道为什么快速滑动时复用率不高
- reuseId 的分组机制:布局不同必须加 reuseId,否则复用会出问题
- 嵌套限制:只在最外层加 @Reusable,内层用普通 @Component
- 状态修改限制:aboutToReuse 中不能直接修改父组件状态,需要 setTimeout
这些坑点不是框架的缺陷,而是设计上的合理取舍。理解了框架为什么要这样设计,用起来就不会觉得别扭。
最后,如果你使用的是 @ComponentV2,记得用 @ReusableV2 而不是 @Reusable,两者不能混用。@ReusableV2 的机制类似但有差异,那是另一篇文章的事了。