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

还在为每个弹窗写 CustomDialog?鸿蒙通用弹窗组件 HappyDialog 从想法到落地 还在为每个弹窗写 CustomDialog鸿蒙通用弹窗组件 HappyDialog 从想法到落地源码已开源AppCustomizationDemo/HappyDialog做鸿蒙应用开发弹窗这块儿是最让人头疼的重复劳动。确认框、提示框、输入框、底部操作表、隐私协议……官方CustomDialogController确实强大但每个弹窗都要新建CustomDialog组件、定义布局、管理控制器、处理生命周期代码冗长且容易出错。更麻烦的是弹窗内容需要动态变化倒计时、进度时只能关闭再打开体验差还易出 bug。能不能用数据描述弹窗让组件自动渲染并且支持静态/动态双模式这就是HappyDialog要解决的问题。一、痛点官方弹窗的“重复造轮子”先看一段官方典型用法CustomDialogstruct 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%)}}// 调用处letdialogControllernewCustomDialogController({builder:MyConfirmDialog({...})});dialogController.open();每个弹窗都要重复这些步骤新建CustomDialog组件手写布局、样式、按钮事件在页面中创建CustomDialogController实例手动管理open()/close()多个弹窗时容易重叠或内存泄漏动态内容倒计时、进度只能关闭重建代码复杂且体验差样式圆角、颜色、宽度硬编码在组件中深色模式适配或设计改版时逐个修改每个页面、每种弹窗类型都在重复造轮子——这是工程化的大忌。二、HappyDialog 设计目标目标实现方式零重复代码业务层只传递配置对象UI 全自动生成一次初始化全局调用EntryAbility中初始化一次任意页面都能用弹窗实例自动管理样式完全可配置所有视觉属性通过style字段集中管理支持全局默认 局部覆盖静态 动态双模式普通对象用于固定内容可观察模型StandardDialogModel支持实时刷新高扩展性新增弹窗类型警告框、底部操作表等只需添加 Model 和 Builder不污染已有代码三、技术亮点分层设计与响应式更新3.1 数据驱动与可观察状态核心思想用数据描述弹窗UI 根据数据自动渲染。使用ObservedV2Trace装饰数据模型的属性使其成为可观察状态。属性改变时依赖该属性的 UI 自动重新渲染。数据模型StandardDialogModel包含所有可变内容标题、内容、按钮数组、按钮排列方向、按钮高度、取消/确认按钮颜色等。按钮数组中的每个按钮也是可观察对象ButtonItemModel类使用ObservedV2和Trace装饰其text和color属性因此直接修改model.buttons[0].text 新文字即可触发 UI 刷新无需整体替换数组。3.2 动态 UI 绑定mutableBuilderwrapBuilder只能静态封装Builder无法在运行时切换。而mutableBuilderAPI 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}fromhappy/dialog;exportdefaultclassEntryAbilityextendsUIAbility{onCreate(){HappyDialog.init(this.context);// 仅需一次}}4.2 静态弹窗最常用// 最简单的提示单个按钮相当于 AlertHappyDialog.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}fromhappy/dialog;constmodelnewStandardDialogModel({title:倒计时演示,content:5 秒后自动关闭,buttons:[{text:5s,onClick:(){}}]// 初始按钮文字});HappyDialog.showStandard(model);letseconds5;consttimersetInterval((){if(seconds0){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 BuilderUI 渲染 │ └─────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────┐ │ 模型层BaseDialog, StandardDialogModel数据 │ └─────────────────────────────────────────────────┘5.2 可观察按钮模型// model/ButtonItemModel.etsObservedV2exportclassButtonItemModelimplementsButtonItem{Tracetext:ResourceStr;Tracecolor?:ResourceColor;onClick?:()void;constructor(init:ButtonItem){this.textinit.text;this.colorinit.color;this.onClickinit.onClick;}}5.3 基础样式接口所有弹窗共用// interface/BaseStyle.etsexportinterfaceBaseStyle{alignment?:DialogAlignment;maskColor?:ResourceColor;autoCancel?:boolean;isModal?:boolean;width?:Length;cornerRadius?:number;contentPadding?:number;}5.4 标准弹窗样式接口继承基础样式// interface/standard/StandardStyle.etsexportinterfaceStandardStyleextendsBaseStyle{titleColor?:ResourceColor;contentColor?:ResourceColor;}5.5 标准弹窗数据接口// interface/standard/StandardData.etsexportinterfaceStandardDataextendsBaseDialogData{buttons:ButtonItem[];buttonDirection?:row|column;buttonHeight?:number;cancelButtonColor?:ResourceColor;confirmButtonColor?:ResourceColor;style?:StandardStyle;}5.6 可观察数据模型// model/StandardDialogModel.etsObservedV2exportclassStandardDialogModelextendsBaseDialogimplementsStandardData{Tracetitle?:ResourceStr;Tracecontent:ResourceStr;Tracebuttons:ButtonItemModel[][];// ✅ 元素是可观察的 ButtonItemModelTracebuttonDirection?:row|columnrow;TracebuttonHeight?:number48;TracecancelButtonColor?:ResourceColor;TraceconfirmButtonColor?:ResourceColor;Tracestyle?:StandardStyle;constructor(init:StandardData){super(DialogType.STANDARD);// 将传入的普通 ButtonItem 转换为 ButtonItemModelthis.buttonsinit.buttons?.map(btnnewButtonItemModel(btn))??[];// ... 其他属性赋值}}5.7 容器组件动态 Builder 绑定// components/DialogComponent.etsComponentV2exportstruct DialogComponent{ParamRequiremodel:BaseDialog;LocalcontentBuilder?:MutableBuilder[BaseDialog];aboutToAppear(){switch(this.model.type){caseDialogType.STANDARD:// 使用 mutableBuilder 动态绑定标准弹窗的 UI 构建器this.contentBuildermutableBuilder(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.etsBuilderexportfunctionstandardContentBuilder(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.buttonDirectioncolumn){Column(){ForEach(model.buttons,(item:ButtonItemModel,index){Button(item.text).width(100%).height(model.buttonHeight??48).backgroundColor(Color.Transparent).fontColor(item.color??(index0?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??(index0?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.etsexportclassDialogViewModel{privatecurrentContent:ComponentContentobject|nullnull;asyncshowStandard(data:StandardData|StandardDialogModel){letmodeldatainstanceofStandardDialogModel?data:newStandardDialogModel(data);// 合并默认样式与用户自定义样式model.stylemergeStyle({...DEFAULT_STANDARD_STYLE},model.style??{});constbuildermutableBuilder(DialogBuilder);awaitthis.showDialogInternal(model,builder,model.style);}privateasyncshowDialogInternalTextendsBaseDialog(model:T,builder:MutableBuilder[T],style:BaseStyle){constuiContextawaitgetCurrentUIContext();awaitthis.close();// 关闭前一个弹窗constcontentNodenewComponentContent(uiContext,builder,model);this.currentContentcontentNode;awaituiContext.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,// ... 生命周期回调});}asyncclose(){/* 关闭当前弹窗 */}}5.10 对外接口HappyDialog// HappyDialog.etsexportclassHappyDialog{staticinit(context:common.UIAbilityContext){setAbilityContext(context);}staticasyncshowStandard(data:StandardData|StandardDialogModel){awaitviewModel.showStandard(data);}staticasyncclose(){awaitviewModel.close();}}六、扩展新弹窗类型以底部操作表为例虽然标准弹窗已覆盖多数场景但若需增加底部操作表只需遵循相同模式定义BottomSheetData接口包含title,items等创建BottomSheetDialogModel继承BaseDialog用Trace标记动态字段编写bottomSheetContentBuilderUI 构建器在DialogComponent的aboutToAppear中添加case DialogType.BOTTOM_SHEET在HappyDialog中添加showBottomSheet方法核心管理逻辑完全复用符合开闭原则。七、总结与避坑指南特性说明零重复代码一次初始化全局调用弹窗只需一行数据配置样式统一管理所有视觉属性通过style集中配置支持全局默认 局部覆盖静态/动态双模式普通对象用于简单场景可观察模型用于实时刷新倒计时、进度响应式更新基于ObservedV2Trace修改数据属性即可触发 UI 刷新按钮文字/颜色可直接修改实例自动管理多次调用自动关闭前一个弹窗避免重叠和内存泄漏高扩展性新增弹窗类型只需添加 Model 和 Builder无需改动核心代码常见问题Q如何动态修改按钮文字或颜色A直接修改model.buttons[index].text或model.buttons[index].color即可因为每个按钮都是ButtonItemModel可观察对象。示例model.buttons[0].text 新文字。Q为什么不支持style内部属性的动态更新A样式通常属于静态配置如宽度、圆角等运行时很少改变。如果确实需要动态改变样式可以整体替换model.style对象。QmutableBuilder要求的最低 API 版本AmutableBuilder从 API 22 开始支持。如果你的应用需要支持更低版本可以提前注册所有 Builder 类型但建议最低 API 22。Q如何实现全局 loading 弹窗A可以创建一个没有按钮、内容为加载动画的StandardDialogModel并通过Trace控制显示/隐藏。或者扩展一个新的LoadingDialog类型。Q为什么传入的buttons数组会自动变成ButtonItemModel[]AStandardDialogModel的构造函数会将普通对象转换为ButtonItemModel实例确保每个按钮都具备可观察能力。如果你手动创建StandardDialogModel并传入ButtonItemModel[]也会被原样保留。八、结语HappyDialog 的核心价值在于数据驱动 样式分离让你从繁琐的弹窗模板代码中解放出来专注于业务逻辑。无论你是需要快速搭建一个确认框还是实现一个带有倒计时、进度更新的复杂弹窗只需几行配置即可完成。鸿蒙开发从“重复造轮子”到“专注于业务”HappyDialog 希望能帮你迈出这一步。如果你在使用中遇到任何问题或者有更好的想法欢迎在评论区交流。