HarmonyOS UIContext 弹窗为什么偶尔不显示:promptAction、openCustomDialog 和页面销毁怎么守边界

HarmonyOS UIContext 弹窗排查图

弹窗问题表面上看很简单:调一个 toast,或者打开一个自定义弹窗。真正放到页面跳转、异步请求、广告回调、全局更新提醒里,就会出现一些很难复现的情况:有时弹不出来,有时弹到旧页面上,有时页面已经关闭了,回调还想弹窗。

这类问题不要先怀疑弹窗样式,也不要上来就换组件。先看一件事:这次弹窗到底应该挂在哪个 UIContext 上。

我的处理顺序是:

  • 弹窗必须绑定当前可用的页面上下文;
  • 异步结果回来时要确认页面还活着;
  • 打开的自定义弹窗要记录 id,关闭时关准确的那个;
  • 不要把全局 prompt 当成永远可用的入口。

问题现场:异步回调晚回来,弹窗找不到正确页面

常见写法是把 promptAction 存成一个全局变量:

let prompt = promptAction

export function showGlobalMessage(message: string) {
  prompt.showToast({ message })
}

或者在页面里拿到一次后长期保存:

aboutToAppear() {
  this.prompt = this.getUIContext().getPromptAction()
}

这两种写法的问题在于:页面上下文不是永远有效。页面被 pop、切走、销毁后,之前拿到的上下文就不一定适合继续弹窗。

实际表现可能是:

  • 请求成功了,但 toast 没显示;
  • openCustomDialog 报上下文相关错误;
  • 弹窗出现在上一个页面;
  • 页面退出后,异步回调还尝试打开确认弹窗;
  • 多个弹窗同时存在,关闭时关错。

这些问题的根源不是“弹窗不稳定”,而是弹窗入口没有跟页面生命周期绑定。

先明确 UIContext 的归属

更稳的写法是:在需要弹窗的页面里获取当前 UIContext,并把弹窗动作收口到当前页面可见期内。

@Component
struct DetailPage {
  private prompt?: PromptAction
  private visible: boolean = false

  aboutToAppear() {
    this.prompt = this.getUIContext().getPromptAction()
  }

  build() {
    NavDestination() {
      DetailContent()
    }
    .onShown(() => {
      this.visible = true
    })
    .onHidden(() => {
      this.visible = false
    })
  }

  private showMessage(message: string) {
    if (!this.visible || !this.prompt) {
      return
    }

    this.prompt.showToast({ message })
  }
}

这里重点不是多写一个 visible,而是让代码表达清楚:页面不可见时,不应该继续弹 UI。

案例一:旧页面上下文还在,被异步回调拿来用

我用一个本地模型模拟了这个问题。页面 A 创建了弹窗宿主,页面切到 B 后,A 已经关闭,但异步回调还拿着 A 的入口去弹窗。

错误结果是:

{
  "badGlobalPromptCase": {
    "error": "host pageA disposed",
    "failedOnDisposedHost": true
  }
}

这说明问题不是消息内容错,而是宿主已经不可用了。

稳定写法是给当前上下文一个 token。页面显示时绑定,隐藏时解绑;异步结果回来时,只有 token 仍然有效才允许弹窗。

class DialogHostGuard {
  private activeToken: number = 0
  private prompt?: PromptAction

  bind(prompt: PromptAction): number {
    this.prompt = prompt
    this.activeToken += 1
    return this.activeToken
  }

  unbind(): void {
    this.prompt = undefined
    this.activeToken += 1
  }

  show(token: number, message: string): void {
    if (!this.prompt || token !== this.activeToken) {
      return
    }

    this.prompt.showToast({ message })
  }
}

页面里使用:

private dialogGuard: DialogHostGuard = new DialogHostGuard()
private dialogToken: number = 0

.onShown(() => {
  this.dialogToken = this.dialogGuard.bind(this.getUIContext().getPromptAction())
})
.onHidden(() => {
  this.dialogGuard.unbind()
})

异步请求回来时:

this.repository.save().then(() => {
  this.dialogGuard.show(this.dialogToken, '保存成功')
})

本地验证结果是:

{
  "stableContextCase": {
    "stale": null,
    "latest": "pageB-1",
    "stable": true
  }
}

旧页面的异步结果被丢弃,当前页面的弹窗正常显示。这就是上下文守卫的价值。

openCustomDialog 要记录 dialogId

自定义弹窗还有一个常见问题:打开容易,关闭时不知道关哪个。

如果页面可能同时出现更新提醒、确认框、操作菜单,就不要只用一个布尔值表示“弹窗打开了”。更稳的是记录打开结果里的 id 或句柄。

private upgradeDialogId: string = ''

async showUpgradeDialog() {
  const prompt = this.getUIContext().getPromptAction()
  const dialogId = await prompt.openCustomDialog({
    builder: () => {
      UpgradeDialog()
    }
  })

  this.upgradeDialogId = dialogId
}

关闭时按 id 关:

closeUpgradeDialog() {
  if (!this.upgradeDialogId) {
    return
  }

  this.getUIContext().getPromptAction().closeCustomDialog(this.upgradeDialogId)
  this.upgradeDialogId = ''
}

本地模型里也验证了这个思路:打开时保存 id,关闭时只移除这个 id 对应的弹窗。

{
  "closeByDialogIdCase": {
    "existing": true,
    "closed": true,
    "stable": true
  }
}

这比“当前有弹窗就关一个”安全得多。

几种弹窗入口怎么取舍

写法 适合场景 风险
组件内普通弹窗 页面局部确认、表单提示 跟页面耦合较强
UIContext.getPromptAction() 当前页面上下文明确的 toast、dialog、menu 页面隐藏后不能继续乱用
openCustomDialog 不依赖某个具体组件绑定的全局自定义弹窗 需要管理 dialogId 和上下文
全局 promptAction 简单提示或历史代码 容易丢 UIContext 边界

如果弹窗跟当前页面强相关,就让页面自己管;如果弹窗需要跨组件触发,也要通过当前页面的 UIContext 入口收口,不要直接把全局函数到处传。

可以沉淀成一个 DialogManager

多个页面都需要弹窗时,可以抽一个轻量管理器:

class DialogManager {
  private prompt?: PromptAction
  private token: number = 0

  attach(prompt: PromptAction): number {
    this.prompt = prompt
    this.token += 1
    return this.token
  }

  detach(): void {
    this.prompt = undefined
    this.token += 1
  }

  toast(token: number, message: string): void {
    if (!this.prompt || token !== this.token) {
      return
    }

    this.prompt.showToast({ message })
  }
}

页面只负责 attach 和 detach:

.onShown(() => {
  this.dialogToken = this.dialogManager.attach(this.getUIContext().getPromptAction())
})
.onHidden(() => {
  this.dialogManager.detach()
})

这样后面不管是保存成功、删除确认、更新提示,都会先经过同一个上下文判断。页面走了,旧回调就不会继续弹。

最后留一份检查清单

以后排 UIContext 弹窗问题,我会先看这些点:

  1. 弹窗入口是不是来自当前页面的 getUIContext()
  2. 异步回调回来时,页面是否仍然可见。
  3. 页面隐藏或销毁时,有没有让旧 token 失效。
  4. openCustomDialog 是否记录了 dialogId。
  5. 关闭弹窗时是不是关闭指定 id,而不是随便关一个。
  6. 全局工具函数有没有偷偷持有旧 prompt。
  7. 多窗口、折叠屏、页面切换时,弹窗是否仍然挂到正确上下文。

弹窗要稳定,核心不是样式写得多漂亮,而是上下文要对。当前页面拿当前 UIContext,异步结果回来先看页面还在不在,自定义弹窗记录 id,旧页面的回调不要再碰 UI。这个边界守住后,toast、菜单、自定义弹窗都会好排很多。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐