鸿蒙开发里但凡是个正经项目,就绕不开信息提示框这个东西。最近我在HarmonyOS6项目里把全站的CustomDialog弹窗统一做了一轮封装,效果比预想好很多,今天把完整方案整理出来。
这套方案核心解决的是“弹窗散落在各个页面”的维护问题。以前每个页面要么复制一份CustomDialog代码,要么自己用Text叠一个假弹窗,改一次样式全工程跟着遭殃。封装之后,业务方只需要一行代码就能弹出标准的信息提示框,普通提示、二次确认、带输入框、加载中这四种场景全覆盖,样式和交互逻辑收敛到一个组件里。适合正在做鸿蒙应用开发、想把基础组件沉淀下来的团队,也适合刚接触CustomDialog、想搞清楚封装思路的个人开发者。
1. 弹窗多到失控时,我决定把CustomDialog封装起来
1.1 业务里的信息提示框远比你想象的多
我在好几个鸿蒙项目里观察过,信息提示框不是“偶尔用一下”的组件,而是业务里出现频率最高的交互元素。删除一条数据要确认框,保存成功要提示框,版本有更新要走升级弹窗,下载进度要弹进度框,输入分组名称要弹输入框,网络请求挂起要弹加载框。算下来一个中型App里至少有七八种弹窗场景,而且这些场景分散在不同模块、不同页面、甚至不同包下面。
最麻烦的是,每个开发同学写弹窗的习惯都不一样。有人用CustomDialog老老实实声明controller,有人直接用bindSheet模拟底部弹窗,还有人在错误提示时用全局toast蒙混过关。时间一长,弹窗的视觉风格、按钮位置、圆角大小、遮罩透明度全都不一样,测试验收时光统一弹窗样式就能提一长串bug单。
这时候就需要一个统一的信息提示框组件。我决定在HarmonyOS6项目里做一次彻底收口,把散落在各处的弹窗逻辑全部收敛到一个公共组件里,对外只暴露几个静态方法。
1.2 不封装的代价:代码爆炸与样式失控
先算一笔账。假设项目里有20个页面需要弹确认框,每个页面直接写CustomDialog,平均一个弹窗要写60行左右的声明代码,包含@CustomDialog装饰器、CustomDialogController实例、按钮回调、取消回调。20个页面就是1200行高度重复的代码,这里面还不含弹窗内部View的布局代码。
更难受的是改样式。有一次产品要求把确认按钮的主色从蓝色改成品牌绿色,我全局搜按钮背景色,一上午改了十几个文件,改完之后还有两处漏网之鱼。这就是典型的“重复代码债务”——你复制的是逻辑,承担的是维护成本,而且这个成本会随着页面数量线性增长。
封装之后,弹窗View只有一份,按钮颜色、圆角、字体粗细都写在同一个文件里。产品再要改样式,改一处就全局生效。这才是组件化该有的样子。
1.3 封装目标:一次挂载,全局调用
我给自己定的封装目标很简单:
- 弹窗在页面里只挂载一次,业务方不需要感知CustomDialogController的存在
- 对外暴露静态方法,一行代码弹出,回调直接传递业务逻辑
- 覆盖alert、confirm、input、loading四种场景,后续可以继续扩展
- 多个弹窗同时触发时能有序处理,不出现叠层和状态错乱
这四个目标里,“业务方不感知controller”是最关键的。很多封装方案做了一半,业务方仍然要自己在页面里new Controller,在我看来这是没封装的——真正的封装应该是业务方完全不需要知道弹窗组件内部怎么实现,他只需要关心“我要弹什么内容、点按钮之后干什么”。
2. 动手前必须搞懂的CustomDialog底层机制
2.1 @CustomDialog装饰器与CustomDialogController的关系
HarmonyOS里的CustomDialog不是随手调一个API就能弹出来的,它由两部分组成:一部分是你用@CustomDialog装饰器声明的自定义弹窗组件,另一部分是负责控制它的CustomDialogController实例。
@CustomDialog struct MyDialog { controller?: CustomDialogController; build() { Column() { Text('这是一个自定义弹窗') Button('关闭') .onClick(() => { this.controller?.close(); }) } } }弹窗组件通过controller?.close()关闭自身,这是一个关键设计。弹窗不能自己把自己关掉,必须借助外部传入的controller实例。而CustomDialogController则是在页面组件里创建的:
@Entry @Component struct Index { dialogController: CustomDialogController = new CustomDialogController({ builder: MyDialog(), autoCancel: true, alignment: DialogAlignment.Center }); build() { Button('弹出') .onClick(() => { this.dialogController.open(); }) } }这里能明显看出问题:CustomDialogController的生命周期和页面组件强绑定,页面销毁时controller也会失效。如果业务方想在一个工具类里直接new CustomDialogController,再在任意位置调用,往往会出现controller失效或者弹窗位置错乱的诡异问题。
2.2 为什么不能靠全局变量直接控制弹窗
有人会说,那我不搞controller,直接用AppStorage存一个isShow字段,弹窗组件用@StorageLink监听这个字段,isShow变true就显示弹窗。这个思路方向是对的,但不能直接套在CustomDialog上。
原因是CustomDialog组件本身不是普通组件,它必须由controller驱动open和close。试图用if (isShow)条件渲染一个@CustomDialog组件是行不通的——CustomDialog装饰的组件不直接参与页面UI树渲染,它走的是系统对话框通道。
所以封装方案必须两头都顾到:既要有全局状态记录“现在该显示什么弹窗”,又要有一个真正持有controller的宿主组件负责执行open和close。这就是我最终采用“全局状态驱动 + 页面内挂载宿主”方案的底层原因。
2.3 我选的技术方案:全局状态驱动弹窗
我的最终方案可以概括为三句话:
- 用一个全局配置对象承载弹窗的所有信息,包括类型、标题、内容、按钮文字、回调函数
- 在页面根部挂载一个CommonDialog宿主组件,它负责监听全局配置,并持有CustomDialogController的实例
- 对外暴露DialogManager工具类,业务方调用静态方法修改全局配置,宿主组件感知到变化后自动open或close
这个方案的好处是弹窗的控制权完全收口在宿主组件内部,业务方不需要在页面里创建任何controller,也不需要关心CustomDialog的生命周期。全局配置对象就是业务方和弹窗组件之间的唯一契约。
3. 封装实现:CommonDialog组件完整代码
3.1 先定义一份弹窗数据模型
任何封装的第一步都是定义数据结构。弹窗的所有可变内容都应该体现在配置对象里,而不是散落在多个独立变量中。
// DialogTypes.ets export type DialogType = 'alert' | 'confirm' | 'input' | 'loading'; export interface DialogConfig { // 是否显示弹窗 isShow: boolean; // 弹窗类型 type: DialogType; // 标题 title?: string; // 正文内容 message?: string; // 确认按钮文字 confirmText?: string; // 取消按钮文字 cancelText?: string; // 输入框占位符 inputPlaceholder?: string; // 输入框默认值 inputDefaultValue?: string; // 确认回调,输入框会传入用户输入值 onConfirm?: (value?: string) => void; // 取消回调 onCancel?: () => void; } export const defaultDialogConfig: DialogConfig = { isShow: false, type: 'alert', title: '提示', message: '', confirmText: '确定', cancelText: '取消', inputPlaceholder: '', inputDefaultValue: '', onConfirm: undefined, onCancel: undefined };设计要点在于,onConfirm回调的参数是可选的string。普通提示框不需要参数,输入框需要把用户输入的内容回传给业务方,这样用一个回调就能兼容两种场景,不需要为输入框单独定义一套配置。
defaultDialogConfig导出一个默认对象,是为了让宿主角色的@StorageLink初始化时有值可读,避免空指针。AppStorage的初始赋值建议放在EntryAbility的onCreate阶段,或者模块加载时执行。
3.2 CommonDialogBuilder:弹窗内部视图
弹窗视图我用一个@CustomDialog装饰的组件来实现,它负责根据DialogConfig中的type字段渲染对应的UI结构。
// CommonDialogBuilder.ets @CustomDialog export struct CommonDialogBuilder { @StorageLink('common_dialog_config') dialogConfig: DialogConfig = defaultDialogConfig; @State inputValue: string = ''; controller?: CustomDialogController; aboutToAppear(): void { // 每次弹窗即将显示时,把默认值同步到输入框 this.inputValue = this.dialogConfig.inputDefaultValue || ''; } build() { Column({ space: 12 }) { if (this.dialogConfig.type === 'loading') { // 加载框:只需要加载动画和提示文字 LoadingProgress() .width(48) .height(48) .color('#007DFF') if (this.dialogConfig.message) { Text(this.dialogConfig.message) .fontSize(14) .fontColor('#666666') } } else { // 标题 Text(this.dialogConfig.title || '提示') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#333333') // 内容 if (this.dialogConfig.message) { Text(this.dialogConfig.message) .fontSize(14) .fontColor('#666666') .textAlign(TextAlign.Center) .lineHeight(20) } // 输入框 if (this.dialogConfig.type === 'input') { TextInput({ placeholder: this.dialogConfig.inputPlaceholder, text: this.inputValue }) .height(44) .margin({ top: 4 }) .onChange((value: string) => { this.inputValue = value; }) } // 按钮区域 Row({ space: 12 }) { if (this.dialogConfig.type === 'confirm' || this.dialogConfig.type === 'input') { Button(this.dialogConfig.cancelText || '取消') .layoutWeight(1) .height(40) .backgroundColor('#FFFFFF') .fontColor('#333333') .border({ width: 1, color: '#E5E5E5' }) .onClick(() => { this.onCancelClick(); }) } Button(this.dialogConfig.confirmText || '确定') .layoutWeight(1) .height(40) .backgroundColor('#007DFF') .fontColor('#FFFFFF') .onClick(() => { this.onConfirmClick(); }) } .margin({ top: 8 }) } } .padding(20) .width('80%') .borderRadius(16) .backgroundColor('#FFFFFF') } }这里有几个关键的实现细节。
第一,弹窗组件内部通过@StorageLink直接读取全局配置,不需要外部传参。这样组件可以自主刷新,配置变化时UI自动更新。
第二,aboutToAppear里同步输入框默认值。因为CustomDialogController.open()每次触发aboutToAppear,所以每次弹窗打开时输入框都会重置为用户预设的默认值。这个细节很容易被忽略,如果不重置,上一次的输入内容会残留。
第三,点击按钮时不能直接调用controller.close()就完事,还要把全局配置的isShow改回false。如果只关弹窗不更新全局状态,下一次业务方再次调用show方法时,isShow已经是true,宿主组件检测不到变化,弹窗就弹不出来了。
3.3 CommonDialog宿主组件:接收全局配置
宿主组件是弹窗机制的中枢,它负责监听全局配置变化,并执行CustomDialogController的open和close。
// CommonDialog.ets @Component export struct CommonDialog { @StorageLink('common_dialog_config') dialogConfig: DialogConfig = defaultDialogConfig; private controller: CustomDialogController | null = null; aboutToAppear(): void { this.controller = new CustomDialogController({ builder: CommonDialogBuilder(), autoCancel: false, alignment: DialogAlignment.Center, customStyle: false }); } @Watch('dialogConfig') onDialogConfigChange(propName: string): void { if (this.dialogConfig.isShow) { // 配置显示:打开弹窗 this.controller?.open(); } else { // 配置隐藏:关闭弹窗 this.controller?.close(); } } build() { // 宿主组件本身不需要渲染内容 // 它的职责是让controller跟随生命周期创建和销毁 } }注意@Watch的用法。@StorageLink修饰的对象,当属性值变化时可以通过@Watch回调感知。在回调里判断isShow字段:变为true就open,变为false就close。
为什么autoCancel要设为false?因为autoCancel默认是true,用户点击弹窗外的遮罩层会直接关闭弹窗,但此时全局配置的isShow仍然是true,会造成状态不同步。我选择把遮罩点击关闭交给业务方控制,用户必须明确点击按钮才能关闭,这样全局状态永远和弹窗实际显示状态保持一致。
宿主组件放在哪里是关键问题。我的做法是在每个页面的根组件里挂载一次CommonDialog。挂在页面根部还有一个好处:弹窗层级自然处于当前页面顶部,不会被页面内其他组件遮挡。
3.4 DialogManager统一入口类
有了宿主组件,还需要一个业务方能直接调用的静态工具类。这个类不依赖页面实例,所有方法都是静态的,内部通过修改AppStorage中的全局配置来触发宿主组件动作。
// DialogManager.ets export class DialogManager { // 普通提示框 static showAlert(options: { title?: string; message?: string; confirmText?: string; onConfirm?: () => void; }): void { this.buildConfig('alert', options); } // 确认框 static showConfirm(options: { title?: string; message?: string; confirmText?: string; cancelText?: string; onConfirm?: () => void; onCancel?: () => void; }): void { this.buildConfig('confirm', options); } // 输入框 static showInput(options: { title?: string; inputPlaceholder?: string; inputDefaultValue?: string; confirmText?: string; cancelText?: string; onConfirm?: (value?: string) => void; onCancel?: () => void; }): void { this.buildConfig('input', options); } // 加载框 static showLoading(message?: string): void { const config: DialogConfig = { isShow: true, type: 'loading', title: '', message: message || '加载中...', confirmText: '', cancelText: '' }; AppStorage.setOrCreate('common_dialog_config', config); } // 关闭加载框 static hideLoading(): void { const current = AppStorage.get<DialogConfig>('common_dialog_config'); if (current) { AppStorage.setOrCreate('common_dialog_config', { ...current, isShow: false }); } } private static buildConfig(type: DialogType, options: any): void { const config: DialogConfig = { isShow: true, type: type, title: options.title || '提示', message: options.message || '', confirmText: options.confirmText || '确定', cancelText: options.cancelText || '取消', inputPlaceholder: options.inputPlaceholder || '', inputDefaultValue: options.inputDefaultValue || '', onConfirm: options.onConfirm, onCancel: options.onCancel }; AppStorage.setOrCreate('common_dialog_config', config); } }这个工具类把之前的全局配置操作全部封装起来了。业务方不需要知道common_dialog_config这个key,也不需要手动拼接DialogConfig对象,只需要记住DialogManager.showAlert、showConfirm、showInput、showLoading这一组方法名。
还有一个细节值得提。AppStorage.setOrCreate每次都会生成一个新对象,@StorageLink监听到对象引用变化后触发@Watch回调。这意味着即使两次弹窗的配置内容完全一样,只要引用不同,宿主组件也能准确感知到变化并重新open。这个特性有效规避了“相同配置无法重复弹窗”的经典坑。
4. 实战接入:四种业务场景一行调用
4.1 页面挂载与普通提示框
在页面里使用之前,需要在页面根组件中挂载宿主组件。
@Entry @Component struct Index { build() { Stack() { // 页面主内容 Column() { // ... } // 挂载全局弹窗宿主 CommonDialog() } } }挂载好之后,业务方在任何地方都能一行弹出提示框:
DialogManager.showAlert({ title: '操作成功', message: '数据已保存,刷新后可见', confirmText: '知道了', onConfirm: () => { console.info('用户点击了知道了'); } });这时候弹窗显示标题“操作成功”,正文显示“数据已保存,刷新后可见”,只有一个“知道了”按钮。用户点击按钮后,宿主组件关闭弹窗,然后把回调传出来。
4.2 确认框:删除场景最常用
删除操作的二次确认,是confirm类型最典型的应用场景。我在业务里直接这么调用:
DialogManager.showConfirm({ title: '删除确认', message: '确定要删除这条销售记录吗?删除后不可恢复。', confirmText: '删除', cancelText: '再想想', onConfirm: () => { // 执行删除接口 this.deleteRecord(); }, onCancel: () => { console.info('用户取消删除'); } });这里按钮文字我特意改成了“删除”和“再想想”,比默认的“确定”“取消”更有业务辨识度。产品上如果想把删除按钮做成红色警示色,只需要修改CommonDialogBuilder里确认按钮的backgroundColor,全项目统一生效。
4.3 输入对话框:表单场景简化
HarmonyOS里原生实现一个带TextInput的弹窗比较繁琐,我把输入逻辑直接封装到了通用组件里,业务方调用时只需要关心输入回调。
DialogManager.showInput({ title: '新建分组', inputPlaceholder: '请输入分组名称', inputDefaultValue: '', confirmText: '创建', cancelText: '取消', onConfirm: (value?: string) => { if (!value || value.trim().length === 0) { // 输入为空时再弹一个提示框 DialogManager.showAlert({ title: '提示', message: '分组名称不能为空' }); return; } this.createGroup(value.trim()); } });这个场景展示了封装的一个额外好处:弹窗的回调里可以再调用弹窗。因为宿主组件是全局状态驱动的,在回调里再触发一个新弹窗时,旧弹窗已经关闭,新弹窗会自然地在之后弹出,不需要额外处理层级关系。
4.4 加载框与网络请求联动
加载框和业务联动的典型场景是网络请求。请求开始时显示loading,请求结束无论成功失败都要关闭。
function fetchData(): void { DialogManager.showLoading('正在加载数据...'); api.getData() .then((res) => { // 处理数据 this.dataList = res; }) .catch((err) => { DialogManager.showAlert({ title: '加载失败', message: err.message || '网络异常,请稍后重试' }); }) .finally(() => { DialogManager.hideLoading(); }); }特别注意案例里alert也可能会在finally之前触发。由于我的封装里宿主组件是单例的,同一时间只有一个controller在操作。当加载框还没关闭时又调用了showAlert,配置会被新的alert配置覆盖。实际执行顺序是:先加载框关闭(hideLoading),然后alert弹出来(showAlert),层级不会乱。
5. 常见问题与踩坑记录
5.1 问题速查表
我把实际开发中遇到的问题整理成一个速查表,方便大家对照排查。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 弹窗一闪而过,open后立刻消失 | 全局配置不断触发@Watch,或多次调用show方法 | 检查宿主组件是否重复挂载,同一页面只挂载一次 |
| 第二次调用show方法弹不出弹窗 | 上次关闭只执行了controller.close(),全局isShow仍为true | 统一通过DialogManager关闭,确保isShow被同步置为false |
| 点击遮罩层弹窗消失但全局状态异常 | autoCancel为true,弹窗被系统关闭但配置未更新 | 创建controller时设置autoCancel为false |
| 输入框内容无法重置 | TextInput复用了上次输入内容 | 在aboutToAppear中同步inputDefaultValue到inputValue |
| 页面销毁后还有代码调用弹窗 | 回调函数持有页面实例引用,组件生命周期未管理好 | 在页面aboutToDisappear中确认无未完成的异步回调 |
| 横竖屏旋转后弹窗错位或消失 | 全局配置更新但controller内部状态已失效 | 监听屏幕方向变化,重新open或同步状态 |
5.2 弹窗消失或闪烁的排查思路
如果遇到弹窗一闪而过,先别急着改代码,按这个思路排查。
第一步,确认全局配置写入是否成功。在DialogManager.showAlert方法里打印AppStorage.get('common_dialog_config'),确认isShow已是true且type正确。
第二步,确认宿主组件是否被挂载了两次。如果页面某处不小心写了两个CommonDialog(),两个controller都会监听同一个全局配置。配置变成true时两个controller同时open,后open的会覆盖先open的,看起来就是弹窗刚出来又没了。
第三步,确认宿主组件没有被if条件包裹。如果宿主组件在某些状态变化时被重建,controller会随之销毁,已经打开的弹窗也会消失。
5.3 连续弹窗与重复点击问题
有两个高频问题经常被问到,这里集中说。
一是快速连续点击两个不同按钮,各弹出一个提示框,最终屏幕上会只显示后一个,前一个被覆盖。这是单例弹窗的正常行为。如果业务上必须让弹窗按顺序排队出现,需要在DialogManager里维护一个弹窗队列,当前弹窗关闭后再弹出下一个。我这里做了一个简化版本:每次调用show方法时直接把配置覆盖为最新值,保证同一时间只有一个弹窗显示,对绝大多数业务已经够用。
二是按钮重复点击导致的回调执行多次。用户快速点三次确认按钮,controller.close()可能还没生效,onConfirm就被触发三次。规避办法是在回调执行前加一个时间戳判断或者防抖标记。
private static isHandlingConfirm: boolean = false; // 在按钮点击处理中: if (DialogManager.isHandlingConfirm) { return; } DialogManager.isHandlingConfirm = true; // 执行回调 // 重置标记 setTimeout(() => { DialogManager.isHandlingConfirm = false; }, 300);5.4 横竖屏切换与页面销毁的坑
CustomDialogController在页面销毁时会自动失效,这一点正常情况没影响,但如果业务方在异步回调里弹窗,就会出现问题。典型的场景是网络请求结束后,页面已经跳转,请求的回调却调用了DialogManager.showAlert。
这种情况下全局配置虽然更新了,但宿主组件已经随着页面销毁,弹窗不会显示,甚至可能控制台报错。我的处理方式是,既然宿主组件挂在页面根部,页面销毁时弹窗自然跟着销毁;而真正需要全局弹窗的场景,可以放到UIAbility层面而不是页面层面。项目里如果对全局弹窗有强需求,可以把CommonDialog挂到UIAbility的窗口内容里,这样弹窗不依赖任何页面生命周期。
不过大部分业务场景并不需要弹窗比页面活得更久,页面跳走弹窗消失反而是合理体验。所以我的建议是:普通业务弹窗放页面根部,全局通知类弹窗再考虑提升宿主层级,不要一上来就全局挂载,会引入复杂度。
最后分享一个小技巧。我的CommonDialog宿主组件里故意没有渲染任何UI,但build方法不能为空,否则编译不通过。可以放一个Blank()占位,或者干脆在Column里放一个空内容。这个宿主组件本质上只是controller的容器,切记不要在里面放业务组件,它会随着弹窗逻辑一起被重建,容易引发不必要的性能损耗。
这个封装方案目前在我参与的两个鸿蒙项目里稳定运行了三个迭代。如果你也在做鸿蒙应用的基础组件沉淀,可以在这个基础上继续扩展,比如加一个底部弹窗类型、支持HTML富文本展示、或者增加弹窗动画自定义。封装组件这件事,边界画得越清晰,后期扩展越省心。