还在为每个弹窗写 CustomDialog?鸿蒙通用弹窗组件 HappyDialog 从想法到落地

源码已开源:AppCustomizationDemo/HappyDialog

做鸿蒙应用开发,弹窗这块儿是最让人头疼的重复劳动。确认框、提示框、输入框、底部操作表、隐私协议……官方 CustomDialogController 确实强大,但每个弹窗都要新建 @CustomDialog 组件、定义布局、管理控制器、处理生命周期,代码冗长且容易出错。更麻烦的是,弹窗内容需要动态变化(倒计时、进度)时,只能关闭再打开,体验差还易出 bug。

能不能用数据描述弹窗,让组件自动渲染,并且支持静态/动态双模式?这就是 HappyDialog 要解决的问题。

一、痛点:官方弹窗的“重复造轮子”

先看一段官方典型用法:

@CustomDialog
struct MyConfirmDialog {
  controller: CustomDialogController;
  title: string = '';
  content: string = '';
  onConfirm: () => void = () => {};
  build() {
    Column() {
      Text(this.title).fontSize(18).fontWeight(FontWeight.Medium)
      Text(this.content).fontSize(14).margin({ top: 10 })
      Row() {
        Button('取消').onClick(() => this.controller.close())
        Button('确认').onClick(() => { this.onConfirm(); this.controller.close(); })
      }
      .justifyContent(FlexAlign.SpaceAround)
      .width('100%')
    }
    .padding(24)
    .width('80%')
  }
}
// 调用处
let dialogController = new CustomDialogController({ builder: MyConfirmDialog({...}) });
dialogController.open();

每个弹窗都要重复这些步骤:

  • 新建 @CustomDialog 组件,手写布局、样式、按钮事件
  • 在页面中创建 CustomDialogController 实例
  • 手动管理 open() / close(),多个弹窗时容易重叠或内存泄漏
  • 动态内容(倒计时、进度)只能关闭重建,代码复杂且体验差
  • 样式(圆角、颜色、宽度)硬编码在组件中,深色模式适配或设计改版时逐个修改

每个页面、每种弹窗类型都在重复造轮子——这是工程化的大忌。

二、HappyDialog 设计目标

目标 实现方式
零重复代码 业务层只传递配置对象,UI 全自动生成
一次初始化,全局调用 EntryAbility 中初始化一次,任意页面都能用,弹窗实例自动管理
样式完全可配置 所有视觉属性通过 style 字段集中管理,支持全局默认 + 局部覆盖
静态 + 动态双模式 普通对象用于固定内容;可观察模型(StandardDialogModel)支持实时刷新
高扩展性 新增弹窗类型(警告框、底部操作表等)只需添加 Model 和 Builder,不污染已有代码

三、技术亮点:分层设计与响应式更新

3.1 数据驱动与可观察状态

核心思想:用数据描述弹窗,UI 根据数据自动渲染

  • 使用 @ObservedV2 + @Trace 装饰数据模型的属性,使其成为可观察状态。属性改变时,依赖该属性的 UI 自动重新渲染。
  • 数据模型 StandardDialogModel 包含所有可变内容:标题、内容、按钮数组、按钮排列方向、按钮高度、取消/确认按钮颜色等。
  • 按钮数组中的每个按钮也是可观察对象ButtonItemModel 类使用 @ObservedV2@Trace 装饰其 textcolor 属性,因此直接修改 model.buttons[0].text = '新文字' 即可触发 UI 刷新,无需整体替换数组。

3.2 动态 UI 绑定:mutableBuilder

wrapBuilder 只能静态封装 @Builder,无法在运行时切换。而 mutableBuilder(API 22+)返回的 MutableBuilder 对象支持动态替换 @Builder

DialogComponent 根据弹窗类型(STANDARDALERTBOTTOM_SHEET)选择不同的 UI 构建器,同时保持对数据模型的引用,实现响应式刷新。

3.3 数据与样式职责分离

为了避免歧义,将字段明确分为两类:

类型 存储位置 是否支持动态刷新 示例
动态字段 StandardDialogModel 中,用 @Trace ✅ 运行时修改立即刷新 title, content, buttons, buttonDirection, buttonHeight
静态样式 style 对象(StandardStyle ❌ 不支持属性级动态更新,但可整体替换 width, cornerRadius, maskColor, titleColor, contentColor

这样弹窗的行为和外观互不干扰,开发者可以灵活组合。

四、快速上手

4.1 安装与初始化

"dependencies": {
  "@happy/dialog": "file:./happy_dialog"
}

EntryAbility 中初始化一次:

import { HappyDialog } from '@happy/dialog';

export default class EntryAbility extends UIAbility {
  onCreate() {
    HappyDialog.init(this.context);   // 仅需一次
  }
}

4.2 静态弹窗(最常用)

// 最简单的提示,单个按钮(相当于 Alert)
HappyDialog.showStandard({
  title: '提示',
  content: '操作成功',
  buttons: [{ text: '知道了', onClick: () => console.log('关闭') }]
});
// 带取消/确认的双按钮弹窗,自定义样式
HappyDialog.showStandard({
  title: '删除确认',
  content: '此操作不可恢复,确定删除吗?',
  buttons: [
    { text: '取消', color: '#8A8F93', onClick: () => console.log('取消') },
    { text: '删除', color: '#FF3B30', onClick: () => console.log('删除') }
  ],
  buttonDirection: 'column',     // 按钮上下排列
  buttonHeight: 52,
  style: {
    width: '90%',
    cornerRadius: 24,
    titleColor: '#FF3B30'        // 覆盖默认标题颜色
  }
});

4.3 动态弹窗(内容实时刷新)

创建 StandardDialogModel 实例,之后修改其 @Trace 属性,弹窗 UI 会自动更新。下面演示一个倒计时弹窗,按钮文字每秒变化

import { HappyDialog, StandardDialogModel } from '@happy/dialog';

const model = new StandardDialogModel({
  title: '倒计时演示',
  content: '5 秒后自动关闭',
  buttons: [{ text: '5s', onClick: () => {} }]  // 初始按钮文字
});

HappyDialog.showStandard(model);

let seconds = 5;
const timer = setInterval(() => {
  if (seconds === 0) {
    clearInterval(timer);
    model.content = '倒计时结束';
    model.buttons[0].text = '知道了';   // ✅ 直接修改按钮文字,UI 自动刷新!
  } else {
    model.content = `${seconds} 秒后关闭`;
    model.buttons[0].text = `${seconds}s`;  // ✅ 每秒更新按钮文字
    seconds--;
  }
}, 1000);

效果:弹窗显示后,内容每秒更新,按钮文字从 “5s” → “4s” → … → “0s” → “知道了”,整个过程无需手动刷新或重建弹窗。

💡 动态更新原理buttons 数组中的每个元素都是 ButtonItemModel 实例,其 textcolor@Trace 装饰,因此直接修改属性即可触发 UI 重新渲染。

4.4 运行效果

五、核心代码解读

5.1 分层架构

┌─────────────────────────────────────────────────┐
│ 调用层:HappyDialog.showStandard(data)          │
└─────────────────────────────────────────────────┘
                        │
┌─────────────────────────────────────────────────┐
│ 入口层:HappyDialog(静态方法,全局单例)        │
└─────────────────────────────────────────────────┘
                        │
┌─────────────────────────────────────────────────┐
│ 视图模型层:DialogViewModel(管理弹窗生命周期)   │
└─────────────────────────────────────────────────┘
                        │
┌─────────────────────────────────────────────────┐
│ 视图层:DialogComponent + Builder(UI 渲染)     │
└─────────────────────────────────────────────────┘
                        │
┌─────────────────────────────────────────────────┐
│ 模型层:BaseDialog, StandardDialogModel(数据)  │
└─────────────────────────────────────────────────┘

5.2 可观察按钮模型

// model/ButtonItemModel.ets
@ObservedV2
export class ButtonItemModel implements ButtonItem {
  @Trace text: ResourceStr;
  @Trace color?: ResourceColor;
  onClick?: () => void;

  constructor(init: ButtonItem) {
    this.text = init.text;
    this.color = init.color;
    this.onClick = init.onClick;
  }
}

5.3 基础样式接口(所有弹窗共用)

// interface/BaseStyle.ets
export interface BaseStyle {
  alignment?: DialogAlignment;
  maskColor?: ResourceColor;
  autoCancel?: boolean;
  isModal?: boolean;
  width?: Length;
  cornerRadius?: number;
  contentPadding?: number;
}

5.4 标准弹窗样式接口(继承基础样式)

// interface/standard/StandardStyle.ets
export interface StandardStyle extends BaseStyle {
  titleColor?: ResourceColor;
  contentColor?: ResourceColor;
}

5.5 标准弹窗数据接口

// interface/standard/StandardData.ets
export interface StandardData extends BaseDialogData {
  buttons: ButtonItem[];
  buttonDirection?: 'row' | 'column';
  buttonHeight?: number;
  cancelButtonColor?: ResourceColor;
  confirmButtonColor?: ResourceColor;
  style?: StandardStyle;
}

5.6 可观察数据模型

// model/StandardDialogModel.ets
@ObservedV2
export class StandardDialogModel extends BaseDialog implements StandardData {
  @Trace title?: ResourceStr;
  @Trace content: ResourceStr = '';
  @Trace buttons: ButtonItemModel[] = [];  // ✅ 元素是可观察的 ButtonItemModel
  @Trace buttonDirection?: 'row' | 'column' = 'row';
  @Trace buttonHeight?: number = 48;
  @Trace cancelButtonColor?: ResourceColor;
  @Trace confirmButtonColor?: ResourceColor;
  @Trace style?: StandardStyle;

  constructor(init: StandardData) {
    super(DialogType.STANDARD);
    // 将传入的普通 ButtonItem 转换为 ButtonItemModel
    this.buttons = init.buttons?.map(btn => new ButtonItemModel(btn)) ?? [];
    // ... 其他属性赋值
  }
}

5.7 容器组件:动态 Builder 绑定

// components/DialogComponent.ets
@ComponentV2
export struct DialogComponent {
  @Param @Require model: BaseDialog;
  @Local contentBuilder?: MutableBuilder<[BaseDialog]>;

  aboutToAppear() {
    switch (this.model.type) {
      case DialogType.STANDARD:
        // 使用 mutableBuilder 动态绑定标准弹窗的 UI 构建器
        this.contentBuilder = mutableBuilder(standardContentBuilder);
        break;
      // 未来可扩展其他类型
    }
  }

  build() {
    Column() {
      this.contentBuilder?.builder(this.model);
    }
    .width(this.model.style?.width)
    .backgroundColor($r('app.color.background_color'))
    .borderRadius(this.model.style?.cornerRadius ?? 16)
  }
}

5.8 UI 构建器(standardContentBuilder)

// builders/StandardContentBuilder.ets
@Builder
export function standardContentBuilder(model: StandardDialogModel) {
  Column() {
    // 内容区域:标题 + 内容
    Column() {
      if (model.title) {
        Text(model.title)
          .fontSize($r('app.float.modal_title_font_size'))
          .fontWeight(FontWeight.Medium)
          .fontColor(model.style?.titleColor ?? $r('app.color.title_color'))
      }
      Text(model.content)
        .fontSize($r('app.float.modal_content_font_size'))
        .fontColor(model.style?.contentColor ?? $r('app.color.content_color'))
    }
    .padding(model.style?.contentPadding ?? 20)

    Divider().strokeWidth(0.5)

    // 按钮区域:根据 buttonDirection 决定行/列布局
    if (model.buttonDirection === 'column') {
      Column() {
        ForEach(model.buttons, (item: ButtonItemModel, index) => {
          Button(item.text)
            .width('100%')
            .height(model.buttonHeight ?? 48)
            .backgroundColor(Color.Transparent)
            .fontColor(item.color ?? (index === 0 ? model.cancelButtonColor : model.confirmButtonColor))
            .onClick(() => { item.onClick?.(); HappyDialog.close(); })
          if (index !== model.buttons.length - 1) Divider()
        })
      }
    } else {
      Row() {
        ForEach(model.buttons, (item: ButtonItemModel, index) => {
          Button(item.text)
            .layoutWeight(1)
            .height(model.buttonHeight ?? 48)
            .backgroundColor(Color.Transparent)
            .fontColor(item.color ?? (index === 0 ? model.cancelButtonColor : model.confirmButtonColor))
            .onClick(() => { item.onClick?.(); HappyDialog.close(); })
          if (index !== model.buttons.length - 1) Divider().vertical(true)
        })
      }
    }
  }
  .backgroundColor($r('app.color.background_color'))
}

5.9 视图模型:管理弹窗生命周期

// viewmodel/DialogViewModel.ets
export class DialogViewModel {
  private currentContent: ComponentContent<object> | null = null;

  async showStandard(data: StandardData | StandardDialogModel) {
    let model = data instanceof StandardDialogModel ? data : new StandardDialogModel(data);
    // 合并默认样式与用户自定义样式
    model.style = mergeStyle({ ...DEFAULT_STANDARD_STYLE }, model.style ?? {});
    const builder = mutableBuilder(DialogBuilder);
    await this.showDialogInternal(model, builder, model.style);
  }

  private async showDialogInternal<T extends BaseDialog>(
    model: T, builder: MutableBuilder<[T]>, style: BaseStyle
  ) {
    const uiContext = await getCurrentUIContext();
    await this.close(); // 关闭前一个弹窗
    const contentNode = new ComponentContent(uiContext, builder, model);
    this.currentContent = contentNode;
    await uiContext.getPromptAction().openCustomDialog(contentNode, {
      alignment: style.alignment ?? DialogAlignment.Center,
      maskColor: style.maskColor ?? 'rgba(0,0,0,0.4)',
      autoCancel: style.autoCancel ?? false,
      isModal: style.isModal ?? true,
      // ... 生命周期回调
    });
  }

  async close() { /* 关闭当前弹窗 */ }
}

5.10 对外接口(HappyDialog)

// HappyDialog.ets
export class HappyDialog {
  static init(context: common.UIAbilityContext) {
    setAbilityContext(context);
  }
  static async showStandard(data: StandardData | StandardDialogModel) {
    await viewModel.showStandard(data);
  }
  static async close() {
    await viewModel.close();
  }
}

六、扩展新弹窗类型(以底部操作表为例)

虽然标准弹窗已覆盖多数场景,但若需增加底部操作表,只需遵循相同模式:

  1. 定义 BottomSheetData 接口(包含 title, items 等)
  2. 创建 BottomSheetDialogModel 继承 BaseDialog,用 @Trace 标记动态字段
  3. 编写 bottomSheetContentBuilder UI 构建器
  4. DialogComponentaboutToAppear 中添加 case DialogType.BOTTOM_SHEET
  5. HappyDialog 中添加 showBottomSheet 方法

核心管理逻辑完全复用,符合开闭原则。

七、总结与避坑指南

特性 说明
零重复代码 一次初始化,全局调用,弹窗只需一行数据配置
样式统一管理 所有视觉属性通过 style 集中配置,支持全局默认 + 局部覆盖
静态/动态双模式 普通对象用于简单场景,可观察模型用于实时刷新(倒计时、进度)
响应式更新 基于 @ObservedV2 + @Trace,修改数据属性即可触发 UI 刷新;按钮文字/颜色可直接修改
实例自动管理 多次调用自动关闭前一个弹窗,避免重叠和内存泄漏
高扩展性 新增弹窗类型只需添加 Model 和 Builder,无需改动核心代码

常见问题

Q:如何动态修改按钮文字或颜色?
A:直接修改 model.buttons[index].textmodel.buttons[index].color 即可,因为每个按钮都是 ButtonItemModel 可观察对象。示例:model.buttons[0].text = '新文字'

Q:为什么不支持 style 内部属性的动态更新?
A:样式通常属于静态配置,如宽度、圆角等,运行时很少改变。如果确实需要动态改变样式,可以整体替换 model.style 对象。

Q:mutableBuilder 要求的最低 API 版本?
A:mutableBuilder 从 API 22 开始支持。如果你的应用需要支持更低版本,可以提前注册所有 Builder 类型,但建议最低 API 22。

Q:如何实现全局 loading 弹窗?
A:可以创建一个没有按钮、内容为加载动画的 StandardDialogModel,并通过 @Trace 控制显示/隐藏。或者扩展一个新的 LoadingDialog 类型。

Q:为什么传入的 buttons 数组会自动变成 ButtonItemModel[]
A:StandardDialogModel 的构造函数会将普通对象转换为 ButtonItemModel 实例,确保每个按钮都具备可观察能力。如果你手动创建 StandardDialogModel 并传入 ButtonItemModel[],也会被原样保留。

八、结语

HappyDialog 的核心价值在于 数据驱动 + 样式分离,让你从繁琐的弹窗模板代码中解放出来,专注于业务逻辑。无论你是需要快速搭建一个确认框,还是实现一个带有倒计时、进度更新的复杂弹窗,只需几行配置即可完成。

鸿蒙开发,从“重复造轮子”到“专注于业务”,HappyDialog 希望能帮你迈出这一步。如果你在使用中遇到任何问题,或者有更好的想法,欢迎在评论区交流。

Logo

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

更多推荐