HarmonyOS应用<奇妙科学乐园>开发第77篇:组件复用与@Reusable装饰器——减少冗余渲染

📖 引言
在上一篇文章中,我们通过 DebounceManager 和 ThrottleManager 解决了搜索输入、列表滚动、窗口尺寸变化三大高频事件的性能优化问题。然而,高频事件只是性能优化的一个维度。在《奇妙科学乐园》中,科普知识列表页、科学实验列表、测验选项列表等场景都存在大量结构相似的列表项组件。当用户快速滑动列表时,LazyForEach 会不断创建和销毁列表项组件实例,频繁的组件创建/销毁开销在大数据量下会成为新的性能瓶颈。
HarmonyOS ArkUI 框架从 API 11 开始提供了 @Reusable 装饰器,它允许开发者标记一个自定义组件为"可复用"。框架会维护一个组件复用池,当列表滚动导致某些组件移出可视区域时,这些组件不会被销毁,而是被回收放入复用池;当新的列表项进入可视区域时,框架优先从复用池中取出组件并更新其数据,而非重新创建。这种"对象池"模式与 Android RecyclerView 的 ViewHolder 复用机制异曲同工。
本文将深入讲解 @Reusable 装饰器的原理与使用方法,从基础的组件复用配置到 aboutToReuse 生命周期回调的数据更新,再到在《奇妙科学乐园》 TopicCard、实验列表项、测验选项等核心组件中的实战应用,帮助读者掌握一套完整的组件复用优化方案。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解组件复用池的工作原理与适用场景
- ✅ 掌握 @Reusable 装饰器的使用方法与配置
- ✅ 熟练使用 aboutToReuse 生命周期回调更新复用组件数据
- ✅ 在 TopicCard 科普文章卡片中实现组件复用
- ✅ 在实验列表项中实现组件复用
- ✅ 在测验选项组件中实现组件复用
- ✅ 理解复用与 @Prop / @Link 状态装饰器的配合关系
- ✅ 掌握复用组件的常见陷阱与调试方法
💡 需求分析
项目中的列表渲染场景
| 页面 | 列表组件 | 列表项组件 | 数据量级 | 优化优先级 |
|---|---|---|---|---|
| Topics 科普列表 | LazyForEach + List | TopicCard | 20-50条 | 高 |
| Lab 实验列表 | ForEach + List | 实验卡片 | 10-20条 | 中 |
| Quiz 测验选项 | ForEach + List | QuizOptionItem | 4-10条 | 低 |
| Favorites 收藏列表 | ForEach + List | TopicCard | 0-100条 | 中 |
| History 历史记录 | ForEach + List | 历史卡片 | 0-100条 | 中 |
组件生命周期对比(无复用 vs 有复用)
无复用模式(默认):
用户滚动列表向下
┌────────────────────────────────────────────────┐
│ 可视区域 │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Card 1│ │Card 2│ │Card 3│ │Card 4│ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
└────────────────────────────────────────────────┘
滚动后 Card 1 移出可视区域:
Card 1: aboutToDisappear → 销毁(内存回收)
Card 5: new 创建 → aboutToAppear → build(新渲染)
开销:销毁 + 创建 = 2次完整生命周期操作
有复用模式(@Reusable):
用户滚动列表向下
┌────────────────────────────────────────────────┐
│ 可视区域 │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Card 1│ │Card 2│ │Card 3│ │Card 4│ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
└────────────────────────────────────────────────┘
│
▼ 回收到复用池
┌──────┐
│Card 1│ → 不销毁,保持组件树结构
└──────┘
Card 5 进入可视区域:
从复用池取出 Card 1 → aboutToReuse → 更新数据 → 重新渲染
开销:数据更新 + 重新渲染 = 1次轻量操作
@Reusable 组件复用架构
LazyForEach 数据源
│
▼
┌─────────────┐ 创建 ┌─────────────┐
│ 组件复用池 │ ──────────→ │ 组件实例A │ → 渲染在可视区域
│ (ReusePool) │ ←────────── │ (TopicCard) │
└─────────────┘ 回收 └─────────────┘
│ │
│ 取出复用 │ aboutToReuse()
▼ ▼
┌─────────────┐ 更新数据 ┌─────────────┐
│ 组件实例A │ ←────────── │ 新数据绑定 │
│ (已回收) │ │ (Card 5) │
└─────────────┘ └─────────────┘
🛠️ 核心实现
步骤1: 理解 @Reusable 装饰器基础
功能说明
@Reusable 是 ArkUI 框架提供的组件级装饰器,标记在 @Component 装饰的自定义组件之上。被标记的组件在被 LazyForEach/ForEach 使用时,框架会自动管理组件实例的创建与回收,而不是每次都销毁和重建。当组件被复用时,框架会调用 aboutToReuse 生命周期回调,开发者需要在其中更新组件的数据状态。
完整代码
// entry/src/main/ets/components/topic/TopicCard.ets
// @Reusable 组件复用基础示例
/*
* 文件用途:科普文章卡片组件(支持复用)
* 创建时间:2026-07-18
* 兼容环境:HarmonyOS API 26+
* 版本:v2.0
* 风险提示:aboutToReuse 中必须完整更新所有展示数据,避免残留旧数据
*/
import { Topic, FunFact } from '../../model/Topic';
import { Category } from '../../model/Category';
import { scienceData } from '../../viewmodel/ScienceData';
import { ThemeColors } from '../../constants/AppConstants';
import { FormatUtil } from '../../utils/FormatUtil';
/**
* 生成默认的空 Topic 对象
* 用于组件初始化时的默认值和复用时的数据重置
*/
function getDefaultTopic(): Topic {
const emptyFacts: FunFact[] = [];
const emptyContent: string[] = [];
const topic: Topic = {
id: 0,
title: '',
category: '',
categoryName: '',
categoryColor: '',
icon: '',
gradientStart: '#ffffff',
gradientEnd: '#ffffff',
content: emptyContent,
funFacts: emptyFacts,
animationType: '',
has3DModel: false,
readTime: 0,
readCount: 0,
difficulty: 'easy'
};
return topic;
}
// ✅ 使用 @Reusable 装饰器标记组件为可复用
@Reusable
@Component
export struct TopicCard {
// @Prop 装饰的数据属性
@Prop topic: Topic = getDefaultTopic();
onItemClick?: (topic: Topic) => void;
/**
* ✅ 组件复用生命周期回调
* 当组件从复用池中被取出并绑定新数据时触发
* 必须在此方法中更新所有需要展示的数据
* @param params - 新的数据参数
*/
aboutToReuse(params: Record<string, Object>): void {
// 从参数中提取新的 topic 数据
if (params.topic) {
this.topic = params.topic as Topic;
}
}
// ... build 方法保持不变 ...
}
代码解析
1. @Reusable 装饰器的位置
// ✅ 正确:@Reusable 在 @Component 之前
@Reusable
@Component
export struct TopicCard { }
// ❌ 错误:@Reusable 在 @Component 之后
@Component
@Reusable
export struct TopicCard { }
// ❌ 错误:@Reusable 用在普通函数组件上(不是 @Component)
@Reusable
function TopicCard() { }
原理/说明:
@Reusable必须位于@Component装饰器之前,两者搭配使用@Reusable仅对使用@Component装饰的自定义组件生效- 被
@Entry标记的入口页面组件不能使用@Reusable(页面组件由路由管理,不参与列表复用)
2. aboutToReuse 回调的数据更新
aboutToReuse(params: Record<string, Object>): void {
if (params.topic) {
this.topic = params.topic as Topic;
}
}
原理/说明:
aboutToReuse是组件复用独有的生命周期回调,在组件从复用池取出时调用- 参数
params是一个Record<string, Object>类型的键值对,包含了父组件传入的新数据 - 键名对应父组件调用子组件时的属性名(如
topic对应<TopicCard topic={data}>中的topic) - 必须在
aboutToReuse中更新所有影响 UI 渲染的状态数据,否则会显示上一次的旧数据
3. 生命周期对比
普通组件(无复用)的生命周期:
创建 → aboutToAppear → build → 渲染 → aboutToDisappear → 销毁
复用组件的生命周期:
首次创建 → aboutToAppear → build → 渲染
回收(不移出可视区域时):保持实例,放入复用池
复用(取出时):aboutToReuse → 数据更新 → build → 渲染
最终销毁:aboutToDisappear → 销毁(页面退出或列表清空时)
注意:复用组件的 aboutToAppear 只在首次创建时调用一次
后续的复用只触发 aboutToReuse,不触发 aboutToAppear
步骤2: TopicCard 科普文章卡片复用改造
功能说明
TopicCard 是《奇妙科学乐园》中最核心的列表项组件,用于在科普知识列表页(Topics)、首页推荐区域、收藏列表、历史记录等多个页面中展示文章卡片。由于科普文章数据量相对较多(20-50条),且每张卡片包含图片、标题、摘要、阅读量等多个 UI 元素,对其进行复用优化能显著减少列表滚动时的创建/销毁开销。
完整代码
// entry/src/main/ets/components/topic/TopicCard.ets
// 完整的 @Reusable TopicCard 组件实现
/*
* 文件用途:科普文章卡片组件(支持复用)
* 创建时间:2026-07-18
* 兼容环境:HarmonyOS API 26+
* 版本:v2.0
* 风险提示:aboutToReuse 中必须完整更新所有展示数据
*/
import { Topic, FunFact } from '../../model/Topic';
import { scienceData } from '../../viewmodel/ScienceData';
import { ThemeColors } from '../../constants/AppConstants';
import { FormatUtil } from '../../utils/FormatUtil';
import { Logger } from '../utils/Logger';
const TOPIC_CARD_TAG = 'TopicCard';
/**
* 生成默认的空 Topic 对象
*/
function getDefaultTopic(): Topic {
const emptyFacts: FunFact[] = [];
const emptyContent: string[] = [];
const topic: Topic = {
id: 0,
title: '',
category: '',
categoryName: '',
categoryColor: '',
icon: '',
gradientStart: '#ffffff',
gradientEnd: '#ffffff',
content: emptyContent,
funFacts: emptyFacts,
animationType: '',
has3DModel: false,
readTime: 0,
readCount: 0,
difficulty: 'easy'
};
return topic;
}
/**
* 根据难度等级获取标签文字
* @param difficulty - 难度等级
* @returns 难度标签文字
*/
function getDifficultyLabel(difficulty: string): string {
switch (difficulty) {
case 'easy':
return '入门';
case 'medium':
return '进阶';
case 'hard':
return '挑战';
default:
return '入门';
}
}
/**
* 根据难度等级获取标签颜色
* @param difficulty - 难度等级
* @returns 标签背景颜色
*/
function getDifficultyBgColor(difficulty: string): string {
switch (difficulty) {
case 'easy':
return '#e8f5e9';
case 'medium':
return '#fff3e0';
case 'hard':
return '#ffebee';
default:
return '#e8f5e9';
}
}
// ✅ 标记为可复用组件
@Reusable
@Component
export struct TopicCard {
// 使用 @Prop 传递不可变数据(父组件单向传递)
@Prop topic: Topic = getDefaultTopic();
// 点击回调
onItemClick?: (topic: Topic) => void;
// ========== 组件生命周期 ==========
/**
* 组件首次创建时调用
* 执行一次性初始化操作(如注册事件监听、加载静态资源)
*/
aboutToAppear(): void {
Logger.debug(TOPIC_CARD_TAG, `卡片首次创建, topicId: ${this.topic.id}`);
}
/**
* ✅ 组件被复用时调用
* 关键方法:在此更新所有展示数据,避免旧数据残留
*
* @param params - 新数据参数
* - params.topic: 新的 Topic 数据
*/
aboutToReuse(params: Record<string, Object>): void {
// ✅ 更新核心数据
if (params.topic) {
this.topic = params.topic as Topic;
}
Logger.debug(TOPIC_CARD_TAG,
`卡片复用, 新topicId: ${this.topic.id}, 标题: ${this.topic.title}`);
}
/**
* 组件即将销毁时调用
* 执行资源释放操作(如果有)
*/
aboutToDisappear(): void {
Logger.debug(TOPIC_CARD_TAG, `卡片销毁, topicId: ${this.topic.id}`);
}
// ========== 私有方法 ==========
/**
* 获取分类封面图片
* 根据 topic 的分类ID查找对应的封面图片资源
*/
private getCategoryCover(): Resource {
const category = scienceData.getCategoryById(this.topic.category);
return category ? category.topicCoverImage : $r('app.media.topic_sun');
}
/**
* 获取文章摘要
* 拼接文章内容的前两段作为摘要文本
*/
private getTopicSummary(): string {
if (this.topic.content && this.topic.content.length > 0) {
if (this.topic.content.length >= 2) {
return this.topic.content[0] + this.topic.content[1];
}
return this.topic.content[0];
}
return '';
}
// ========== UI 渲染 ==========
build() {
Column() {
// 封面图片区域(Stack 布局:图片 + 分类标签)
Stack({ alignContent: Alignment.TopStart }) {
Image(this.getCategoryCover())
.width('100%')
.height(110)
.objectFit(ImageFit.Cover);
// 分类标签(左上角)
Text(this.topic.categoryName)
.fontSize(11)
.fontColor(ThemeColors.TEXT_WHITE)
.backgroundColor('rgba(0, 0, 0, 0.4)')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(9999)
.margin({ top: 10, left: 10 });
}
.width('100%');
// 文字信息区域
Column() {
// 文章标题
Text(this.topic.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_PRIMARY)
.width('100%')
.margin({ bottom: 6 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis });
// 文章摘要
Text(this.getTopicSummary())
.fontSize(12)
.fontColor(ThemeColors.TEXT_SECONDARY)
.width('100%')
.margin({ bottom: 8 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis });
// 底部信息栏(阅读量 + 难度标签 + 箭头)
Row() {
Text('👁 ' + FormatUtil.formatReadCount(this.topic.readCount) + ' 阅读')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY)
.layoutWeight(1);
// 难度标签
Text(getDifficultyLabel(this.topic.difficulty))
.fontSize(11)
.fontColor(
this.topic.difficulty === 'easy' ? ThemeColors.SUCCESS :
this.topic.difficulty === 'medium' ? ThemeColors.WARNING :
ThemeColors.DANGER
)
.backgroundColor(getDifficultyBgColor(this.topic.difficulty))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(9999)
.margin({ right: 8 });
Text('→')
.fontSize(16)
.fontColor(ThemeColors.PRIMARY);
}
.width('100%');
}
.padding(12)
.width('100%');
}
.width('100%')
.backgroundColor(ThemeColors.BG_PRIMARY)
.borderRadius(16)
.border({ width: 1, color: ThemeColors.BORDER_COLOR })
.clip(true)
.margin({ bottom: 12 })
.onClick(() => {
if (this.onItemClick) {
this.onItemClick(this.topic);
}
});
}
}
代码解析
1. aboutToReuse 中的数据更新策略
aboutToReuse(params: Record<string, Object>): void {
if (params.topic) {
this.topic = params.topic as Topic;
}
}
原理/说明:
params的键名topic对应父组件中<TopicCard topic={data}>的属性名as Topic类型断言将Object类型还原为Topic类型@Prop topic属性在aboutToReuse中通过直接赋值更新,框架会自动触发重新渲染- 如果有多个
@Prop属性,都需要在aboutToReuse中分别更新
2. 复用组件中的日志打印
aboutToReuse(params: Record<string, Object>): void {
if (params.topic) {
this.topic = params.topic as Topic;
}
Logger.debug(TOPIC_CARD_TAG,
`卡片复用, 新topicId: ${this.topic.id}, 标题: ${this.topic.title}`);
}
原理/说明:
- 在开发阶段,
aboutToReuse中打印日志可以直观地观察复用行为 - 通过对比
aboutToAppear(首次创建)和aboutToReuse(复用)的调用次数,可以验证复用是否生效 - 生产环境可通过 Logger 级别控制关闭 debug 日志
步骤3: 实验列表项复用——ExperimentCard
功能说明
科学实验列表(Lab 页面)中使用 ForEach 渲染实验卡片。虽然数据量不大(10-20条),但每张实验卡片包含封面图片、名称、描述、难度标签、时长、分类等多个 UI 元素。将实验卡片改造为 @Reusable 可复用组件,可以在数据量增加时保持流畅的滚动体验。
完整代码
// entry/src/main/ets/components/lab/ExperimentCard.ets
/*
* 文件用途:科学实验卡片组件(支持复用)
* 创建时间:2026-07-18
* 兼容环境:HarmonyOS API 26+
* 版本:v1.0
* 风险提示:aboutToReuse 中需更新全部展示属性
*/
import { Experiment } from '../../model/Experiment';
import { ThemeColors } from '../../constants/AppConstants';
/**
* 生成默认的空 Experiment 对象
*/
function getDefaultExperiment(): Experiment {
const emptySteps: string[] = [];
const emptyMaterials: string[] = [];
const experiment: Experiment = {
id: '',
name: '',
icon: '',
coverImage: $r('app.media.topic_sun'),
description: '',
category: '',
categoryName: '',
difficulty: 'easy',
duration: '',
materials: emptyMaterials,
steps: emptySteps,
colorStart: '#ffffff',
colorEnd: '#ffffff',
safetyTip: ''
};
return experiment;
}
// ✅ 标记为可复用组件
@Reusable
@Component
export struct ExperimentCard {
@Prop experiment: Experiment = getDefaultExperiment();
onItemClick?: (experimentId: string) => void;
// ========== 生命周期 ==========
aboutToAppear(): void {
// 首次创建时的初始化逻辑
}
/**
* ✅ 复用时更新实验数据
*/
aboutToReuse(params: Record<string, Object>): void {
if (params.experiment) {
this.experiment = params.experiment as Experiment;
}
}
// ========== 工具方法 ==========
/**
* 获取难度名称
*/
private getDifficultyName(difficulty: string): string {
switch (difficulty) {
case 'easy':
return '简单';
case 'medium':
return '中等';
case 'hard':
return '困难';
default:
return '简单';
}
}
/**
* 获取难度颜色
*/
private getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case 'easy':
return ThemeColors.SUCCESS;
case 'medium':
return ThemeColors.WARNING;
case 'hard':
return ThemeColors.DANGER;
default:
return ThemeColors.TEXT_TERTIARY;
}
}
/**
* 获取难度标签背景色
*/
private getDifficultyBgColor(difficulty: string): string {
switch (difficulty) {
case 'easy':
return '#e8f5e9';
case 'medium':
return '#fff3e0';
case 'hard':
return '#ffebee';
default:
return '#f5f5f5';
}
}
// ========== UI 渲染 ==========
build() {
Row() {
// 封面图片
Image(this.experiment.coverImage)
.width(64)
.height(64)
.borderRadius(16)
.objectFit(ImageFit.Cover)
.margin({ right: 12 });
// 信息区域
Column({ space: 4 }) {
// 第一行:实验名称 + 难度标签
Row() {
Text(this.experiment.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(ThemeColors.TEXT_PRIMARY)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis });
Text(this.getDifficultyName(this.experiment.difficulty))
.fontSize(11)
.fontColor(this.getDifficultyColor(this.experiment.difficulty))
.backgroundColor(this.getDifficultyBgColor(this.experiment.difficulty))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(9999);
}
.width('100%');
// 第二行:实验描述
Text(this.experiment.description)
.fontSize(13)
.fontColor(ThemeColors.TEXT_SECONDARY)
.width('100%')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.wordBreak(WordBreak.BREAK_ALL);
// 第三行:分类 + 时长
Row({ space: 6 }) {
Text(this.experiment.categoryName)
.fontSize(11)
.fontColor(ThemeColors.TEXT_TERTIARY);
Text('·')
.fontSize(11)
.fontColor('#cccccc');
Row() {
Image($r('app.media.icon_history'))
.width(14)
.height(14)
.objectFit(ImageFit.Contain)
.margin({ right: 4 });
Text(this.experiment.duration)
.fontSize(11)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
}
.width('100%');
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start);
// 箭头
Text('→')
.fontSize(16)
.fontColor('#cccccc');
}
.width('100%')
.padding(14)
.backgroundColor(ThemeColors.BG_PRIMARY)
.borderRadius(16)
.border({ width: 1, color: ThemeColors.BORDER_COLOR })
.onClick(() => {
if (this.onItemClick) {
this.onItemClick(this.experiment.id);
}
});
}
}
代码解析
1. Lab 页面中使用复用的 ExperimentCard
// entry/src/main/ets/pages/Lab.ets
// 使用 @Reusable ExperimentCard 替代内联布局
import { ExperimentCard } from '../components/lab/ExperimentCard';
@Entry
@Component
struct Lab {
@State experiments: Experiment[] = [];
@State currentCategory: string = 'all';
// ... 其他逻辑保持不变 ...
build() {
Column() {
// AppBar 和分类标签(省略)
// ✅ 使用独立的 @Reusable 组件替代内联 ForEach 布局
List({ space: 12 }) {
ForEach(this.getFilteredExperiments(), (exp: Experiment) => {
ListItem() {
ExperimentCard({
experiment: exp,
onItemClick: (experimentId: string) => {
this.goToDetail(experimentId);
}
});
}
}, (exp: Experiment) => exp.id);
}
.width('100%')
.layoutWeight(1)
.padding(16)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.backgroundColor(ThemeColors.BG_SECONDARY)
.alignListItem(ListItemAlign.Start);
}
}
}
原理/说明:
- 将原来 Lab 页面中 ForEach 内联的复杂 Row 布局提取为独立的
ExperimentCard组件 - 独立组件更容易添加
@Reusable装饰器进行复用优化 - 通过
onItemClick回调将点击事件传递给父组件,保持数据流单向清晰 - ForEach 的第三个参数
exp.id是唯一键值,确保框架能正确追踪每个列表项的身份
步骤4: 测验选项组件复用——QuizOptionItem
功能说明
测验页面(Quiz)中,每个问题有 4 个选项。虽然单个页面的选项数量不多(4-10个),但在连续答题过程中,选项组件会被频繁创建和销毁。更重要的是,选项组件包含状态切换逻辑(选中/未选中/正确/错误),复用组件时需要特别注意状态的重置。
完整代码
// entry/src/main/ets/components/quiz/QuizOptionItem.ets
/*
* 文件用途:测验选项组件(支持复用)
* 创建时间:2026-07-18
* 兼容环境:HarmonyOS API 26+
* 版本:v1.0
* 风险提示:复用时必须重置选中状态,否则上一个选项的状态会残留
*/
import { ThemeColors } from '../../constants/AppConstants';
/** 选项状态枚举 */
type OptionState = 'normal' | 'selected' | 'correct' | 'wrong';
// ✅ 标记为可复用组件
@Reusable
@Component
export struct QuizOptionItem {
// 选项文字内容
@Prop optionText: string = '';
// 选项索引(A/B/C/D)
@Prop optionIndex: number = 0;
// 选项索引标签数组
private readonly OPTION_LABELS: string[] = ['A', 'B', 'C', 'D', 'E', 'F'];
// 当前选项状态(内部管理)
@State currentState: OptionState = 'normal';
// 是否已提交答案(控制不可再点击)
@State isSubmitted: boolean = false;
// 点击回调
onOptionSelect?: (index: number) => void;
// ========== 生命周期 ==========
aboutToAppear(): void {
// 首次创建,初始化为正常状态
this.resetState();
}
/**
* ✅ 复用时重置状态并更新数据
* 关键:除了更新展示数据,还必须重置组件内部状态
*/
aboutToReuse(params: Record<string, Object>): void {
// 更新展示数据
if (params.optionText) {
this.optionText = params.optionText as string;
}
if (params.optionIndex !== undefined) {
this.optionIndex = params.optionIndex as number;
}
// ✅ 重置内部状态(关键!)
this.resetState();
}
// ========== 公共方法 ==========
/**
* 设置选项状态(供父组件调用)
* @param state - 目标状态
*/
setState(state: OptionState): void {
this.currentState = state;
}
/**
* 设置提交状态(禁止再次点击)
*/
setSubmitted(submitted: boolean): void {
this.isSubmitted = submitted;
}
// ========== 私有方法 ==========
/**
* 重置组件到初始状态
* 在复用时调用,清除上一轮答题的状态残留
*/
private resetState(): void {
this.currentState = 'normal';
this.isSubmitted = false;
}
/**
* 获取选项标签文字(A/B/C/D)
*/
private getOptionLabel(): string {
if (this.optionIndex >= 0 && this.optionIndex < this.OPTION_LABELS.length) {
return this.OPTION_LABELS[this.optionIndex];
}
return String.fromCharCode(65 + this.optionIndex); // 超出范围用字母
}
/**
* 获取状态对应的背景色
*/
private getStateBgColor(): string {
switch (this.currentState) {
case 'selected':
return '#e3f2fd'; // 选中蓝色
case 'correct':
return '#e8f5e9'; // 正确绿色
case 'wrong':
return '#ffebee'; // 错误红色
default:
return ThemeColors.BG_TERTIARY; // 默认灰色
}
}
/**
* 获取状态对应的边框颜色
*/
private getStateBorderColor(): string {
switch (this.currentState) {
case 'selected':
return '#2196f3';
case 'correct':
return '#4caf50';
case 'wrong':
return '#f44336';
default:
return ThemeColors.BORDER_COLOR;
}
}
/**
* 获取状态对应的标签背景色
*/
private getLabelBgColor(): string {
switch (this.currentState) {
case 'selected':
return '#2196f3';
case 'correct':
return '#4caf50';
case 'wrong':
return '#f44336';
default:
return '#999999';
}
}
// ========== UI 渲染 ==========
build() {
Row() {
// 选项标签(A/B/C/D)
Text(this.getOptionLabel())
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.width(28)
.height(28)
.backgroundColor(this.getLabelBgColor())
.borderRadius(14)
.textAlign(TextAlign.Center);
// 选项文字
Text(this.optionText)
.fontSize(15)
.fontColor(ThemeColors.TEXT_PRIMARY)
.layoutWeight(1)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis });
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.backgroundColor(this.getStateBgColor())
.borderRadius(12)
.border({ width: 1.5, color: this.getStateBorderColor() })
.onClick(() => {
// 已提交后禁止切换选项
if (this.isSubmitted) {
return;
}
// 未提交时允许选中
if (this.currentState !== 'selected') {
this.currentState = 'selected';
}
if (this.onOptionSelect) {
this.onOptionSelect(this.optionIndex);
}
});
}
}
代码解析
1. 复用时的状态重置——最容易踩的坑
aboutToReuse(params: Record<string, Object>): void {
// ✅ 更新展示数据
if (params.optionText) {
this.optionText = params.optionText as string;
}
if (params.optionIndex !== undefined) {
this.optionIndex = params.optionIndex as number;
}
// ✅ 重置内部状态(关键!不重置会导致旧选项的选中/正确/错误状态残留)
this.resetState();
}
private resetState(): void {
this.currentState = 'normal'; // 重置选项状态
this.isSubmitted = false; // 重置提交锁定
}
原理/说明:
- 这是
@Reusable复用中最容易出 bug 的地方:组件从复用池取出时,内部的@State状态仍保留上一次的值 - 如果不调用
resetState(),复用后可能出现"选项B显示为正确(绿色)"但实际上是下一题的选项B(应为默认灰色) @State属性不会在复用时自动重置,必须手动在aboutToReuse中恢复初始值
2. 状态重置的完整性检查
// ✅ 正确:aboutToReuse 中重置所有影响UI的 @State 属性
aboutToReuse(params: Record<string, Object>): void {
this.optionText = params.optionText as string; // @Prop 数据更新
this.optionIndex = params.optionIndex as number; // @Prop 数据更新
this.currentState = 'normal'; // @State 状态重置
this.isSubmitted = false; // @State 状态重置
}
// ❌ 错误:只更新了数据,忘记重置状态
aboutToReuse(params: Record<string, Object>): void {
this.optionText = params.optionText as string;
this.optionIndex = params.optionIndex as number;
// 缺少:this.currentState = 'normal';
// 缺少:this.isSubmitted = false;
// → 导致下一题的选项可能显示上一题的颜色状态
}
原理/说明:
- 复用组件的
aboutToReuse方法中,需要更新两类数据:@Prop数据:通过params传入的新数据(如optionText、optionIndex)@State内部状态:组件自身管理的状态(如currentState、isSubmitted)
- 遗漏任何一类都会导致 UI 展示异常
步骤5: 复用组件在 LazyForEach 中的配合使用
功能说明
@Reusable 组件在 LazyForEach 中配合使用时效果最佳。LazyForEach 本身已经实现了按需加载(只渲染可视区域内的列表项),结合 @Reusable 的组件复用池,可以达到"只创建少量组件实例,通过复用覆盖大量数据项"的理想效果。
完整代码
// entry/src/main/ets/pages/Topics.ets
// @Reusable TopicCard 与 LazyForEach 的配合使用
import { TopicCard } from '../components/topic/TopicCard';
@Component
export struct Topics {
@State topicList: Topic[] = [];
private topicDataSource: TopicDataSource = new TopicDataSource();
// ... 其他属性和方法 ...
build() {
Column() {
// 搜索栏和分类标签(省略)
if (this.topicList.length > 0) {
List() {
ListItem() {
Row() {
Text('共 ' + this.topicList.length + ' 篇文章')
.fontSize(13)
.fontColor(ThemeColors.TEXT_SECONDARY);
}
.width('100%')
.margin({ bottom: 12 });
}
// ✅ LazyForEach + @Reusable TopicCard 的黄金组合
// LazyForEach: 按需加载,只创建可视区域的组件
// @Reusable: 组件复用,移出可视区域的不销毁而是回收
LazyForEach(this.topicDataSource, (topic: Topic) => {
ListItem() {
TopicCard({
topic: topic,
onItemClick: (t: Topic) => this.goToTopicDetail(t)
});
}
}, (topic: Topic) => topic.id.toString());
}
.width('100%')
.layoutWeight(1)
.cachedCount(3) // ✅ 缓存扩展:可视区域上下各多缓存3个组件
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.padding(16)
.backgroundColor(ThemeColors.BG_SECONDARY);
}
}
}
}
代码解析
1. LazyForEach + @Reusable 的协同工作
可视区域能显示 5 个 TopicCard
不使用复用:
滚动时,每移出一个卡片就销毁,每移入一个卡片就创建
滚动 10 次可能创建/销毁 20+ 个组件实例
使用 @Reusable + cachedCount(3):
框架维护约 5 + 3 + 3 = 11 个组件实例
这 11 个实例在滚动过程中被反复复用
滚动 100 次也只创建过 11 个组件实例
组件实例池:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│Card A│Card B│Card C│Card D│Card E│Card F│Card G│Card H│Card I│Card J│Card K│
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
↑ 缓存区(3) ↑ 可视区(5) ↑ 缓存区(3)
滚动时:
Card A → 回收到复用池 → 取出时复用为 Card L(新数据)
Card B → 回收到复用池 → 取出时复用为 Card M(新数据)
以此类推,始终只维护这 11 个组件实例
2. cachedCount 的配置
// ✅ 设置合理的缓存数量
.cachedCount(3) // 可视区域上下各多创建3个组件
// ❌ cachedCount 设置过大(浪费内存)
.cachedCount(20) // 创建过多组件,失去懒加载的意义
// ❌ cachedCount 设置为 0(可能导致滚动空白闪烁)
.cachedCount(0) // 无缓存,快速滚动时可能出现空白区域
原理/说明:
cachedCount指定可视区域外上下各额外缓存的组件数量- 合理的
cachedCount可以避免快速滚动时出现空白区域 - 对于 TopicCard 这种包含图片的组件,建议
cachedCount设为 2-5 cachedCount越大,内存占用越多,需要根据设备性能和数据项复杂度平衡
步骤6: 复用组件的通用模板封装
功能说明
为了减少每个列表项组件中重复的复用逻辑,我们可以提取一个通用的 ReusableListItem 基类模板,为项目中所有的列表项组件提供统一的复用支持。
完整代码
// entry/src/main/ets/components/base/ReusableListItem.ets
/*
* 文件用途:可复用列表项基础模板
* 创建时间:2026-07-18
* 兼容环境:HarmonyOS API 26+
* 版本:v1.0
* 风险提示:子组件的 aboutToReuse 必须调用 super 方法
*/
import { Logger } from '../../utils/Logger';
const REUSABLE_TAG = 'ReusableListItem';
/**
* 可复用列表项基础接口
* 所有需要复用的列表项组件都应实现此接口
*/
export interface IReusableListItem {
/**
* 复用时更新数据
* 子组件必须实现此方法,更新所有 @Prop 和 @State 属性
*/
aboutToReuse(params: Record<string, Object>): void;
/**
* 重置内部状态
* 子组件必须实现此方法,将所有 @State 恢复到初始值
*/
resetState(): void;
}
/**
* 复用组件计数器(用于调试复用效果)
* 统计组件的创建次数和复用次数
*/
export class ReuseCounter {
private static createCount: Map<string, number> = new Map();
private static reuseCount: Map<string, number> = new Map();
/**
* 记录组件创建
*/
static recordCreate(componentName: string): void {
const count = (ReuseCounter.createCount.get(componentName) || 0) + 1;
ReuseCounter.createCount.set(componentName, count);
Logger.debug(REUSABLE_TAG,
`创建 ${componentName}, 累计创建: ${count}次`);
}
/**
* 记录组件复用
*/
static recordReuse(componentName: string): void {
const count = (ReuseCounter.reuseCount.get(componentName) || 0) + 1;
ReuseCounter.reuseCount.set(componentName, count);
Logger.debug(REUSABLE_TAG,
`复用 ${componentName}, 累计复用: ${count}次`);
}
/**
* 获取统计信息
*/
static getStats(): Record<string, { create: number, reuse: number }> {
const stats: Record<string, { create: number, reuse: number }> = {};
ReuseCounter.createCount.forEach((count, name) => {
const reuseCount = ReuseCounter.reuseCount.get(name) || 0;
stats[name] = { create: count, reuse: reuseCount };
});
return stats;
}
/**
* 重置统计
*/
static reset(): void {
ReuseCounter.createCount.clear();
ReuseCounter.reuseCount.clear();
}
}
在 TopicCard 中使用 ReuseCounter
// entry/src/main/ets/components/topic/TopicCard.ets
// 集成 ReuseCounter 的复用统计
import { ReuseCounter } from '../base/ReusableListItem';
@Reusable
@Component
export struct TopicCard {
@Prop topic: Topic = getDefaultTopic();
onItemClick?: (topic: Topic) => void;
aboutToAppear(): void {
ReuseCounter.recordCreate('TopicCard');
}
aboutToReuse(params: Record<string, Object>): void {
ReuseCounter.recordReuse('TopicCard');
if (params.topic) {
this.topic = params.topic as Topic;
}
}
}
代码解析
1. ReuseCounter 的统计输出
// hilog 输出示例
[TopicCard] 创建 TopicCard, 累计创建: 1次
[TopicCard] 创建 TopicCard, 累计创建: 2次
[TopicCard] 创建 TopicCard, 累计创建: 3次
[TopicCard] 复用 TopicCard, 累计复用: 1次
[TopicCard] 复用 TopicCard, 累计复用: 2次
[TopicCard] 复用 TopicCard, 累计复用: 3次
...
[TopicCard] 复用 TopicCard, 累计复用: 47次
// 统计结果:
// TopicCard: 创建3次,复用47次 → 复用率 = 47/(3+47) = 94%
原理/说明:
- 复用率 = 复用次数 / (创建次数 + 复用次数)
- 高复用率(> 80%)说明 @Reusable 生效良好
- 低复用率可能原因:列表项高度不一致导致框架无法有效复用、cachedCount 设置不合理
ReuseCounter仅用于开发调试,生产环境应关闭 debug 级别日志
⚠️ 常见问题与解决方案
问题1: 复用组件显示旧数据——"幽灵数据"残留
现象:
滚动列表后,某张卡片显示了上一次数据的内容(标题、封面图片等与当前数据不匹配)。
原因:aboutToReuse 中没有完整更新所有影响 UI 的数据属性,导致部分 UI 元素仍使用旧值。
错误代码:
// ❌ 只更新了 topic 数据,但忘记更新其他 @Prop 属性
aboutToReuse(params: Record<string, Object>): void {
if (params.topic) {
this.topic = params.topic as Topic;
}
// 缺少:如果还有其他 @Prop 属性也需要更新
// 例如 this.isFavorite = params.isFavorite as boolean;
}
正确代码:
// ✅ 更新所有 @Prop 属性
aboutToReuse(params: Record<string, Object>): void {
if (params.topic) {
this.topic = params.topic as Topic;
}
if (params.isFavorite !== undefined) {
this.isFavorite = params.isFavorite as boolean;
}
// ✅ 同时重置所有 @State 内部状态
this.isExpanded = false; // 重置展开状态
this.isLoading = false; // 重置加载状态
this.animationProgress = 0; // 重置动画进度
}
规则/建议:
- 列出组件中所有的
@Prop和@State属性,逐一确认是否需要在aboutToReuse中更新 - 建议在组件中添加注释标记所有需要复用重置的属性
- 使用 ReuseCounter 统计复用率,验证复用是否生效
问题2: @Link 状态在复用组件中行为异常
现象:
使用 @Link 绑定的状态在复用后不更新,或者双向绑定失效。
原因:@Link 需要父组件通过 $ 语法传递引用,复用时引用链可能断裂。
错误代码:
// ❌ @Link 与 @Reusable 配合可能有问题
@Reusable
@Component
struct ChildItem {
@Link count: number; // 复用时 @Link 引用可能失效
}
// 父组件
ChildItem({ count: $this.count })
正确代码:
// ✅ 复用组件优先使用 @Prop + 事件回调模式
@Reusable
@Component
struct ChildItem {
@Prop count: number; // 单向数据传递
onCountChange?: (newCount: number) => void; // 事件回调通知父组件
}
// 父组件
ChildItem({
count: this.count,
onCountChange: (newCount: number) => {
this.count = newCount;
}
})
规则/建议:
- 复用组件优先使用
@Prop(单向传递)而非@Link(双向绑定) - 如果需要子组件修改父组件数据,使用事件回调模式(emit)
@Link在复用场景中可能导致状态同步异常,仅在简单场景谨慎使用
问题3: @Reusable 与 ForEach 搭配无效果
现象:
在 ForEach 中使用 @Reusable 组件,但观察日志发现组件仍在频繁创建和销毁,没有复用行为。
原因:@Reusable 主要配合 LazyForEach 使用,普通 ForEach 对复用的支持有限。
错误代码:
// ❌ ForEach + @Reusable 效果不理想
ForEach(this.itemList, (item: Item) => {
ListItem() {
ReusableCard({ item: item });
}
});
正确代码:
// ✅ LazyForEach + @Reusable 最佳搭配
LazyForEach(this.itemDataSource, (item: Item) => {
ListItem() {
ReusableCard({ item: item });
}
}, (item: Item) => item.id.toString());
原理/说明:
LazyForEach本身就是为大量数据列表设计的,配合IDataSource接口能精确追踪数据变化@Reusable在LazyForEach下能最大程度发挥复用效果- 如果数据量较小(< 20条),
ForEach+@Reusable也可能有部分复用效果,但不保证
问题4: 列表项高度不一致导致复用异常
现象:
列表中不同项的高度差异较大,使用 @Reusable 后出现布局错乱或内容截断。
原因:
复用池回收的组件高度与目标位置需要的高度不匹配,框架可能无法正确调整。
错误代码:
// ❌ 列表项高度不固定,复用时可能出现高度错乱
@Reusable
@Component
struct VariableHeightCard {
@Prop content: string;
// 高度根据内容动态变化
build() {
Column() {
Text(this.content) // 内容长度不同导致高度不同
.fontSize(14);
}
// 没有固定高度
}
}
正确代码:
// ✅ 列表项使用固定高度或明确的最大高度
@Reusable
@Component
struct FixedHeightCard {
@Prop content: string;
build() {
Column() {
Text(this.content)
.fontSize(14)
.maxLines(3) // 限制最大行数
.textOverflow({ overflow: TextOverflow.Ellipsis }); // 超出截断
}
.width('100%')
.height(120) // ✅ 固定高度,确保复用时高度一致
}
}
规则/建议:
- 尽量让同类型的列表项保持固定高度
- 如果内容高度必须变化,使用
maxLines+textOverflow截断超长内容 - 复用组件内部的
Image应设置固定的width和height,避免图片加载后高度跳变
问题5: 复用组件中注册的事件监听未清理
现象:
页面退出后,hilog 仍持续输出复用组件中注册的回调日志,内存持续增长。
原因:aboutToAppear 中注册的事件监听在组件回收时未被移除,因为回收不触发 aboutToDisappear。
错误代码:
@Reusable
@Component
struct MyCard {
private timerId: number = -1;
aboutToAppear(): void {
// 注册定时器
this.timerId = setInterval(() => {
// 某些定时操作
}, 1000);
}
// ❌ aboutToReuse 中没有清理旧定时器
aboutToReuse(params: Record<string, Object>): void {
// 更新数据但没有清理定时器
this.data = params.data;
}
// ❌ aboutToDisappear 可能在复用过程中不会被调用
aboutToDisappear(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId);
}
}
}
正确代码:
@Reusable
@Component
struct MyCard {
private timerId: number = -1;
aboutToAppear(): void {
this.startTimer();
}
// ✅ aboutToReuse 中先清理旧资源再更新数据
aboutToReuse(params: Record<string, Object>): void {
this.stopTimer(); // 先清理旧定时器
this.data = params.data; // 再更新数据
this.startTimer(); // 重新启动定时器
}
aboutToDisappear(): void {
this.stopTimer(); // 最终销毁时清理
}
private startTimer(): void {
if (this.timerId < 0) {
this.timerId = setInterval(() => {
// 定时操作
}, 1000);
}
}
private stopTimer(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
}
规则/建议:
aboutToReuse相当于"半重新初始化",需要执行清理 + 重新初始化的完整流程- 将资源注册和释放分别封装为
startXxx()和stopXxx()方法,在aboutToAppear、aboutToReuse、aboutToDisappear中统一调用 - 复用组件的
aboutToDisappear只在整个组件被彻底销毁时调用(如页面退出或列表清空)
📝 本章小结
核心知识点
本文详细讲解了 HarmonyOS 组件复用与 @Reusable 装饰器的完整实现方案,主要包括:
1. @Reusable 装饰器基础
@Reusable标记在@Component之前,声明组件可被框架复用- 框架维护组件复用池,移出可视区域的组件被回收而非销毁
- 新列表项进入可视区域时优先从复用池取出组件并更新数据
aboutToReuse是复用独有的生命周期回调,用于更新数据和重置状态
2. 生命周期管理
aboutToAppear:仅在首次创建时调用一次(不复用触发)aboutToReuse:每次从复用池取出时调用(核心数据更新方法)aboutToDisappear:组件最终销毁时调用(页面退出或列表清空)
3. 三个核心组件的复用实战
- TopicCard:科普文章卡片,更新 topic 数据,无内部状态需重置
- ExperimentCard:实验卡片,更新 experiment 数据,提取独立组件优化
- QuizOptionItem:测验选项,更新数据 + 重置 selected/correct/wrong 状态
最佳实践总结
✅ @Reusable + LazyForEach 黄金组合
@Reusable
@Component
export struct TopicCard {
@Prop topic: Topic = getDefaultTopic();
aboutToReuse(params: Record<string, Object>): void {
if (params.topic) {
this.topic = params.topic as Topic;
}
}
}
// 页面中使用
LazyForEach(this.dataSource, (topic: Topic) => {
ListItem() {
TopicCard({ topic: topic });
}
}, (topic: Topic) => topic.id.toString());
✅ 复用时完整重置内部状态
aboutToReuse(params: Record<string, Object>): void {
// 1. 更新 @Prop 数据
this.optionText = params.optionText as string;
this.optionIndex = params.optionIndex as number;
// 2. 重置 @State 内部状态
this.currentState = 'normal';
this.isSubmitted = false;
}
✅ 复用组件中资源管理的三步法则
aboutToReuse(params): void {
this.stopTimer(); // ① 清理旧资源
this.data = params.data; // ② 更新数据
this.startTimer(); // ③ 重新初始化
}
下一步预告
在下一篇文章中,我们将:
- 🎨 深入讲解 HarmonyOS 动画系统与自定义转场动画
- 📚 掌握 animateTo、transition、animation 三大动画API的使用
- 🏷️ 在《奇妙科学乐园》详情页中实现科普知识的动画展示效果
🔗 相关链接
- 项目源码: Atomgit仓库
💡 提示: 建议结合项目源码阅读,在 Topics.ets 页面中为 TopicCard 添加 @Reusable 装饰器,通过 hilog 的 debug 日志观察组件创建次数和复用次数的变化,直观感受复用优化效果!
更多推荐



所有评论(0)