别再写 @CustomDialog 了,我把它从雷达鸭代码里全删了重写
2026/6/30 17:58:04 网站建设 项目流程

一句话观点先放这

@CustomDialog 看上去是为"弹窗"这种场景设计的,实际上它的生命周期假设和真实的业务场景对不上。

我承认一开始是被官方文档带偏的——他们给的所有示例都很简洁、漂亮。但真实项目里你不可能只弹一个静态文字框,对吧?等你需要带数据、带异步、带按钮回调的时候,它就开始给你表演"翻车三连"了。

下面三个坑,是我分别在三个不同场景里遇到的。

坑一:和 Navigation 路由的 onBackPress 互相打架

我之前写过一个"删除确认"弹窗,长这样:

@CustomDialogstruct DeleteConfirmDialog{controller:CustomDialogController caseTitle:string=''onConfirm:()=>void=()=>{}build(){Column(){Text(`确定删除「${this.caseTitle}」?`).fontSize(16)Row(){Button('取消').onClick(()=>this.controller.close())Button('删除').onClick(()=>{this.onConfirm()this.controller.close()})}}}}

页面用 Navigation 包着,弹窗在页面里。问题来了:用户按系统返回键时,是关弹窗还是退页面?我当时期待是关弹窗,结果它直接退了页面,弹窗还在。回来一看,弹窗是显示的,但 controller 已经销毁了,点任何按钮都报 undefined。

更恶心的是,当你用router.back()主动退页面时,如果页面里挂着一个没关的 @CustomDialog,Log 里会冒出一堆CustomDialogController has been destroyed的红字,然后整个 App 一卡一卡的。

我后来翻了下官方 issue 区,发现这个问题从 API 8 就有人提,到现在还是 open 状态——这已经两年了。

坑二:弹窗内部 async 数据回填,关闭时序会错乱

这个坑最坑。

场景是这样的:用户点"编辑标签",弹窗起来,弹窗里面要发请求拿可选标签列表。请求回来之前,按钮 disabled,loading 转圈。代码大概长这样:

@CustomDialogstruct TagEditDialog{controller:CustomDialogController@Statetags:string[]=[]@Stateloading:boolean=trueaboutToAppear(){fetchTags().then((res)=>{this.tags=res.datathis.loading=false})}build(){Column(){if(this.loading){LoadingProgress()}else{ForEach(this.tags,(tag)=>{Text(tag)})}}}}

看起来没毛病是吧?实际跑起来,网络慢的时候,弹窗会先关掉,数据才回来。不是没关好那种"残留",是 controller.close() 已经调用了,then 里 this.tags = res.data 还在执行。

为什么?因为 controller.close() 是异步的,它立刻就返回了。我一开始以为是 await 没用对,后来打日志才发现——即使我 await 了this.controller.close(),弹窗消失的动画和 then 回调的执行顺序在不同设备上还不一样。

更惨的是:this 指向也会丢。我那段代码里then((res) => { this.tags = res.data })在鸿蒙 PC 上有一半概率 this 是 undefined,在鸿蒙手机(标准版)上倒是稳的。

这种"PC 端和手机端行为不一致"的问题,我个人最讨厌——你根本没法复现,用户反馈过来你也只能猜。

但@CustomDialog的问题更直接:它就是没把"异步"这件事纳入设计考量。控制器一调,假设你立刻能拿到结果。整个生命周期都按同步模型设计,结果一遇到 await、then、网络请求,就开始拧巴。

为什么我纠结这一点?因为我在另一个项目里用 promptAction.showToast 处理过类似场景——人家就老老实实按 promise 模型设计的,从来不出错。@CustomDialog 抄了一部分 UI,又自作主张加了 controller.close() 这种"立刻返回"的 API,两套模型混在一起,怎么用怎么别扭。

坑三:多次调用,弹窗叠在一起

我有个"批量操作"功能,逻辑是:选完案例后,弹一个"选择操作类型"的弹窗,选完操作类型后,再弹一个"确认执行"的弹窗。连环弹。

我第一次这么写:

this.step1DialogController.open()

在 step1 的"确定"回调里:

this.step1DialogController.close()this.step2DialogController.open()

你猜怎么着?两个弹窗同时显示。step2 直接叠在 step1 上面,背景的 step1 还能看见。用户点 step2 外面想关掉 step2,结果 step1 也被关了。

官方对这个的解释是"@CustomDialog 不是模态的",文档里轻飘飘一句带过。但问题是——它的样式和行为就是模态的啊,挡住下面的操作,谁会想到它是非模态的?

我后来加了一堆 setTimeout 来错开:

this.step1DialogController.close()setTimeout(()=>{this.step2DialogController.open()},250)

看起来能跑了。但 250ms 是魔数,换个设备可能 200ms 都不够。而且这种魔法数字早晚要翻车。我现在已经不敢这么写了。

替代方案:普通组件 + visibility

我后来是用这个方案重写的,全项目跑了两个月没再出过问题:

@Componentexportstruct BaseDialog{@Propvisibility:Visibility=Visibility.None@BuilderParamcontent:()=>void=this.defaultContent@BuilderParamactions:()=>void=this.defaultActions@BuilderdefaultContent(){Text('默认内容')}@BuilderdefaultActions(){Button('关闭').onClick(()=>{this.visibility=Visibility.None})}build(){Stack(){// 背景遮罩Column().width('100%').height('100%').backgroundColor('#00000080').visibility(this.visibility).onClick(()=>{this.visibility=Visibility.None})// 弹窗本体Column(){this.content()this.actions()}.width('80%').backgroundColor(Color.White).borderRadius(12).padding(16).visibility(this.visibility)}}}

页面里用:

@Entry@Componentstruct CaseListPage{@StatedialogVisible:Visibility=Visibility.Nonebuild(){Column(){Button('打开弹窗').onClick(()=>{this.dialogVisible=Visibility.Visible})BaseDialog({visibility:this.dialogVisible}){// 自定义内容Text('这是要删除的案例')}}}}

好处

  • 和 Navigation 路由完全无冲突,因为它是普通组件
  • 关闭就是改个状态,异步数据回填时序清晰
  • 多次弹窗?不存在这个问题,每次都重新 mount

坏处

  • 动画得自己写(其实也不难,animateTo 一把梭)
  • 全屏遮罩要自己处理点击穿透
  • 没办法像 @CustomDialog 那样全局调起

但这三条我都能接受,至少不会再"PC 端和手机端行为不一致"。

顺便说说 promptAction 的取舍

你可能会问:那系统级的弹窗怎么办?比如 Toast、AlertDialog 这种全局提示?

我的答案是:能用 promptAction 的就用 promptAction。鸿蒙官方给 promptAction 设计的就是 promise 风格的 API,await 一把梭,没那么多破事。

// 全局提示用这个就完事promptAction.showToast({message:'保存成功'})// 需要用户确认的轻量弹窗promptAction.showDialog({title:'提示',message:'确定删除吗?',buttons:[{text:'取消',color:'#999'},{text:'确定',color:'#FF0000'}]}).then((result)=>{if(result.index===1){// 确认删除逻辑}})

我现在的策略是这样的:所有"系统级"提示走 promptAction,所有"页面内"弹窗走普通 @Component。这样边界很清晰,代码也很好维护。

唯一要注意的是,promptAction.showDialog 在某些鸿蒙版本里样式很丑,几乎没法定制样式。但对功能来说够用了——丑就丑吧,又不是不能用。

我现在的态度

我承认我花了三天才决定把 @CustomDialog 删干净。

中间也犹豫过——毕竟官方 API,说不定下个版本就修了呢?但转念一想,真用上 @CustomDialog 的项目都已经 1.0 了,没人会等你下个版本。绕开它我反而更自由——我可以在弹窗里塞任何逻辑,不用担心 controller 在那边抽风。

重写那一刻我甚至有点兴奋。说白了,删 @CustomDialog 那行代码的时候有种"终于把病根切了"的快感。我估计每个被这个 API 折磨过的开发者,看到自己项目里彻底没有它,都会下意识去刷新一下 build 目录——不是有什么问题,是想确认它真的没了。

你要是也在被 @CustomDialog 折磨,直接换成普通组件试试,别再花时间研究"为什么 onBackPress 不触发"这种问题——这问题短期内不会修的。

要不要等官方修?我个人是不会等的。等不下去是一方面,另一方面,我新项目干脆连导入都不会有它了——架构上把这条路堵死,就不用纠结"这个弹窗到底该不该用 @CustomDialog"。

写完这篇我打算去把那三处历史代码也清理一下,controller: CustomDialogController 这个字段留着实在碍眼。


关于我:10+ 年软件开发的老人,软件设计师 + 人工智能应用工程师,专注鸿蒙 ArkTS 北向和 Web 前端,不定期在 CSDN 写点鸿蒙和 AI 的东西。

本文遵循 MIT 协议,转载请注明出处。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询