📖 鸿蒙NEXT开发实战系列 | 第33篇 | 实战篇 🎯 适合人群:有ArkUI基础的开发者 ⏰ 阅读时间:约15分钟 | 💻 开发环境:DevEco Studio 5.0+


上一篇32-ArkUI动画效果进阶 | 下一篇34-自定义组件与页面路由


前言

在日常的鸿蒙应用开发中,你是否经常遇到这样的情况:同一个按钮样式在多个页面重复出现,卡片布局的代码被复制粘贴了十几次,弹窗组件的逻辑散落在各个页面中。这些重复的代码不仅增加了维护成本,还容易导致样式不一致的问题。

自定义组件就是解决这些问题的利器。通过将UI和逻辑封装成可复用的组件,我们可以:

  • 减少代码重复,提高开发效率

  • 统一组件样式,保持界面一致性

  • 降低耦合度,便于维护和扩展

  • 实现组件级别的状态管理

本文将从最基础的@Component装饰器开始,逐步讲解@Builder函数、组件通信机制,最后通过3个实战案例手把手教你如何封装可复用的UI组件。


目录


一、@Component装饰器详解

1.1 什么是@Component

@Component是ArkUI中用于定义自定义组件的装饰器。被@Component装饰的struct会成为一个可复用的UI组件,拥有自己的状态管理和生命周期。

1.2 基础语法

@Component
struct MyComponent {
  // 组件内部状态
  @State message: string = 'Hello';

  // 组件生命周期
  aboutToAppear() {
    console.log('组件即将出现');
  }

  aboutToDisappear() {
    console.log('组件即将消失');
  }

  // 必须包含build方法,描述组件的UI结构
  build() {
    Column() {
      Text(this.message)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
    }
    .width('100%')
    .padding(16)
  }
}

1.3 组件的生命周期

生命周期

说明

常见用途

aboutToAppear()

组件即将出现时调用

初始化数据、订阅事件

aboutToDisappear()

组件即将销毁时调用

清理资源、取消订阅

onDidBuild()

组件build完成后调用

获取组件尺寸等

1.4 组件的使用

定义好的组件可以直接在其他组件的build()方法中使用:

@Entry
@Component
struct Index {
  build() {
    Column() {
      // 使用自定义组件
      MyComponent()
      MyComponent() // 可以多次复用
    }
  }
}

二、@Builder函数用法

2.1 什么是@Builder

@Builder是ArkUI提供的轻量级UI复用机制,它可以将一段UI描述封装成一个函数,在需要的地方调用。与@Component不同的是,@Builder函数不创建新的组件实例,而是直接嵌入到当前组件的UI树中。

2.2 全局Builder函数

使用@Builder装饰的全局函数,可以在任何组件中调用:

// 定义全局Builder函数
@Builder function IconText(icon: Resource, text: string) {
  Row({ space: 8 }) {
    Image(icon)
      .width(24)
      .height(24)
    Text(text)
      .fontSize(16)
      .fontColor('#333333')
  }
}

// 使用全局Builder函数
@Entry
@Component
struct Index {
  build() {
    Column() {
      IconText($r('app.media.ic_home'), '首页')
      IconText($r('app.media.ic_setting'), '设置')
    }
  }
}

2.3 组件内Builder函数

在组件内部定义的Builder函数,可以访问组件的状态:

@Component
struct UserCard {
  @State userName: string = '张三'
  @State userAge: number = 25

  // 组件内Builder函数,可以访问组件状态
  @Builder UserInfoRow(label: string, value: string) {
    Row() {
      Text(label)
        .fontSize(14)
        .fontColor('#666666')
        .width(80)
      Text(value)
        .fontSize(14)
        .fontColor('#333333')
    }
    .padding(8)
  }

  build() {
    Column() {
      this.UserInfoRow('姓名', this.userName)
      this.UserInfoRow('年龄', this.userAge.toString())
    }
  }
}

2.4 @Builder与@Component的区别

特性

@Component

@Builder

是否创建新组件实例

是否有独立状态

否(共享调用者状态)

是否有生命周期

适用场景

复杂的独立组件

轻量级UI复用片段


三、组件通信机制

3.1 @Prop - 单向数据流

@Prop装饰的变量允许父组件向子组件传递数据,子组件可以修改@Prop变量,但修改不会同步回父组件。

// 子组件
@Component
struct ChildComponent {
  @Prop title: string = ''

  build() {
    Text(this.title)
      .fontSize(18)
  }
}

// 父组件
@Entry
@Component
struct ParentComponent {
  @State parentTitle: string = '父组件标题'

  build() {
    Column() {
      // 传递数据给子组件
      ChildComponent({ title: this.parentTitle })
    }
  }
}

@Link装饰的变量实现父子组件之间的双向数据同步:

// 子组件
@Component
struct SwitchComponent {
  @Link isOn: boolean

  build() {
    Toggle({ type: ToggleType.Switch, isOn: this.isOn })
      .onChange((isOn: boolean) => {
        this.isOn = isOn
      })
  }
}

// 父组件
@Entry
@Component
struct ParentComponent {
  @State switchState: boolean = false

  build() {
    Column() {
      Text(`开关状态: ${this.switchState ? '开' : '关'}`)
      // 使用$符号传递引用
      SwitchComponent({ isOn: $switchState })
    }
  }
}

3.3 事件回调

通过回调函数实现子组件向父组件传递数据:

// 子组件
@Component
struct InputComponent {
  @State inputText: string = ''
  onTextChange: (text: string) => void = () => {}

  build() {
    TextInput({ text: this.inputText })
      .onChange((value: string) => {
        this.inputText = value
        this.onTextChange(value) // 通知父组件
      })
  }
}

// 父组件
@Entry
@Component
struct ParentComponent {
  @State receivedText: string = ''

  build() {
    Column() {
      Text(`输入内容: ${this.receivedText}`)
      InputComponent({
        onTextChange: (text: string) => {
          this.receivedText = text
        }
      })
    }
  }
}

四、样式定制与主题适配

4.1 通过参数定制样式

// 支持多种样式参数的组件
@Component
struct CustomText {
  @Prop content: string = ''
  @Prop fontSize: number = 16
  @Prop fontColor: string = '#333333'
  @Prop fontWeight: FontWeight = FontWeight.Normal

  build() {
    Text(this.content)
      .fontSize(this.fontSize)
      .fontColor(this.fontColor)
      .fontWeight(this.fontWeight)
  }
}

// 使用示例
CustomText({ content: '标题', fontSize: 24, fontWeight: FontWeight.Bold })
CustomText({ content: '正文', fontSize: 16, fontColor: '#666666' })

4.2 主题色适配

// 主题管理类
class AppTheme {
  static primaryColor: string = '#007DFF'
  static backgroundColor: string = '#FFFFFF'
  static textColor: string = '#333333'
  static secondaryTextColor: string = '#666666'
}

// 使用主题色的组件
@Component
struct ThemedButton {
  @Prop text: string = ''
  @State isPressed: boolean = false

  build() {
    Button(this.text)
      .backgroundColor(this.isPressed ? '#005FC3' : AppTheme.primaryColor)
      .fontColor('#FFFFFF')
      .borderRadius(8)
      .height(44)
      .onTouch((event: TouchEvent) => {
        if (event.type === TouchType.Down) {
          this.isPressed = true
        } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
          this.isPressed = false
        }
      })
  }
}

五、实战案例一:自定义按钮组件

5.1 需求分析

封装一个功能完整的自定义按钮组件,支持:

  • 多种样式类型(主要、次要、文本按钮)

  • 自定义尺寸

  • 加载状态

  • 点击事件回调

5.2 完整代码

// CustomButton.ets
// 按钮类型枚举
enum ButtonType {
  Primary,    // 主要按钮
  Secondary,  // 次要按钮
  Text        // 文本按钮
}

// 按钮尺寸枚举
enum ButtonSize {
  Small,
  Medium,
  Large
}

@Component
export struct CustomButton {
  @Prop text: string = ''
  @Prop type: ButtonType = ButtonType.Primary
  @Prop size: ButtonSize = ButtonSize.Medium
  @Prop loading: boolean = false
  @Prop disabled: boolean = false
  onClick: () => void = () => {}

  // 根据类型获取背景色
  private getBackgroundColor(): string {
    if (this.disabled) return '#CCCCCC'
    switch (this.type) {
      case ButtonType.Primary: return '#007DFF'
      case ButtonType.Secondary: return '#F5F5F5'
      case ButtonType.Text: return 'transparent'
    }
  }

  // 根据类型获取文字颜色
  private getFontColor(): string {
    if (this.disabled) return '#999999'
    switch (this.type) {
      case ButtonType.Primary: return '#FFFFFF'
      case ButtonType.Secondary: return '#007DFF'
      case ButtonType.Text: return '#007DFF'
    }
  }

  // 根据尺寸获取高度
  private getHeight(): number {
    switch (this.size) {
      case ButtonSize.Small: return 32
      case ButtonSize.Medium: return 44
      case ButtonSize.Large: return 56
    }
  }

  // 根据尺寸获取字体大小
  private getFontSize(): number {
    switch (this.size) {
      case ButtonSize.Small: return 12
      case ButtonSize.Medium: return 14
      case ButtonSize.Large: return 16
    }
  }

  build() {
    Button(this.loading ? '加载中...' : this.text)
      .width('100%')
      .height(this.getHeight())
      .fontSize(this.getFontSize())
      .backgroundColor(this.getBackgroundColor())
      .fontColor(this.getFontColor())
      .borderRadius(8)
      .enabled(!this.disabled && !this.loading)
      .onClick(() => {
        if (!this.disabled && !this.loading) {
          this.onClick()
        }
      })
  }
}

// 使用示例
@Entry
@Component
struct ButtonDemo {
  build() {
    Column({ space: 16 }) {
      // 主要按钮
      CustomButton({
        text: '主要按钮',
        type: ButtonType.Primary,
        onClick: () => {
          console.log('点击了主要按钮')
        }
      })

      // 次要按钮
      CustomButton({
        text: '次要按钮',
        type: ButtonType.Secondary
      })

      // 禁用状态
      CustomButton({
        text: '禁用按钮',
        disabled: true
      })

      // 加载状态
      CustomButton({
        text: '提交',
        loading: true
      })
    }
    .padding(16)
    .width('100%')
  }
}

六、实战案例二:自定义卡片组件

6.1 需求分析

封装一个通用的信息卡片组件,支持:

  • 标题和副标题

  • 图片展示

  • 自定义内容插槽

  • 点击事件

6.2 完整代码

// CustomCard.ets
@Component
export struct CustomCard {
  @Prop title: string = ''
  @Prop subtitle: string = ''
  @Prop imageUrl: string = ''
  @Prop showImage: boolean = true
  onCardClick: () => void = () => {}

  // 使用@Builder定义内容插槽
  @Builder ContentSlot() {
    // 默认内容,可被覆盖
    Text('卡片内容区域')
      .fontSize(14)
      .fontColor('#666666')
  }

  build() {
    Column() {
      // 图片区域
      if (this.showImage && this.imageUrl) {
        Image(this.imageUrl)
          .width('100%')
          .height(180)
          .objectFit(ImageFit.Cover)
          .borderRadius({ topLeft: 12, topRight: 12 })
      }

      // 内容区域
      Column({ space: 8 }) {
        // 标题行
        Row() {
          Column({ space: 4 }) {
            Text(this.title)
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')

            if (this.subtitle) {
              Text(this.subtitle)
                .fontSize(14)
                .fontColor('#999999')
            }
          }
          .layoutWeight(1)

          Image($r('app.media.ic_more'))
            .width(24)
            .height(24)
            .fillColor('#999999')
        }
        .width('100%')

        // 插槽内容
        this.ContentSlot()
      }
      .padding(16)
      .width('100%')
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({
      radius: 8,
      color: 'rgba(0, 0, 0, 0.1)',
      offsetX: 0,
      offsetY: 2
    })
    .clip(true)
    .onClick(() => {
      this.onCardClick()
    })
  }
}

// 使用示例
@Entry
@Component
struct CardDemo {
  build() {
    Column({ space: 16 }) {
      // 基础卡片
      CustomCard({
        title: '新闻标题',
        subtitle: '2024-01-15',
        imageUrl: 'https://example.com/image.jpg',
        onCardClick: () => {
          console.log('点击了卡片')
        }
      })

      // 无图片卡片
      CustomCard({
        title: '系统通知',
        subtitle: '您有3条未读消息',
        showImage: false
      })
    }
    .padding(16)
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

6.3 使用Builder函数扩展卡片内容

// 继承并扩展卡片组件
@Component
struct ArticleCard {
  @Prop title: string = ''
  @Prop summary: string = ''
  @Prop author: string = ''
  @Prop readCount: number = 0

  @Builder ContentSlot() {
    Column({ space: 8 }) {
      Text(this.summary)
        .fontSize(14)
        .fontColor('#666666')
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      Row({ space: 16 }) {
        Row({ space: 4 }) {
          Image($r('app.media.ic_person'))
            .width(16)
            .height(16)
          Text(this.author)
            .fontSize(12)
            .fontColor('#999999')
        }

        Row({ space: 4 }) {
          Image($r('app.media.ic_eye'))
            .width(16)
            .height(16)
          Text(`${this.readCount} 阅读`)
            .fontSize(12)
            .fontColor('#999999')
        }
      }
    }
  }

  build() {
    // 复用CustomCard组件
    CustomCard({
      title: this.title,
      showImage: false,
      ContentSlot: () => {
        this.ContentSlot()
      }
    })
  }
}

七、实战案例三:自定义弹窗组件

7.1 需求分析

封装一个功能完整的弹窗组件,支持:

  • 标题和内容

  • 自定义按钮

  • 遮罩层点击关闭

  • 弹出/关闭动画

7.2 完整代码

// CustomDialog.ets
@Component
export struct CustomDialogComponent {
  @Prop visible: boolean = false
  @Prop title: string = '提示'
  @Prop content: string = ''
  @Prop showCancel: boolean = true
  @Prop confirmText: string = '确定'
  @Prop cancelText: string = '取消'
  @State dialogScale: number = 0
  @State overlayOpacity: number = 0

  onConfirm: () => void = () => {}
  onCancel: () => void = () => {}
  onClose: () => void = () => {}

  // 监听visible变化,触发动画
  aboutToAppear() {
    if (this.visible) {
      this.showAnimation()
    }
  }

  // 显示动画
  private showAnimation() {
    animateTo({
      duration: 200,
      curve: Curve.EaseOut
    }, () => {
      this.dialogScale = 1
      this.overlayOpacity = 0.5
    })
  }

  // 关闭动画
  private hideAnimation() {
    animateTo({
      duration: 150,
      curve: Curve.EaseIn
    }, () => {
      this.dialogScale = 0
      this.overlayOpacity = 0
    })
  }

  build() {
    if (this.visible) {
      Stack() {
        // 遮罩层
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('#000000')
          .opacity(this.overlayOpacity)
          .onClick(() => {
            this.hideAnimation()
            this.onClose()
          })

        // 弹窗主体
        Column({ space: 20 }) {
          // 标题
          Text(this.title)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')

          // 内容
          Text(this.content)
            .fontSize(16)
            .fontColor('#666666')
            .textAlign(TextAlign.Center)
            .padding({ left: 16, right: 16 })

          // 分割线
          Divider()
            .color('#EEEEEE')

          // 按钮区域
          Row() {
            if (this.showCancel) {
              // 取消按钮
              Text(this.cancelText)
                .fontSize(16)
                .fontColor('#666666')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .height(48)
                .onClick(() => {
                  this.hideAnimation()
                  this.onCancel()
                })
            }

            // 确定按钮
            Text(this.confirmText)
              .fontSize(16)
              .fontColor('#007DFF')
              .fontWeight(FontWeight.Medium)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .height(48)
              .onClick(() => {
                this.hideAnimation()
                this.onConfirm()
              })
          }
          .width('100%')
        }
        .width(280)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .padding({ top: 24, bottom: 0 })
        .scale({ x: this.dialogScale, y: this.dialogScale })
      }
      .width('100%')
      .height('100%')
    }
  }
}

// 使用示例
@Entry
@Component
struct DialogDemo {
  @State showDialog: boolean = false

  build() {
    Stack() {
      Column() {
        Button('显示弹窗')
          .onClick(() => {
            this.showDialog = true
          })
      }
      .width('100%')
      .height('100%')

      // 弹窗组件
      CustomDialogComponent({
        visible: this.showDialog,
        title: '确认删除',
        content: '确定要删除这条记录吗?删除后无法恢复。',
        showCancel: true,
        confirmText: '确认删除',
        cancelText: '再想想',
        onConfirm: () => {
          console.log('点击了确认')
          this.showDialog = false
        },
        onCancel: () => {
          console.log('点击了取消')
          this.showDialog = false
        },
        onClose: () => {
          this.showDialog = false
        }
      })
    }
    .width('100%')
    .height('100%')
  }
}

八、总结与最佳实践

8.1 组件封装原则

  1. 单一职责:每个组件只负责一个功能

  2. 可配置性:通过Props提供足够的定制能力

  3. 可复用性:避免硬编码,使用参数化设计

  4. 类型安全:使用枚举定义固定的选项值

8.2 性能优化建议

  1. 合理使用@State@Prop,避免不必要的重渲染

  2. 对于复杂组件,使用@Builder函数拆分UI片段

  3. 避免在build()方法中执行耗时操作

  4. 使用@Watch监听状态变化时注意防抖

8.3 何时使用@Component vs @Builder

场景

推荐方案

需要独立状态管理

@Component

复杂的生命周期逻辑

@Component

简单的UI片段复用

@Builder

需要访问父组件状态

@Builder

需要多次实例化

@Component

8.4 项目组织建议

src/main/ets/
├── components/          # 公共组件目录
│   ├── CustomButton.ets
│   ├── CustomCard.ets
│   ├── CustomDialog.ets
│   └── index.ets        # 统一导出
├── builders/            # Builder函数目录
│   └── CommonBuilders.ets
├── pages/               # 页面目录
└── resources/           # 资源文件

系列文章推荐


参考资料


标签自定义组件 @Component @Builder 鸿蒙开发 组件封装 ArkUI HarmonyOS NEXT


本文为鸿蒙NEXT开发实战系列第33篇,持续更新中。如有问题或建议,欢迎在评论区留言交流!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐