ArkUI自定义组件开发指南:从封装到复用的全流程实战 原创
头像 巴拉巴拉~~ 2025-12-15 11:59:29    发布
28732 浏览 782 点赞 0 收藏

引言

在HarmonyOS ArkUI开发中,自定义组件是提升开发效率、保证代码一致性的核心手段。原生组件虽能满足基础需求,但面对复杂业务场景(如自定义表单、个性化列表项)时,需通过组件封装实现复用。很多开发者在封装自定义组件时,常遇到参数设计不合理、事件回调混乱、复用性差等问题。本文从组件封装原则出发,通过“下拉刷新组件”和“表单录入组件”两个实战案例,详解自定义组件的设计、开发、复用与扩展全流程,帮助开发者打造高质量可复用组件。

一、ArkUI自定义组件核心设计原则

好的自定义组件需满足“高内聚、低耦合、可复用、易扩展”四大原则,具体落地为以下四点:

  1. 单一职责原则:一个组件只负责一个核心功能,如下拉刷新组件仅处理刷新逻辑,不包含列表展示逻辑;
  2. 参数化配置:通过入参控制组件样式和行为,如允许配置刷新图标、刷新文案、触发阈值;
  3. 事件回调清晰:通过回调函数向外暴露组件内部事件,如刷新开始、刷新结束事件;
  4. 样式隔离:组件内部样式不影响外部,外部可通过入参覆盖核心样式。

二、实战一:通用下拉刷新组件封装

2.1 需求分析

封装一款通用下拉刷新组件,满足以下需求:1. 支持下拉触发刷新;2. 可配置刷新图标、文案(下拉中/刷新中/刷新完成);3. 支持自定义刷新触发阈值;4. 暴露刷新开始和结束回调。

2.2 组件设计

  • 入参设计: refreshIcon:刷新图标资源(默认使用系统图标);
  • pullText:下拉文案(默认“下拉刷新”);
  • loadingText:刷新中文案(默认“刷新中...”);
  • successText:刷新完成文案(默认“刷新完成”);
  • triggerHeight:触发刷新的阈值高度(默认80px);
  • onRefresh:刷新触发回调(必填,业务层实现刷新逻辑)。
  • 内部状态
           pullDistance:下拉距离;
  • refreshState:刷新状态(初始/下拉中/刷新中/刷新完成);
  • isRefreshing:是否正在刷新。
  • 对外方法
           finishRefresh():结束刷新,更新状态为“刷新完成”后恢复初始状态。

2.3 代码实现

import router from '@ohos.router';

// 刷新状态枚举
export enum RefreshState {
  INIT = 0, // 初始状态
  PULLING = 1, // 下拉中
  REFRESHING = 2, // 刷新中
  SUCCESS = 3 // 刷新完成
}

// 组件入参接口
export interface PullRefreshProps {
  refreshIcon?: Resource; // 刷新图标
  pullText?: string; // 下拉文案
  loadingText?: string; // 刷新中文案
  successText?: string; // 刷新完成文案
  triggerHeight?: number; // 触发阈值
  onRefresh: () => void; // 刷新回调
}

@Component
export struct PullRefresh {
  // 入参(设置默认值)
  private props: PullRefreshProps = {
    refreshIcon: $r('app.media.refresh_icon'),
    pullText: '下拉刷新',
    loadingText: '刷新中...',
    successText: '刷新完成',
    triggerHeight: 80,
    onRefresh: () => {}
  };
  // 内部状态
  @State pullDistance: number = 0;
  @State refreshState: RefreshState = RefreshState.INIT;
  private isRefreshing: boolean = false;
  // 动画控制器(用于刷新完成后回弹)
  private animationController: AnimationController = new AnimationController({
    duration: 300,
    curve: Curve.EaseOut
  });

  // 组件构造函数(接收外部入参)
  constructor(props: PullRefreshProps) {
    Object.assign(this.props, props);
  }

  // 下拉事件处理
  private onTouchMove(event: TouchEvent) {
    // 正在刷新时不处理下拉
    if (this.isRefreshing) return;
    // 获取下拉距离(仅处理垂直方向)
    const deltaY = event.touches[0].y - event.touches[0].startY;
    if (deltaY > 0) { // 仅处理下拉
      this.pullDistance = Math.min(deltaY, this.props.triggerHeight * 1.5); // 限制最大下拉距离
      // 根据下拉距离更新状态
      if (this.pullDistance < this.props.triggerHeight) {
        this.refreshState = RefreshState.PULLING;
      } else {
        this.refreshState = RefreshState.REFRESHING;
      }
    }
  }

  // 松手事件处理
  private onTouchEnd() {
    if (this.isRefreshing) return;
    // 下拉距离达到阈值,触发刷新
    if (this.pullDistance >= this.props.triggerHeight) {
      this.isRefreshing = true;
      this.refreshState = RefreshState.REFRESHING;
      // 调用外部刷新回调
      this.props.onRefresh();
    } else {
      // 未达到阈值,回弹到初始状态
      this.resetPullState();
    }
  }

  // 结束刷新
  finishRefresh() {
    if (!this.isRefreshing) return;
    this.refreshState = RefreshState.SUCCESS;
    this.isRefreshing = false;
    // 显示成功文案1秒后回弹
    setTimeout(() => {
      this.resetPullState();
    }, 1000);
  }

  // 重置下拉状态
  private resetPullState() {
    // 执行回弹动画
    Animation.create(this.animationController)
      .onFrame(() => {
        this.pullDistance = this.pullDistance * 0.8;
        if (this.pullDistance < 1) {
          this.pullDistance = 0;
          this.refreshState = RefreshState.INIT;
          this.animationController.stop();
        }
      })
      .play();
  }

  // 获取当前显示的文案
  private getDisplayText(): string {
    switch (this.refreshState) {
      case RefreshState.PULLING:
        return this.props.pullText!;
      case RefreshState.REFRESHING:
        return this.props.loadingText!;
      case RefreshState.SUCCESS:
        return this.props.successText!;
      default:
        return '';
    }
  }

  build() {
    // 层叠布局:刷新头部 + 内容区域
    Stack() {
      // 内容区域(通过slot接收外部内容)
      Slot()
        .offset({ y: this.pullDistance }); // 随下拉距离偏移

      // 刷新头部
      Column({ space: 8, alignItems: ItemAlign.Center })
        .height(this.pullDistance > 0 ? this.pullDistance : 0)
        .justifyContent(FlexAlign.End)
        .padding({ bottom: 10 }) {
        // 刷新图标(刷新中时旋转)
        Image(this.props.refreshIcon)
          .width(30)
          .height(30)
          .rotate({
            angle: this.refreshState === RefreshState.REFRESHING ? 360 : 0,
            centerX: '50%',
            centerY: '50%'
          })
          .animation({
            duration: 1000,
            curve: Curve.Linear,
            iterations: this.refreshState === RefreshState.REFRESHING ? Infinity : 1
          });

        // 刷新文案
        Text(this.getDisplayText())
          .fontSize(14)
          .color('#666666');
      }
    }
    // 绑定触摸事件
    .onTouch((event) => {
      if (event.type === TouchType.MOVE) {
        this.onTouchMove(event);
      } else if (event.type === TouchType.UP || event.type === TouchType.CANCEL) {
        this.onTouchEnd();
      }
    })
  }
}

2.4 组件使用示例

import { PullRefresh } from '../components/PullRefresh';

@Entry
@Component
struct RefreshDemoPage {
  private pullRefresh: PullRefresh | null = null;
  @State dataList: string[] = ['数据1', '数据2', '数据3'];

  // 模拟刷新数据
  async fetchData() {
    // 模拟网络请求耗时
    await new Promise(resolve => setTimeout(resolve, 2000));
    // 更新数据
    this.dataList = [...this.dataList, `新数据${this.dataList.length + 1}`];
    // 通知组件刷新完成
    this.pullRefresh?.finishRefresh();
  }

  build() {
    Column() {
      Text('下拉刷新示例')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 30, left: 20 })
        .alignSelf(ItemAlign.Start);

      // 使用自定义下拉刷新组件
      PullRefresh({
        pullText: '下拉即可刷新',
        loadingText: '正在加载...',
        triggerHeight: 100,
        onRefresh: () => this.fetchData()
      })
        .ref(ref => this.pullRefresh = ref) // 获取组件实例
        .width('100%')
        .flexGrow(1) {
        // 内容区域:列表
        List({ space: 10 }) {
          ForEach(this.dataList, (item) => {
            ListItem() {
              Text(item)
                .fontSize(18)
                .padding(20)
                .backgroundColor('#ffffff')
                .borderRadius(12);
            }
          }, (item) => item);
        }
        .padding({ left: 20, right: 20, top: 20 });
      }
    }
    .width('100%')
    .backgroundColor('#f5f5f5')
  }
}

三、实战二:表单录入组件封装(高复用场景)

3.1 需求分析

表单是应用开发的高频场景,封装一款通用表单组件,支持输入框、选择器、开关等表单项,满足以下需求:1. 支持多种表单类型配置;2. 表单数据联动(如选择“其他”后显示输入框);3. 表单验证与错误提示;4. 一键获取表单数据。

3.2 组件实现(核心代码)

// 表单项类型枚举
export enum FormItemType {
  INPUT = 'input', // 输入框
  SELECT = 'select', // 选择器
  SWITCH = 'switch' // 开关
}

// 表单项配置接口
export interface FormItemConfig {
  id: string; // 表单项唯一标识
  label: string; // 标签文本
  type: FormItemType; // 类型
  placeholder?: string; // 输入框占位符
  options?: Array<{ label: string; value: string }>; // 选择器选项
  defaultValue?: string | boolean; // 默认值
  required?: boolean; // 是否必填
  validator?: (value: any) => string | boolean; // 自定义验证函数
  showCondition?: (formData: Record<string, any>) => boolean; // 显示条件(数据联动)
}

// 表单组件入参接口
export interface FormProps {
  items: FormItemConfig[]; // 表单项配置
  onSubmit?: (data: Record<string, any>) => void; // 提交回调
}

@Component
export struct UniversalForm {
  private props: FormProps;
  // 表单数据存储
  @State formData: Record<string, any> = {};
  // 错误信息存储
  @State errorMsg: Record<string, string> = {};

  constructor(props: FormProps) {
    this.props = props;
    // 初始化表单数据(设置默认值)
    this.initFormData();
  }

  // 初始化表单数据
  private initFormData() {
    this.props.items.forEach(item => {
      if (item.defaultValue !== undefined) {
        this.formData[item.id] = item.defaultValue;
      } else {
        this.formData[item.id] = item.type === FormItemType.SWITCH ? false : '';
      }
    });
  }

  // 表单验证
  private validateForm(): boolean {
    let isValid = true;
    this.errorMsg = {};
    this.props.items.forEach(item => {
      const value = this.formData[item.id];
      // 必填项验证
      if (item.required && (value === '' || value === undefined || value === null)) {
        this.errorMsg[item.id] = `${item.label}不能为空`;
        isValid = false;
        return;
      }
      // 自定义验证
      if (item.validator) {
        const result = item.validator(value);
        if (typeof result === 'string') {
          this.errorMsg[item.id] = result;
          isValid = false;
        }
      }
    });
    return isValid;
  }

  // 提交表单
  submitForm() {
    if (this.validateForm()) {
      this.props.onSubmit?.(this.formData);
    }
  }

  // 获取表单数据
  getFormData(): Record<string, any> {
    return { ...this.formData };
  }

  // 渲染单个表单项
  @Builder
  renderFormItem(item: FormItemConfig) {
    // 根据显示条件判断是否渲染
    if (item.showCondition && !item.showCondition(this.formData)) {
      return;
    }

    Column({ space: 8 }) {
      // 标签
      Row({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center }) {
        Text(item.label)
          .fontSize(18)
          .fontWeight(Medium);
        if (item.required) {
          Text('*')
            .fontSize(18)
            .color('#ff3b30');
        }
      }

      // 根据类型渲染不同表单项
      if (item.type === FormItemType.INPUT) {
        TextField({
          placeholder: item.placeholder || `请输入${item.label}`,
          text: this.formData[item.id] + ''
        })
          .width('100%')
          .height(45)
          .border({ width: 1, color: '#cccccc', radius: 8 })
          .padding({ left: 15 })
          .onChange((value) => {
            this.formData[item.id] = value;
            // 输入时清除错误提示
            delete this.errorMsg[item.id];
          });
      } else if (item.type === FormItemType.SELECT) {
        Select(item.options?.map(opt => opt.label) || [])
          .width('100%')
          .height(45)
          .border({ width: 1, color: '#cccccc', radius: 8 })
          .padding({ left: 15 })
          .onChange((index) => {
            const value = item.options?.[index].value;
            this.formData[item.id] = value;
            delete this.errorMsg[item.id];
          });
      } else if (item.type === FormItemType.SWITCH) {
        Switch({
          checked: this.formData[item.id] as boolean
        })
          .onChange((isChecked) => {
            this.formData[item.id] = isChecked;
            delete this.errorMsg[item.id];
          });
      }

      // 错误提示
      if (this.errorMsg[item.id]) {
        Text(this.errorMsg[item.id])
          .fontSize(14)
          .color('#ff3b30')
          .alignSelf(ItemAlign.Start);
      }
    }
    .margin({ bottom: 20 });
  }

  build() {
    Column({ space: 20 }) {
      // 渲染所有表单项
      ForEach(this.props.items, (item) => {
        this.renderFormItem(item);
      }, (item) => item.id);

      // 提交按钮
      Button('提交')
        .width('90%')
        .height(50)
        .backgroundColor('#007aff')
        .fontColor('#ffffff')
        .borderRadius(25)
        .onClick(() => this.submitForm());
    }
    .padding({ left: 20, right: 20, top: 30 });
  }
}

3.3 组件使用示例(用户信息表单)

import { UniversalForm, FormItemType } from '../components/UniversalForm';

@Entry
@Component
struct UserFormPage {
  // 表单配置
  private formConfig = [
    {
      id: 'name',
      label: '姓名',
      type: FormItemType.INPUT,
      placeholder: '请输入姓名',
      required: true
    },
    {
      id: 'gender',
      label: '性别',
      type: FormItemType.SELECT,
      options: [
        { label: '男', value: 'male' },
        { label: '女', value: 'female' },
        { label: '其他', value: 'other' }
      ],
      required: true
    },
    {
      id: 'otherGender',
      label: '其他性别',
      type: FormItemType.INPUT,
      placeholder: '请输入性别',
      // 显示条件:性别选择“其他”时显示
      showCondition: (formData) => formData.gender === 'other'
    },
    {
      id: 'phone',
      label: '手机号',
      type: FormItemType.INPUT,
      placeholder: '请输入手机号',
      required: true,
      // 自定义验证:手机号格式
      validator: (value) => {
        const reg = /^1[3-9]\d{9}$/;
        return reg.test(value) ? true : '手机号格式不正确';
      }
    },
    {
      id: 'agree',
      label: '同意用户协议',
      type: FormItemType.SWITCH,
      required: true,
      validator: (value) => value ? true : '请同意用户协议'
    }
  ];

  // 表单提交处理
  handleSubmit(data: Record<string, any>) {
    console.log('表单数据:', JSON.stringify(data));
    // 提交到服务器逻辑...
  }

  build() {
    Column() {
      Text('用户信息录入')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 30, left: 20 })
        .alignSelf(ItemAlign.Start);

      // 使用通用表单组件
      UniversalForm({
        items: this.formConfig,
        onSubmit: (data) => this.handleSubmit(data)
      })
        .flexGrow(1);
    }
    .width('100%')
    .backgroundColor('#f5f5f5')
  }
}

四、自定义组件复用与扩展技巧

4.1 组件复用策略

  • 抽离公共组件库:将通用组件(如下拉刷新、表单、弹窗)抽离到独立的组件库模块,多个项目可通过依赖引入;
  • 配置化驱动:通过JSON配置实现组件行为定制,减少代码修改;
  • 组合复用:复杂组件由多个简单组件组合而成,如“表单组件”由“输入框组件”“选择器组件”组合。

4.2 组件扩展技巧

  • 插槽(Slot)扩展:通过Slot暴露组件内部节点,允许外部自定义部分UI,如下拉刷新组件的内容区域通过Slot传入;
  • 继承扩展:基于现有组件继承,重写部分方法实现扩展,如基于“输入框组件”扩展“验证码输入组件”;
  • 事件扩展:通过多回调函数暴露更多事件,如表单组件暴露“输入变化”“验证失败”等事件。

五、总结

本文通过两个实战案例,详解了ArkUI自定义组件的设计与开发流程。好的自定义组件需在设计阶段明确入参、状态和回调,遵循单一职责原则,通过参数化配置和事件回调提升复用性。下拉刷新组件聚焦交互逻辑封装,表单组件聚焦配置化和数据联动,两种封装思路可覆盖大部分业务场景。

开发者在实际开发中,可根据业务复杂度选择合适的封装粒度,简单场景可封装为基础组件,复杂场景可封装为复合组件,同时注重组件的可测试性和文档编写,提升团队协作效率。


©本站发布的所有内容,包括但不限于文字、图片、音频、视频、图表、标志、标识、广告、商标、商号、域名、软件、程序等,除特别标明外,均来源于网络或用户投稿,版权归原作者或原出处所有。我们致力于保护原作者版权,若涉及版权问题,请及时联系我们进行处理。
分类
HarmonyOS
地址:北京市朝阳区北三环东路三元桥曙光西里甲1号第三置业A座1508室 商务内容合作QQ:2291221 电话:13391790444或(010)62178877
版权所有:电脑商情信息服务集团 北京赢邦策略咨询有限责任公司
声明:本媒体部分图片、文章来源于网络,版权归原作者所有,我司致力于保护作者版权,如有侵权,请与我司联系删除
京ICP备:2022009079号-2
京公网安备:11010502051901号
ICP证:京B2-20230255