本文基于 ArkTS + ArkUI V2 的工程实践,介绍如何把
PromptAction.openCustomDialog封装成可复用、可扩展、易维护的对话框体系,核心思路可直接复用到自己的模块。
一、为什么需要二次封装?
HarmonyOS NEXT 提供了两种主流弹窗能力:
CustomDialogController:与页面强绑定,适合简单场景。PromptAction.openCustomDialog:通过ComponentContent解耦 UI 上下文,支持动态更新、生命周期监听,更适合业务级封装。
直接使用 openCustomDialog 时,每个页面都要重复写一段「创建 ComponentContent → 绑定 UIContext → 打开 → 关闭 → 释放」的样板代码。一旦业务需要统一对话框样式、按钮风格、转场动画,就会发现代码散落在各处,难以维护。
我们的目标是:业务侧一行代码唤起对话框,样式、动画、关闭逻辑全部收敛到公共组件。
二、整体架构
text
entry/src/main/ets
├── components/dialog
│ ├── AppDialogBuilder.ets // 对外暴露的 Builder 与 show 方法
│ └── DialogCard.ets // 对话框内容组件
└── utils
└── DialogManager.ets // ComponentContent / PromptAction 的封装
DialogManager:负责ComponentContent的绑定、打开、关闭、更新、释放,屏蔽PromptAction细节。DialogCard:纯展示组件,只接收@Param,不持有弹窗状态。AppDialogBuilder:把「参数 → Builder → ComponentContent → 打开」的链路串起来,提供业务友好的showAppDialog()API。
三、DialogManager:把 PromptAction 包成工具类
核心职责只有四个:绑定、打开、关闭、释放。
typescript
// entry/src/main/ets/utils/DialogManager.ets
import { BusinessError } from '@kit.BasicServicesKit'
import { ComponentContent, promptAction, UIContext } from '@kit.ArkUI'
export class DialogManager {
private uiContext: UIContext | null = null
private contentNode: ComponentContent<Object> | null = null
private options: promptAction.BaseDialogOptions = {
alignment: DialogAlignment.Center,
autoCancel: true,
showInSubWindow: false,
isModal: true
}
bind(context: UIContext, node: ComponentContent<Object>, options?: promptAction.BaseDialogOptions): void {
this.uiContext = context
this.contentNode = node
if (options) {
this.options = options
}
}
open(): void {
if (!this.uiContext || !this.contentNode) {
console.error('DialogManager open failed: dialog context or content is not bound')
return
}
this.uiContext.getPromptAction()
.openCustomDialog(this.contentNode, this.options)
.catch((err: BusinessError) => {
console.error(`openCustomDialog failed, code: ${err.code}`)
})
}
close(): void {
if (!this.uiContext || !this.contentNode) {
return
}
this.uiContext.getPromptAction()
.closeCustomDialog(this.contentNode)
.catch((err: BusinessError) => {
console.error(`closeCustomDialog failed, code: ${err.code}`)
})
this.dispose()
}
updateContent(params: Object): void {
if (!this.contentNode) {
return
}
this.contentNode.update(params)
}
dispose(): void {
if (this.contentNode) {
this.contentNode.dispose()
this.contentNode = null
}
this.uiContext = null
}
}
设计要点
-
单例 or 实例? 如果业务同时只存在一个弹窗,可以用一个全局实例;如果允许弹窗层叠,建议每个
showAppDialog()调用创建新的DialogManager实例,避免关闭时误伤上一个弹窗。 -
关闭即释放
close()中调用dispose(),防止ComponentContent内存泄漏。ComponentContent不会随弹窗消失自动销毁,必须手动释放。 -
错误兜底
openCustomDialog可能因上下文销毁、重复打开等原因失败,统一用.catch捕获并输出console.error,不要抛到业务层。
四、DialogCard:把弹窗当纯展示组件写
使用 ArkUI V2 装饰器,所有可变状态通过 @Param 传入,所有事件通过回调传出。
typescript
// entry/src/main/ets/components/dialog/DialogCard.ets
function buildCardTransition(): TransitionEffect {
return TransitionEffect.asymmetric(
TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 20 }))
.combine(TransitionEffect.scale({ x: 0.96, y: 0.96 }))
.animation({ duration: 240, curve: Curve.FastOutSlowIn }),
TransitionEffect.opacity(0)
.combine(TransitionEffect.translate({ y: 12 }))
.combine(TransitionEffect.scale({ x: 0.98, y: 0.98 }))
.animation({ duration: 140, curve: Curve.EaseIn })
)
}
@ComponentV2
export struct DialogCard {
@Param icon?: ResourceStr = undefined
@Param iconBgColor?: ResourceColor = undefined
@Param title: ResourceStr = ''
@Param content: ResourceStr = ''
@Param cancelText?: ResourceStr = '取消'
@Param confirmText?: ResourceStr = undefined
@Param onCancel?: () => void = undefined
@Param onConfirm?: () => void = undefined
@Param isTextBtn: boolean = false
@Computed
get hasDivider(): boolean {
return this.isTextBtn && Boolean(this.cancelText) && Boolean(this.confirmText)
}
build() {
Column({ space: 16 }) {
if (this.icon) {
Row() {
Image(this.icon)
.width(24).height(24)
.fillColor(Color.White)
.draggable(false)
}
.width(48).height(48)
.borderRadius(16)
.backgroundColor(this.iconBgColor ?? '#FF5B6CFD')
.justifyContent(FlexAlign.Center)
}
if (this.title) {
Text(this.title)
.fontSize(20)
.fontWeight(700)
.fontColor('#E6000000')
.textAlign(TextAlign.Center)
}
Text(this.content)
.fontSize(14)
.lineHeight(22)
.fontColor('#99000000')
.textAlign(TextAlign.Center)
Row({ space: 24 }) {
if (this.cancelText) {
Button(this.cancelText)
.layoutWeight(1)
.height(44)
.fontSize(16)
.fontColor(this.isTextBtn ? '#FF5B6CFD' : '#E6000000')
.backgroundColor(this.isTextBtn ? Color.Transparent : '#0F000000')
.borderRadius('50%')
.onClick(() => this.onCancel?.())
}
if (this.hasDivider) {
Divider()
.vertical(true)
.color('#99000000')
.height(16)
}
if (this.confirmText) {
Button(this.confirmText)
.layoutWeight(1)
.height(44)
.fontSize(16)
.fontColor(this.isTextBtn ? '#FF5B6CFD' : Color.White)
.backgroundColor(this.isTextBtn ? Color.Transparent : '#FF5B6CFD')
.borderRadius('50%')
.onClick(() => this.onConfirm?.())
}
}
.width('100%')
.margin({ top: 6 })
}
.padding({ top: 24, bottom: 16, left: 24, right: 24 })
.width('100%')
.backgroundColor(Color.White)
.shadow({ radius: 16, color: '#0F000000', offsetX: 0, offsetY: -4 })
.borderRadius(32)
.transition(buildCardTransition())
}
}
设计要点
-
V2 装饰器,单向数据流 全部使用
@Param接收输入,回调函数输出事件,组件内部不持有弹窗开关状态。 -
样式转场动效收敛到组件内部 颜色、字号、圆角、阴影等调用方无需关心
buildCardTransition()放在组件文件里,业务不需要关心弹窗怎么出现、怎么消失。
五、AppDialogBuilder:一行代码打开弹窗
把「参数 → Builder → ComponentContent → DialogManager」串起来,业务侧只需调用 showAppDialog(params)。
typescript
// entry/src/main/ets/components/dialog/AppDialogBuilder.ets
import { DialogCard, DialogContainer } from './DialogCard'
import { ComponentContent, UIContext } from '@kit.ArkUI'
import { DialogManager } from '../utils/DialogManager'
export class AppDialogParams {
title: ResourceStr = ''
content: ResourceStr = ''
icon?: ResourceStr
iconBgColor?: ResourceColor
cancelText?: ResourceStr
confirmText?: ResourceStr
onCancel?: () => void
onConfirm?: () => void
maskCancel?: boolean
isTextBtn?: boolean
}
@Builder
export function AppDialogBuilder(params: AppDialogParams): void {
DialogContainer({
maskCancel: params.maskCancel ?? false,
onCancel: params.onCancel
}) {
DialogCard({
title: params.title,
content: params.content,
icon: params.icon,
iconBgColor: params.iconBgColor,
cancelText: params.cancelText,
confirmText: params.confirmText ?? '确认',
onCancel: params.onCancel,
onConfirm: params.onConfirm,
isTextBtn: params.isTextBtn ?? false
})
}
}
const globalDialog = new DialogManager()
const globalWrap = wrapBuilder(AppDialogBuilder)
export function showAppDialog(params: AppDialogParams, ctx: UIContext) {
// 包装回调:业务逻辑执行后自动关闭弹窗
// 这里直接修改入参对象,因为 AppDialogParams 是一次性配置对象
const originalCancel = params.onCancel
params.onCancel = () => {
originalCancel?.()
globalDialog.close()
}
const originalConfirm = params.onConfirm
params.onConfirm = () => {
originalConfirm?.()
globalDialog.close()
}
const contentNode = new ComponentContent(ctx, globalWrap, params)
globalDialog.bind(ctx, contentNode)
globalDialog.open()
}
对应的蒙层容器:
typescript
// entry/src/main/ets/components/dialog/DialogCard.ets(同文件追加)
@Builder
function noopBuilder() {}
@ComponentV2
export struct DialogContainer {
@Param maskCancel: boolean = false
@Event onCancel?: () => void = undefined
@BuilderParam content: () => void = noopBuilder
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.content()
}
.width('100%')
.onClick(() => {}) // 消费内容区点击事件,避免向上冒泡触发蒙层关闭
}
.width('100%')
.height('100%')
.padding({ left: 16, right: 16 })
.transition(TransitionEffect.OPACITY.animation({ duration: 180, curve: Curve.EaseOut }))
.onClick(() => {
if (this.maskCancel) {
this.onCancel?.()
}
})
}
}
设计要点
-
Builder 与 ComponentContent 解耦 通过
wrapBuilder(AppDialogBuilder)把 Builder 转成可传入ComponentContent的包装,避免 Builder 与页面上下文强绑定。 -
自动关闭,业务无感 在
showAppDialog内部包裹onCancel/onConfirm,业务回调执行完后自动调用globalDialog.close()。 -
UIContext 由调用方传入 调用方传入
UIContext。在页面内直接使用this.getUIContext()即可。 -
@BuilderParam必须用@Builder函数初始化DialogContainer的content默认使用noopBuilder,而不是箭头函数。@BuilderParam的初始化值必须是@Builder装饰的函数。 -
阻止内容区点击冒泡到蒙层 用
Column包裹this.content()并设置空的onClick,避免用户点击弹窗内容时事件向上冒泡触发Stack的蒙层关闭逻辑。
六、业务侧用法
typescript
// entry/src/main/ets/pages/Index.ets
import { showAppDialog } from '../components/dialog/AppDialogBuilder'
@Entry
@ComponentV2
struct DemoPage {
private handleDelete() {
showAppDialog({
title: '确认删除?',
content: '删除后数据不可恢复,请谨慎操作。',
confirmText: '删除',
onConfirm: () => {
console.info('执行删除逻辑')
}
}, this.getUIContext())
}
build() {
Column() {
Button('打开弹窗')
.onClick(() => this.handleDelete())
}
.width('100%')
.height('100%')
}
}
七、五个最佳实践
1. 不要混用 V1 和 V2 装饰器
弹窗内容组件统一用 @ComponentV2 + @Param / @Event。@Link、@Provide、@Consume 在 ComponentContent 场景下有使用限制,容易踩坑。
2. 弹窗状态只在调用侧维护
弹窗组件本身不保存「是否显示」状态,只负责展示。是否打开、打开什么内容,由调用方决定。这样弹窗可以脱离页面生命周期存在,也更容易做全局提示。
3. Builder 传引用,不要立即调用
在 bindSheet、Dialog、Overlay 等场景,优先传 this.MyBuilder 引用,避免传 this.MyBuilder() 导致内容在绑定瞬间被求值固化。
4. 关闭后必须 dispose
ComponentContent 不会自动释放,忘记 dispose() 会导致内存泄漏,甚至再次打开同名弹窗时出现异常。建议在 close() 中统一释放。
5. 区分「更新内容」和「更新属性」
- 更新内容:
contentNode.update(params),用于修改弹窗内部文字、图标等。 - 更新属性:
promptAction.updateCustomDialog(node, options),用于修改对齐方式、蒙层颜色、偏移量等。
注意:updateCustomDialog 未设置的属性会恢复为默认值,更新时要传完整配置。
八、常见坑点
| 现象 | 原因 | 解决 |
|---|---|---|
| 弹窗不显示 | UIContext 已销毁或未传入 |
确保在页面可见后调用,并传入 this.getUIContext() |
| 关闭后再打开无响应 | 上一个 ComponentContent 未 dispose |
close() 中调用 dispose() |
| 蒙层点击无法关闭 | maskCancel 未开启,或 autoCancel 为 false |
检查 BaseDialogOptions 配置 |
| 弹窗内部状态不刷新 | 通过 Builder 参数以值快照传递 | 使用 ComponentContent.update() 或拆分为独立 V2 组件 |
| 同时打开多个弹窗互相覆盖 | 共用同一个 DialogManager 实例 |
每个弹窗使用独立实例,或维护弹窗栈 |
九、参考文档
本文涉及的核心 API 与能力均来自 HarmonyOS NEXT 官方文档,建议进一步阅读:
- 不依赖UI组件的全局自定义弹出框 (openCustomDialog)
- ComponentContent
- PromptAction(openCustomDialog / closeCustomDialog / updateCustomDialog)
- BaseDialogOptions
- wrapBuilder:封装全局@Builder
- @Param:组件外部输入
- @BuilderParam装饰器:引用@Builder函数
- 组件内转场 (transition)
十、写在最后
HarmonyOS NEXT 的 openCustomDialog 是一个非常强大的能力,但直接使用容易写出「复制粘贴式」代码。通过 DialogManager + DialogCard + AppDialogBuilder 三层封装,可以把弹窗能力收敛到一处:
- 业务侧一行调用;
- 样式、动画、关闭逻辑统一维护;
- 内存和生命周期风险可控。
如果你也在做 HarmonyOS NEXT 项目,也可以把项目里的弹窗统一收一收。