img

📖 引言

在上一篇文章中,我们通过 DebounceManager 和 ThrottleManager 解决了搜索输入、列表滚动、窗口尺寸变化三大高频事件的性能优化问题。然而,高频事件只是性能优化的一个维度。在《奇妙科学乐园》中,科普知识列表页、科学实验列表、测验选项列表等场景都存在大量结构相似的列表项组件。当用户快速滑动列表时,LazyForEach 会不断创建和销毁列表项组件实例,频繁的组件创建/销毁开销在大数据量下会成为新的性能瓶颈。

HarmonyOS ArkUI 框架从 API 11 开始提供了 @Reusable 装饰器,它允许开发者标记一个自定义组件为"可复用"。框架会维护一个组件复用池,当列表滚动导致某些组件移出可视区域时,这些组件不会被销毁,而是被回收放入复用池;当新的列表项进入可视区域时,框架优先从复用池中取出组件并更新其数据,而非重新创建。这种"对象池"模式与 Android RecyclerView 的 ViewHolder 复用机制异曲同工。

本文将深入讲解 @Reusable 装饰器的原理与使用方法,从基础的组件复用配置到 aboutToReuse 生命周期回调的数据更新,再到在《奇妙科学乐园》 TopicCard、实验列表项、测验选项等核心组件中的实战应用,帮助读者掌握一套完整的组件复用优化方案。

源码仓库https://atomgit.com/2301_79280419/WonderSciencePark


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解组件复用池的工作原理与适用场景
  • ✅ 掌握 @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 方法中,需要更新两类数据:
    1. @Prop 数据:通过 params 传入的新数据(如 optionTextoptionIndex
    2. @State 内部状态:组件自身管理的状态(如 currentStateisSubmitted
  • 遗漏任何一类都会导致 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 统计复用率,验证复用是否生效

现象:
使用 @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 接口能精确追踪数据变化
  • @ReusableLazyForEach 下能最大程度发挥复用效果
  • 如果数据量较小(< 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 应设置固定的 widthheight,避免图片加载后高度跳变

问题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() 方法,在 aboutToAppearaboutToReuseaboutToDisappear 中统一调用
  • 复用组件的 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的使用
  • 🏷️ 在《奇妙科学乐园》详情页中实现科普知识的动画展示效果

🔗 相关链接


💡 提示: 建议结合项目源码阅读,在 Topics.ets 页面中为 TopicCard 添加 @Reusable 装饰器,通过 hilog 的 debug 日志观察组件创建次数和复用次数的变化,直观感受复用优化效果!

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐