HarmonyOS 应用开发《掌上英语》第24篇-自定义组件设计ReusableFlowItem模式复用
自定义组件设计——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;
}
}
接口设计原则:
- 单一职责:
PracticeView只承载展示所需的数据字段,不包含业务逻辑 - 类型明确:
imageUri使用ResourceStr类型,限制只能传入资源引用 - 默认值设计:
@Param提供了安全的默认值,确保组件在未传参时不会崩溃 - 只读语义:
@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 中的组件复用
在首页的练习模式区域,ReusableFlowItem 在 LazyForEach 中被高效复用:
// 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
这个键值用于框架识别列表项的唯一性,影响以下行为:
- 复用判定:相同键值的组件会被复用而非重建
- 动画过渡:键值稳定的项目在列表重新排序时可以应用过渡动画
- 状态保持:
@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 自定义组件的核心模式:
- 接口清晰:通过
@Param定义类型安全的输入接口,通过@BuilderParam支持内容插槽 - 视觉统一:统一的圆角、阴影、背景色和按压反馈
- 性能高效:配合
LazyForEach实现按需渲染和组件复用 - 扩展灵活:数据模型独立,添加新功能只需新增一条数据
这种"数据模型 + 统一组件 + 懒加载"的复用模式,是构建中大型 HarmonyOS 应用的推荐实践。从 ReusableFlowItem 出发,可以将类似的模式应用到列表、卡片、表单等各种场景,大幅提升开发效率和代码质量。
更多推荐



所有评论(0)