HarmonyOS应用开发实战:猫猫大作战-@Reusable 声明与复用池、复用触发条件、Reusable vs ForEach 取舍、与 Lazy
前言
前面我们用ForEach渲染猫咪数组——每只猫一个CatItem子组件。但当列表项数多到几十甚至上百(战绩历史、背包道具、关卡列表),ForEach会全量渲染所有项,哪怕屏幕只显示 5 个——性能浪费。HarmonyOS 提供了@Reusable可复用组件装饰器——给子组件打上后,列表滚动时离屏的项不销毁,存入复用池,新进入的项从池里取复用,省去重建开销。
本篇以「猫猫大作战」未来扩展的战绩历史列表为锚点,把@Reusable 声明与复用池、复用触发条件、Reusable vs ForEach 取舍、与 LazyForEach 搭配四大要点讲透。
提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–46 篇。本篇是阶段二第十七篇。
一、场景拆解:战绩历史长列表
假设「猫猫大作战」要加战绩历史页——显示最近 100 局的得分/时间/最高连击:
@State history: GameRecord[] = []; // 100 条记录 List({ scroller: this.scroller }) { ForEach(this.history, (record: GameRecord) => { ListItem() { RecordItem({ record: record }) } }, (record: GameRecord) => record.id) } .cachedCount(5) // 预渲染 5 个痛点:ForEach全量渲染 100 个RecordItem——屏幕只显示 8 个,其余 92 个仍建组件树、占内存、耗 CPU。滚动时离屏项销毁、新项新建,卡顿。
@Reusable 的解法:
@Reusable @Component export struct RecordItem { @Prop record: GameRecord; build() { /* ... */ } } // List 检测到子组件是 @Reusable,自动启用复用池 List() { ForEach(this.history, (record: GameRecord) => { ListItem() { RecordItem({ record: record }) } }, (record: GameRecord) => record.id) } .cachedCount(5) `` **关键经验**:**@Reusable 让 ListItem 离屏不销毁、入复用池,新项从池取复用**——省重建开销,滚动流畅。 ## 二、@Reusable 声明与复用池 ### 2.1 给子组件打 @Reusable ```ts @Reusable @Component export struct RecordItem { @Prop record: GameRecord; build() { Row() { Text(this.record.score.toString()) .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#2ECC71') Text(this.record.formatTime) .fontSize(14).fontColor('#7F8C8D') Text(`x${this.record.maxCombo}`) .fontSize(14).fontColor('#E74C3C') } .width('100%').height(56) .padding({ left: 16, right: 16 }) .border({ width: { bottom: 1 }, color: '#ECF0F1' }) } }拆解:
| 片段 | 含义 |
|---|---|
@Reusable | 装饰器,标记「本组件可复用,离屏入池」 |
@Component | 普通组件装饰器(必须先有) |
@Prop record | 从父接收数据(复用时重新传值) |
关键约束:@Reusable 必须搭配 @Component——单独 @Reusable 无意义,它是组件的复用标记。
2.2 复用池机制
List 滚动: ├─ 离屏项(滚出可视区)→ 不销毁,存入复用池 └─ 新进入项(滚入可视区)→ 从池取复用,重新传 @Prop 值池的容量:由cachedCount控制——预渲染 N 个离屏项 + 可视区项,池容量约等于cachedCount + 可视区项数。
2.3 复用项的重新传值
@Reusable @Component export struct RecordItem { @Prop record: GameRecord; // 复用时重新传新 record build() { /* ... */ } } // 滚动时: // 1. 离屏的 RecordItem(record=历史第 3 局)入池 // 2. 新进入的 RecordItem 从池取,传 record=历史第 13 局 // 3. 子组件 build() 重渲染,显示第 13 局数据关键经验:复用是「壳复用、数据重传」——组件实例不销毁,但 @Prop 重新赋值,触发 build() 显示新数据。
三、复用触发条件
3.1 必须在 List/Grid 内
// ✅ List 内:触发复用 List() { ForEach(this.history, (record) => { ListItem() { RecordItem({ record: record }) } }, (record) => record.id) } .cachedCount(5) // ❌ 普通 Column 内:不触发复用(全量渲染) Column() { ForEach(this.history, (record) => { RecordItem({ record: record }) }, (record) => record.id) }机制:只有List/Grid这类滚动容器有复用池逻辑——Column/Row是静态布局,全量渲染。
3.2 必须配合 ForEach 密钥
List() { ForEach(this.history, (record: GameRecord) => { ListItem() { RecordItem({ record: record }) } }, (record: GameRecord) => record.id) // ← 密钥 }机制:ForEach 用密钥做 diff,配合 List 的复用池——密钥变触发 diff,List 决定复用还是新建。
3.3 cachedCount 预渲染
List() { /* ... */ }.cachedCount(5)含义:可视区外上下各预渲染 5 个——滚动时新项已就绪,不闪白。
| cachedCount | 预渲染 | 内存 | 滚动流畅度 |
|---|---|---|---|
| 0 | 0 个 | 极低 | ❌ 滚动闪白 |
| 5(推荐) | 上下各 5 | 中 | ✅ 流畅 |
| 10 | 上下各 10 | 高 | ✅ 极流畅 |
实战经验:cachedCount(5)是通用平衡——内存适中,滚动流畅。低端设备可降到 3。
四、@Reusable vs ForEach 取舍
4.1 ForEach 全量渲染
// 无 @Reusable:ForEach 全量渲染所有项 ForEach(this.history, (record) => { ListItem() { RecordItem({ record: record }) } }, (record) => record.id) // 100 条记录 → 100 个 RecordItem 实例,全占内存4.2 @Reusable 池化渲染
// 有 @Reusable:只渲染可视区 + cachedCount @Reusable @Component export struct RecordItem { /* ... */ } List() { ForEach(this.history, (record) => { ListItem() { RecordItem({ record: record }) } }, (record) => record.id) }.cachedCount(5) // 100 条记录 → 只渲染可视区 8 个 + cached 10 个 = 18 个实例4.3 对比表
| 维度 | ForEach(无 Reusable) | @Reusable |
|---|---|---|
| 实例数 | N(全量) | 可视区 + cachedCount |
| 内存 | 高(N 个实例) | 低(18 个) |
| 滚动 | 销毁新建卡顿 | 复用池流畅 |
| 适合 | N < 20(少量) | N ≥ 20(长列表) |
| 必须在 | 任意容器 | List/Grid |
关键经验:N < 20 用 ForEach,N ≥ 20 用 @Reusable + List——少量列表全量渲染更简单,长列表复用省内存。
4.4 策策树
列表项数 N? ├─ N < 20 → ForEach(任意容器,简单) └─ N ≥ 20 ├─ 静态不滚动 → ForEach(全量也 OK) └─ 滚动列表 → @Reusable + List + cachedCount五、实战:战绩历史列表用 @Reusable
5.1 定义 GameRecord 类型
假设新建entry/src/main/ets/components/GameTypes.ets补充:
export interface GameRecord { id: string; // 唇一密钥 score: number; // 得分 formatTime: string; // 用时(已格式化) maxCombo: number; // 最高连击 mergeCount: number; // 合并次数 highestLevel: CatLevel; // 最高等级 date: string; // 日期 }5.2 创建 RecordItem 子组件
新建entry/src/main/ets/components/RecordItem.ets:
import { GameRecord } from './GameTypes'; @Reusable // ← 本篇重点:标记可复用 @Component export struct RecordItem { @Prop record: GameRecord; build() { Row() { // 得分 Column() { Text('得分').fontSize(10).fontColor('#95A5A6') Text(this.record.score.toString()) .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#2ECC71') } .alignItems(HorizontalAlign.Start) .layoutWeight(2) // 用时 Column() { Text('用时').fontSize(10).fontColor('#95A5A6') Text(this.record.formatTime) .fontSize(16).fontWeight(FontWeight.Medium).fontColor('#2C3E50') } .alignItems(HorizontalAlign.Center) .layoutWeight(2) // 最高连击 Column() { Text('连击').fontSize(10).fontColor('#95A5A6') Text(`x${this.record.maxCombo}`) .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#E74C3C') } .alignItems(HorizontalAlign.End) .layoutWeight(2) } .width('100%').height(64) .padding({ left: 16, right: 16, top: 8, bottom: 8 }) .border({ width: { bottom: 1 }, color: '#ECF0F1' }) .onClick(() => { // 点击查看详情(第 71 篇会专讲导航) console.info('click record', this.record.id); }) } }5.3 创建战绩历史页
新建entry/src/main/ets/pages/HistoryPage.ets:
import { GameRecord } from '../components/GameTypes'; import { RecordItem } from '../components/RecordItem'; @Component export struct HistoryPage { @State history: GameRecord[] = []; private scroller: Scroller = new Scroller(); aboutToAppear() { // 加载历史记录(第 145 篇会专讲持久化) this.history = this.loadHistory(); } loadHistory(): GameRecord[] { // 简化:模拟 100 条记录 const records: GameRecord[] = []; for (let i = 0; i < 100; i++) { records.push({ id: `record_${i}`, score: 500 + Math.floor(Math.random() * 2000), formatTime: `${Math.floor(Math.random() * 10)}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`, maxCombo: Math.floor(Math.random() * 10) + 1, mergeCount: Math.floor(Math.random() * 50) + 10, highestLevel: CatLevel.SMALL, date: `2026-07-${(20 - Math.floor(i / 10)).toString().padStart(2, '0')}` }); } return records; } build() { Column() { // 标题栏 Row() { Text('战绩历史').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#2C3E50') } .width('100%').height(56) .padding({ left: 16, right: 16 }) .justifyContent(FlexAlign.Start).alignItems(VerticalAlign.Center) // 战绩列表(@Reusable + List,本篇重点) List({ scroller: this.scroller }) { ForEach(this.history, (record: GameRecord) => { ListItem() { RecordItem({ record: record }) // ← @Reusable 子组件 } }, (record: GameRecord) => record.id) } .width('100%') .layoutWeight(1) .cachedCount(5) // ← 预渲染 5 个 .divider({ strokeWidth: 1, color: '#ECF0F1', startMargin: 16, endMargin: 16 }) } .width('100%').height('100%') .backgroundColor('#FFFFFF') } }5.4 改造对比
| 维度 | ForEach 全量版 | @Reusable 版 |
|---|---|---|
| 100 条实例数 | 100 个 | 18 个(可视 8 + cached 10) |
| 内存 | ~100 × RecordItem | ~18 × RecordItem |
| 滚动流畅 | 卡顿(销毁新建) | 流畅(复用池) |
| 代码量 | 少(无装饰器) | 略多(@Reusable) |
| 适合 | N < 20 | N ≥ 20 |
六、@Reusable 与 LazyForEach 搭配
6.1 LazyForEach 按需渲染
@Reusable解决「复用组件实例」,LazyForEach解决「按需调用数据源」——两者搭配是长列表最优解:
import { LazyForEach, IDataSource } from '@kit.ArkUI'; // 数据源实现 IDataSource class HistoryDataSource implements IDataSource { private records: GameRecord[] = []; totalCount(): number { return this.records.length; } getData(index: number): GameRecord { return this.records[index]; } registerDataChangeListener(listener: DataChangeListener): void { /* ... */ } unregisterDataChangeListener(listener: DataChangeListener): void { /* ... */ } reloadData(records: GameRecord[]): void { this.records = records; // 通知监听器数据变 } } // 使用 private dataSource: HistoryDataSource = new HistoryDataSource(); List({ scroller: this.scroller }) { LazyForEach(this.dataSource, (record: GameRecord) => { ListItem() { RecordItem({ record: record }) // @Reusable 子组件 } }, (record: GameRecord) => record.id) } .cachedCount(5)机制:
- LazyForEach:只对可视区 + cachedCount 范围的索引调
getData,不全量遍历 100 条。 - @Reusable:可视区外的 ListItem 入复用池,新进入的复用壳。
关键经验:LazyForEach + @Reusable 是长列表最优解——前者省数据遍历,后者省组件实例。本系列第 48 篇会专讲 LazyForEach。
6.2 三种列表方案对比
| 方案 | 数据遍历 | 组件实例 | 适合 |
|---|---|---|---|
| ForEach | 全量 N 次 | 全量 N 个 | N < 20 |
| @Reusable + ForEach | 全量 N 次 | 可视区 + cached | N 20-100,数据简单 |
| LazyForEach + @Reusable | 按需 18 次 | 可视区 + cached | N ≥ 100,数据复杂 |
七、踩坑提示
7.1 @Reusable 用在非 List/Grid
// ❌ 错误:Column 内用 @Reusable,无复用池逻辑,退化为全量 @Reusable @Component export struct RecordItem { /* ... */ } Column() { ForEach(this.history, (record) => { RecordItem({ record: record }) }, (record) => record.id) } // 还是全量渲染 100 个 // ✅ 正确:List 内用 List() { ForEach(this.history, (record) => { ListItem() { RecordItem({ record: record }) } }, (record) => record.id) }.cachedCount(5)7.2 忘 cachedCount
// ❌ 错误:没 cachedCount,滚动时新项未预渲染,闪白 List() { /* @Reusable */ } // ✅ 正确:设 cachedCount List() { /* @Reusable */ }.cachedCount(5)7.3 @Reusable 子组件用 @Link
// ⚠️ 复用时 @Link 会重新绑定,可能引起父 state 错乱 @Reusable @Component export struct RecordItem { @Link record: GameRecord; // ❌ 不推荐 @Link build() { /* ... */ } } // ✅ 推荐:@Reusable 子组件用 @Prop(只读,复用时重传新值) @Reusable @Component export struct RecordItem { @Prop record: GameRecord; // 只读,复用安全 build() { /* ... */ } }关键经验:@Reusable 子组件用 @Prop 不用 @Link——复用时只读数据重传,避免双向绑定错乱。
7.4 ForEach 密钥不唯一
// ❌ 错误:密钥不唯一,复用池错乱 ForEach(this.history, (record) => { ListItem() { RecordItem({ record: record }) } }, (record) => record.score.toString()) // 多条同得分密钥冲突 // ✅ 正确:密钥唯一 ForEach(this.history, (record) => { ListItem() { RecordItem({ record: record }) } }, (record) => record.id) // id 唯一八、调试技巧
- DevEco Profiler 看组件实例数:滚动时实例数应稳定在可视区+cached,不应随 N 增长。
console.info在子组件 aboutToReuse:追复用时机(如果有生命周期)。- 滚动卡顿排查:检查是否在 List 内;检查 cachedCount 是否设;检查是否用了 LazyForEach。
- 内存对比:DevEco Memory Profiler 看 RecordItem 实例数,@Reusable 应远少于 N。
九、性能与最佳实践
- N ≥ 20 的滚动列表用 @Reusable——省组件实例,滚动流畅。
- @Reusable 必须在 List/Grid 内——普通容器无复用池逻辑。
- cachedCount(5) 平衡内存与流畅——低端设备可降到 3。
- @Reusable 子组件用 @Prop 不用 @Link——复用时只读数据重传,避免双向错乱。
- 密钥必须唯一——ForEach diff 和复用池都靠密钥,冲突错乱。
- N ≥ 100 用 LazyForEach + @Reusable——前者省数据遍历,后者省组件实例。
总结
本篇我们从 @Reusable 列表复用切入,掌握了**@Reusable 声明与复用池机制**、复用触发条件(List/Grid + ForEach 密钥 + cachedCount)、@Reusable vs ForEach 取舍(N < 20 ForEach,N ≥ 20 Reusable)、与 LazyForEach 搭配四大要点,并给出了战绩历史列表用 @Reusable 的完整代码。核心要点:@Reusable 在 List 内离屏入池复用;cachedCount(5) 平衡;子用 @Prop 不用 @Link;N ≥ 100 搭 LazyForEach。
下一篇我们将专讲 LazyForEach——大列表按需渲染。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- 「猫猫大作战」项目源码:本仓库
entry/src/main/ets/components/、entry/src/main/ets/pages/ - ArkUI @Reusable 可复用组件官方指南
- List 列表组件官方指南
- ForEach 循环渲染官方指南
- ArkUI 长列表性能最佳实践
- 开源鸿蒙跨平台社区
- HarmonyOS 开发者官方文档首页
- 系列索引:本仓库
articles/INDEX.md
