还在为每个弹窗写 CustomDialog?鸿蒙通用弹窗组件 HappyDialog 从想法到落地
还在为每个弹窗写 CustomDialog?鸿蒙通用弹窗组件 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装饰其text和color属性,因此直接修改model.buttons[0].text = '新文字'即可触发 UI 刷新,无需整体替换数组。
3.2 动态 UI 绑定:mutableBuilder
wrapBuilder 只能静态封装 @Builder,无法在运行时切换。而 mutableBuilder(API 22+)返回的 MutableBuilder 对象支持动态替换 @Builder。
DialogComponent 根据弹窗类型(STANDARD、ALERT、BOTTOM_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实例,其text和color被@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();
}
}
六、扩展新弹窗类型(以底部操作表为例)
虽然标准弹窗已覆盖多数场景,但若需增加底部操作表,只需遵循相同模式:
- 定义
BottomSheetData接口(包含title,items等) - 创建
BottomSheetDialogModel继承BaseDialog,用@Trace标记动态字段 - 编写
bottomSheetContentBuilderUI 构建器 - 在
DialogComponent的aboutToAppear中添加case DialogType.BOTTOM_SHEET - 在
HappyDialog中添加showBottomSheet方法
核心管理逻辑完全复用,符合开闭原则。
七、总结与避坑指南
| 特性 | 说明 |
|---|---|
| 零重复代码 | 一次初始化,全局调用,弹窗只需一行数据配置 |
| 样式统一管理 | 所有视觉属性通过 style 集中配置,支持全局默认 + 局部覆盖 |
| 静态/动态双模式 | 普通对象用于简单场景,可观察模型用于实时刷新(倒计时、进度) |
| 响应式更新 | 基于 @ObservedV2 + @Trace,修改数据属性即可触发 UI 刷新;按钮文字/颜色可直接修改 |
| 实例自动管理 | 多次调用自动关闭前一个弹窗,避免重叠和内存泄漏 |
| 高扩展性 | 新增弹窗类型只需添加 Model 和 Builder,无需改动核心代码 |
常见问题
Q:如何动态修改按钮文字或颜色?
A:直接修改 model.buttons[index].text 或 model.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 希望能帮你迈出这一步。如果你在使用中遇到任何问题,或者有更好的想法,欢迎在评论区交流。
更多推荐

所有评论(0)