ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙开发实战:一行代码封装CustomDialog统一弹窗体系

鸿蒙开发实战:一行代码封装CustomDialog统一弹窗体系 鸿蒙开发里但凡是个正经项目就绕不开信息提示框这个东西。最近我在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.getDialogConfig(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富文本展示、或者增加弹窗动画自定义。封装组件这件事边界画得越清晰后期扩展越省心。
返回列表