自定义组件设计——ReusableFlowItem 的模式复用

在这里插入图片描述

一、引言

在 HarmonyOS 应用开发中,自定义组件的设计质量直接影响代码的可维护性和运行时的性能。一个设计良好的自定义组件应具备清晰的接口定义、灵活的扩展能力和高效的运行时性能。

本文以英语学习 App 中的 ReusableFlowItem 组件为核心案例,深入探讨 @ComponentV2 组件的接口设计、@BuilderParam 实现内容插槽的模式,以及在 LazyForEach 中使用自定义组件进行性能复用的最佳实践。

二、ReusableFlowItem 组件设计

2.1 组件定义与接口设计

ReusableFlowItem 是项目中典型的列表条目组件,用于展示练习模式的各个功能入口。它使用 @ComponentV2 装饰,通过 @Param 定义输入接口:

// features/homePage/src/main/ets/pages/MainPage.ets
@ComponentV2
struct ReusableFlowItem {
  @Param item: PracticeView = new PracticeView($r('app.media.ic_home'), '顺序练习', '1、3256');

  build() {
    Row() {
      Column() {
        Text(this.item.name)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .maxLines(1)
          .fontWeight(FontWeight.Bold)
          .fontSize($r('sys.float.Body_S'))
          .fontColor($r('sys.color.font_primary'));
        Text(this.item.describe)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .maxLines(1)
          .fontSize($r('sys.float.Caption_M'))
          .fontWeight(FontWeight.Regular)
          .fontColor($r('sys.color.font_secondary'))
          .margin({ top: $r('app.float.vp_2') });
      }
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1);

      Image(this.item.imageUri)
        .interpolation(ImageInterpolation.High)
        .objectFit(ImageFit.Fill)
        .width(40)
        .height(40)
        .clip(true)
        .margin({ left: $r('app.float.vp_4'), right: $r('app.float.vp_4') });
    }
    .stateStyles({ pressed: { .scale({ x: 0.97, y: 0.97 }) } })
    .backgroundColor($r('sys.color.background_secondary'))
    .justifyContent(FlexAlign.SpaceBetween)
    .height(100).width('100%')
    .padding({ left: $r('app.float.vp_12'), right: $r('app.float.vp_16') })
    .borderRadius($r('app.float.vp_12'));
  }
}

2.2 数据模型定义

组件依赖的数据模型 PracticeView 定义在独立的 model 文件中:

// features/homePage/src/main/ets/model/PracticeMode.ets
export class PracticeView {
  imageUri: ResourceStr;   // 图标资源引用
  name: string;            // 练习名称
  describe: string;        // 练习描述

  constructor(imageUri: ResourceStr, name: string, describe: string) {
    this.imageUri = imageUri;
    this.name = name;
    this.describe = describe;
  }
}

接口设计原则

  1. 单一职责PracticeView 只承载展示所需的数据字段,不包含业务逻辑
  2. 类型明确imageUri 使用 ResourceStr 类型,限制只能传入资源引用
  3. 默认值设计@Param 提供了安全的默认值,确保组件在未传参时不会崩溃
  4. 只读语义@Param 是单向数据流,父组件修改数据会自动触发子组件刷新

三、@BuilderParam 实现内容插槽

3.1 插槽模式的设计思路

@BuilderParam 是 HarmonyOS 中实现内容插槽(Slot)机制的装饰器。它允许父组件向子组件传递一段 UI 片段,子组件在特定位置渲染这段 UI。这在需要自定义组件的局部展示内容时非常有用。

以下是一个通用的卡片容器组件,通过 @BuilderParam 接收自定义头部和内容:

@ComponentV2
struct CardContainer {
  // 使用 @BuilderParam 定义插槽,允许父组件注入自定义 UI
  @BuilderParam customHeader?: () => void;
  @BuilderParam customContent: () => void = this.defaultContent;

  // 默认内容(当父组件未传入 customContent 时使用)
  @Builder
  defaultContent() {
    Text('此处内容可自定义').fontSize(14);
  }

  build() {
    Column() {
      // 头部插槽
      if (this.customHeader) {
        this.customHeader();
      }

      Divider().margin({ top: 8, bottom: 8 });

      // 内容插槽(有默认值)
      this.customContent();
    }
    .padding(16)
    .borderRadius(16)
    .backgroundColor($r('sys.color.background_primary'))
    .shadow(ShadowStyle.OUTER_DEFAULT_MD);
  }
}

3.2 插槽模式的使用

父组件通过闭包语法向子组件注入 UI 片段:

@Entry
@ComponentV2
struct ParentPage {
  @Builder
  myHeaderBuilder() {
    Row() {
      Text('今日推荐').fontSize(18).fontWeight(FontWeight.Bold);
      Blank();
      Text('更多 ›').fontSize(13).fontColor($r('sys.color.font_tertiary'));
    }
    .width('100%');
  }

  @Builder
  myContentBuilder() {
    Column({ space: 8 }) {
      Text('CET-4 核心词汇').fontSize(15);
      Progress({ value: 120, total: 300, type: ProgressType.Linear })
        .color('#165DFF').height(6);
      Text('已完成 120/300').fontSize(12)
        .fontColor($r('sys.color.font_secondary'));
    }
    .width('100%');
  }

  build() {
    Column() {
      // 传入自定义头部和内容
      CardContainer({
        customHeader: (): void => this.myHeaderBuilder(),
        customContent: (): void => this.myContentBuilder(),
      });
    }
    .padding(16)
    .width('100%')
    .height('100%');
  }
}

3.3 插槽与 @Prop/@Param 的选择

决策条件 使用 @Param 使用 @BuilderParam
传入简单数据 ✅ 字符串、数字等 ❌ 不适合
传入 UI 片段 ❌ 无法传递 ✅ 自定义布局
子组件内部定义样式 ✅ 父传子数据 ❌ 通常由外部定义
需要默认 UI ✅ 通过默认值 ✅ 通过默认 Builder

实用建议:当子组件的布局结构固定、只是数据变化时,使用 @Param 传递数据模型。当子组件需要在某块区域展示完全不同的布局时,使用 @BuilderParam 实现插槽。

四、性能复用:LazyForEach 中的组件复用

4.1 数据源实现

ReusableFlowItem 配合 LazyForEach 使用,后者要求实现 IDataSource 接口。项目中定义了 PracticeDataSource 作为数据源:

// features/homePage/src/main/ets/model/PracticeMode.ets
const PRACTICE_LIST_DATA: PracticeView[] = [
  new PracticeView($r('app.media.ic_sequence'), '单词记忆', '每日单词打卡'),
  new PracticeView($r('app.media.ic_practice_simulations'), '听力训练', '沉浸式听力练习'),
  new PracticeView($r('app.media.ic_practice_test_paper'), '阅读训练', '英文原著阅读'),
  new PracticeView($r('app.media.ic_wrong_question'), '语法练习', '语法专项突破'),
];

export class PracticeDataSource implements IDataSource {
  private practiceData: PracticeView[] = [];
  private dataListeners: DataChangeListener[] = [];

  constructor(practiceData: PracticeView[]) {
    for (let i = 0; i < practiceData.length; i++) {
      this.practiceData.push(practiceData[i]);
    }
  }

  public getData(index: number): PracticeView {
    return this.practiceData[index];
  }

  public totalCount(): number {
    return this.practiceData.length;
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.dataListeners.indexOf(listener) < 0) {
      this.dataListeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.dataListeners.indexOf(listener);
    if (pos >= 0) {
      this.dataListeners.splice(pos, 1);
    }
  }

  notifyDataReload(): void {
    this.dataListeners.forEach(listener => { listener.onDataReloaded(); });
  }

  notifyDataAdd(index: number): void {
    this.dataListeners.forEach(listener => { listener.onDataAdd(index); });
  }

  notifyDataChange(index: number): void {
    this.dataListeners.forEach(listener => { listener.onDataChange(index); });
  }

  notifyDataDelete(index: number): void {
    this.dataListeners.forEach(listener => { listener.onDataDelete(index); });
  }

  notifyDataMove(from: number, to: number): void {
    this.dataListeners.forEach(listener => { listener.onDataMove(from, to); });
  }
}

4.2 LazyForEach 中的组件复用

在首页的练习模式区域,ReusableFlowItemLazyForEach 中被高效复用:

// features/homePage/src/main/ets/pages/MainPage.ets
@ComponentV2
export struct HomePage {
  private listData: PracticeView[] = PRACTICE_LIST_DATA;
  private dataSource: PracticeDataSource = new PracticeDataSource(this.listData);

  build() {
    // ...
    Grid() {
      LazyForEach(this.dataSource, (practiceItem: PracticeView) => {
        GridItem() {
          ReusableFlowItem({ item: practiceItem });
        }
        .onClick(() => {
          // 根据练习类型路由到不同页面
          if (practiceItem.name === '单词记忆') {
            RouterModule.push({ url: RouterMap.ANSWER_QUESTIONS_PAGE, param: '1' });
          } else if (practiceItem.name === '听力训练') {
            RouterModule.push({ url: RouterMap.Mock_PAGE, param: 1 });
          } else if (practiceItem.name === '阅读训练') {
            RouterModule.push({ url: RouterMap.Mock_PAGE, param: 2 });
          } else {
            RouterModule.push({ url: RouterMap.ANSWER_QUESTIONS_PAGE, param: '5' });
          }
        });
      }, (practiceItem: PracticeView, index: number) => practiceItem.name + index);
    }
    .columnsTemplate('1fr 1fr')
    .rowsGap($r('app.float.vp_12'))
    .columnsGap($r('app.float.vp_12'))
    .padding($r('app.float.vp_12'))
    .backgroundColor($r('sys.color.background_primary'))
    .borderRadius($r('app.float.vp_16'));
    // ...
  }
}

4.3 动态 vs 静态数据源选择

特性 LazyForEach + IDataSource ForEach
渲染策略 按需渲染可见项 一次性渲染全部
数据量适应 适合中大型列表(>30 项) 适合小型列表(<30 项)
更新通知 细粒度(增删改移) 整体刷新
组件复用 自动复用已回收组件 不涉及复用

选择建议

  • 练习模式只有 4 个条目,使用 ForEach 也可。项目之所以选择 LazyForEach + Grid``,是为了演示可扩展性——未来增加更多练习模式时无需重构代码
  • 在单词列表、错题列表等数据量可能较大的场景,必须使用 LazyForEach 以确保流畅性

4.4 组件键值(key)的重要性

LazyForEach 的第三个参数是一个键值生成函数:

(practiceItem: PracticeView, index: number) => practiceItem.name + index

这个键值用于框架识别列表项的唯一性,影响以下行为:

  1. 复用判定:相同键值的组件会被复用而非重建
  2. 动画过渡:键值稳定的项目在列表重新排序时可以应用过渡动画
  3. 状态保持@Local 装饰的组件本地状态会与键值绑定

键值设计原则

  • 使用稳定的唯一标识(如数据库 ID),而非仅用索引
  • 如果数据没有唯一 ID,使用 name + index 或类似组合
  • 避免使用随机数或时间戳作为键值

五、组件复用的完整模式

5.1 通用组件模板

总结 ReusableFlowItem 模式,提炼出通用的可复用组件设计模板:

@ComponentV2
export struct ReusableTemplate<T> {
  // 1. 数据接口:使用 @Param 定义输入
  @Param data: T;
  // 2. 插槽接口:使用 @BuilderParam 支持自定义
  @BuilderParam customSlot?: () => void;

  build() {
    // 3. 统一容器样式
    Row() {
      // 左侧内容区
      Column() {
        // 数据驱动的文本展示
      }
      // 右侧图标区
      Image(this.data.icon)
    }
    // 4. 统一的交互反馈
    .stateStyles({ pressed: { .scale({ x: 0.97, y: 0.97 }) } })
    // 5. 统一的视觉样式
    .borderRadius($r('app.float.vp_12'))
    .backgroundColor($r('sys.color.background_secondary'));
  }
}

5.2 代码组织建议

  • 数据模型(如 PracticeView):放在 model/ 目录
  • 数据源实现(如 PracticeDataSource):放在 model/viewModel/ 目录
  • 组件实现(如 ReusableFlowItem):放在 pages/components/ 目录
  • 常量数据(如 PRACTICE_LIST_DATA):定义在数据模型同文件

六、总结

ReusableFlowItem 的设计充分体现了 HarmonyOS 自定义组件的核心模式:

  1. 接口清晰:通过 @Param 定义类型安全的输入接口,通过 @BuilderParam 支持内容插槽
  2. 视觉统一:统一的圆角、阴影、背景色和按压反馈
  3. 性能高效:配合 LazyForEach 实现按需渲染和组件复用
  4. 扩展灵活:数据模型独立,添加新功能只需新增一条数据

这种"数据模型 + 统一组件 + 懒加载"的复用模式,是构建中大型 HarmonyOS 应用的推荐实践。从 ReusableFlowItem 出发,可以将类似的模式应用到列表、卡片、表单等各种场景,大幅提升开发效率和代码质量。

Logo

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

更多推荐