茶器艺科智造HarmonyOS应用实战-56-新Toast会直接取消旧Toast,导出错误为何一闪而过:加入队列、优先级与安全区

应用内 Toast 同时承接切片完成、连接成功、贴图更新、读取失败、智能体错误和 Web 侧 STL 消息。茶器艺科智造当前只有一份文本和一个计时器:任何新消息到来都会清掉旧计时器、覆盖文本并重新计时。于是“正在导出”之后紧接一个普通提示时,重要错误也可能很快被下一条消息替换;固定底部 96vp 又没有显式合并窗口安全区。解决方法不是把所有时长加倍,而是为消息加类型、优先级、最短展示、去重键和可操作性,再由队列统一决定谁能显示。

Toast队列与安全区主题封面

1. 实际问题:单槽位让最后到达者无条件获胜

当前 showToast() 先取消已有 timeout,再把 chaqiToastText 替换成新文本并显示。时长会限制在 1500–10000ms,但这个下限只对“没有新消息打断”的情况有效。

private showToast(
  msg: string,
  duration: number = 2000
): void {
  if (this.chaqiToastTimerId >= 0) {
    clearTimeout(this.chaqiToastTimerId)
    this.chaqiToastTimerId = -1
  }
  this.chaqiToastText = msg
  this.chaqiToastVisible = true

  const d = Math.max(
    1500,
    Math.min(10000, duration)
  )
  this.chaqiToastTimerId = setTimeout((): void => {
    this.chaqiToastVisible = false
    this.chaqiToastTimerId = -1
  }, d) as number
}

源码没有消息 ID、类别、队列、去重、优先级或“至少展示到何时”。因此,旧消息实际展示时间可能远小于传入 duration。本文把“导出错误一闪而过”视为结构允许的风险,没有声称已经在设备上复现某条具体错误。

2. 来源审计:页面与Web都写入同一个槽位

页面内有切片完成、连接、图片大小、读取不完整、格式不支持、贴图更新、智能体错误、视角重置和 STL 流程等多处调用。CupWeb3d.ets 还通过 eventHub 发出 JSON,只包含 m 文本和 d 时长;页面解析后仍调用同一个 showToast

private readonly onChaqiToast = (
  data: object | string
): void => {
  let msg = ''
  let dur = 3200
  if (typeof data === 'string') {
    try {
      const value = JSON.parse(data) as
        Record<string, Object>
      msg = String(value['m'] ?? '')
      const parsedDuration = Number(value['d'])
      if (!isNaN(parsedDuration) &&
          parsedDuration > 0) {
        dur = parsedDuration
      }
    } catch (_error) {
      msg = data
    }
  }
  if (msg.length > 0) {
    this.showToast(msg, dur)
  }
}

调用方只能通过时长暗示重要性,管理器无法知道“读取失败”应压过“已重置视角”,也无法识别连续的导出进度是否属于同一任务。修复应先升级消息协议,再替换计时逻辑。

消息分类、排队、展示与安全区计算流程

3. typed消息:把重要性和生命周期写进数据

建议定义 ToastMessage。kind 用于视觉和无障碍语义;priority 用于调度;dedupeKey 把同一任务的进度合并;minMs 保证最短阅读时间;maxMs 防止普通消息永久占位;sticky 只给需要用户确认的错误;action 提供“重试”或“查看详情”。

type ToastKind =
  | 'info'
  | 'success'
  | 'warning'
  | 'error'

interface ToastAction {
  label: string
  actionId: string
}

interface ToastMessage {
  id: string
  kind: ToastKind
  priority: number
  text: string
  createdAtMs: number
  minMs: number
  maxMs: number
  sticky: boolean
  dedupeKey?: string
  action?: ToastAction
  source: 'page' | 'web3d' | 'agent'
}

id 必须唯一,dedupeKey 才是同一逻辑消息的稳定键。例如 STL 导出可用 stl-export:<jobId>,进度更新替换同组 pending 项;error 使用新 ID 并提高优先级。priority 的范围和各类默认值要集中定义,不让调用点随手写 999。

文本也应有长度和字符策略。来自 Web 或异常的原始字符串先映射为用户文案,详细堆栈写受控日志;不要把路径、内部对象或敏感值塞进 Toast。

4. 调度规则:同级排队,高优先级才允许抢占

建议的默认规则是:没有 current 时立即展示;新消息优先级小于或等于 current 时进入队尾;更高优先级只有在 current 已满足 minMs 或 current 允许被抢占时才切换;error 可以抢占普通进度,但被抢占的可恢复消息按剩余时间回队。队列要设上限并优先丢弃重复低优先级 info。

class ToastQueue {
  private current?: ToastRuntime
  private pending: ToastMessage[] = []
  private timerId: number = -1
  private generation: number = 0
  private readonly maxPending: number = 8

  enqueue(message: ToastMessage): void {
    const normalized = normalizeToast(message)
    this.mergePendingByDedupeKey(normalized)

    if (this.current === undefined) {
      this.showNext()
      return
    }

    if (this.canPreempt(
      normalized,
      this.current
    )) {
      this.preemptCurrent(normalized)
      return
    }

    this.insertByPriority(normalized)
    this.trimLowPriorityOverflow()
  }
}

maxPending=8 是建议初值,不是系统上限。若错误消息超过队列容量,不能静默丢失;应转入持久的错误中心、任务详情或日志,并让顶部状态可发现。Toast 只适合短时反馈,不应承担全部故障恢复。

5. 计时器需要generation,旧回调不能关闭新消息

每次展示新 current 都递增 generation,并在 timeout 中捕获。旧计时器即便在 clear 与执行边界相撞,也只能结束它所属的那一代。sticky 消息不安排自动关闭;普通消息在 maxMs 后完成并拉取下一条。

private present(message: ToastMessage): void {
  this.clearTimer()
  const generation = ++this.generation
  const now = Date.now()

  this.current = {
    message,
    shownAtMs: now,
    earliestDismissAtMs: now + message.minMs
  }
  this.publishCurrent()

  if (!message.sticky) {
    this.timerId = setTimeout((): void => {
      if (generation !== this.generation) {
        return
      }
      this.finishCurrent('timeout')
    }, message.maxMs) as number
  }
}

private finishCurrent(reason: string): void {
  this.clearTimer()
  this.current = undefined
  this.publishHidden()
  this.showNext()
}

若用户点击 action,先记录 message ID 和 actionId,再结束 current;action 执行失败可以入队一个新的 error,不能重新使用旧 ID。页面离开时应取消 timer、增加 generation、清掉内存队列,或把队列所有权提升到应用级;两种生命周期语义要选一种并写清。

Toast生产者、队列、浮层与安全区结构图

6. Web协议升级:m和d兼容读取,新增kind与dedupeKey

迁移期可以继续接受旧 {m,d},但默认归类为 info;新格式带协议版本、kind、code、jobId 和 progress。页面只接受白名单 kind,限制时长和文本长度,并由 code 映射 priority。外部消息不能直接指定任意高优先级。

interface WebToastPayloadV2 {
  v: 2
  m: string
  kind: string
  code?: string
  jobId?: string
  progress?: number
}

function fromWebToast(
  raw: string
): ToastMessage {
  const value = JSON.parse(raw) as
    Record<string, Object>
  const version = Number(value['v'] ?? 1)
  const text = safeToastText(
    String(value['m'] ?? '')
  )

  if (version < 2) {
    return infoToast(text, 'web3d')
  }

  const kind = allowToastKind(
    String(value['kind'] ?? 'info')
  )
  const code = safeCode(value['code'])
  return policyToast({
    kind,
    text,
    source: 'web3d',
    code,
    dedupeKey: buildWebDedupeKey(value)
  })
}

旧 JSON 解析失败时直接把整串当文案,可能显示结构化垃圾;建议改为统一的“3D 预览返回了无法解析的消息”,原文只进入截断日志。Web 侧导出开始、进度、成功、失败使用相同 jobId,队列才能合并进度并保留最终错误。

7. 安全区:bottom=96改为由窗口和底栏共同计算

ChaqiToastOverlay.ets 当前固定 margin({ bottom: 96 }),最大八行,外层 hitTestBehavior(HitTestMode.Transparent)。固定值没有表达底部系统避让区、浮动 Tab 高度和额外间距的来源;透明命中也意味着当前浮层没有可点击 action。

@Component
export struct ChaqiToastOverlay {
  @Prop message: ToastMessage = defaultToast()
  @Prop bottomSafeInsetVp: number = 0
  @Prop tabOverlayHeightVp: number = 0
  onAction: (id: string) => void = () => {}

  private bottomOffsetVp(): number {
    return this.bottomSafeInsetVp +
      this.tabOverlayHeightVp + 12
  }

  build() {
    Stack() {
      this.ToastCard()
    }
    .margin({ bottom: this.bottomOffsetVp() })
  }
}

建议由页面读取 bottom avoid area,和当前手机/宽屏底栏实际占用高度一起传入。键盘显示时 Toast 放在键盘上方还是贴窗口底部,需要单独定义。若 message 有 action,卡片必须可命中且不让全屏透明层吞掉后方操作;具体 ArkUI 命中层级要用组件测试确认。

错误内容超过八行时不应简单拉长 Toast。主文案保持短,详情转到弹窗或错误页;action 标签要明确,例如“重试导出”“查看详情”,不要只有“确定”。

8. 无障碍与去重:消息可读、可操作、不过度打扰

状态不能只靠边框颜色区分。浮层应给出“错误”“成功”等文字前缀或图标语义,设置合适的辅助说明,并验证屏幕阅读器能在新消息出现时感知。连续进度不应每 1% 都重复播报,可按 10% 或阶段去重;最终成功和失败必须播报一次。

function announceText(
  message: ToastMessage
): string {
  switch (message.kind) {
    case 'error':
      return '错误:' + message.text
    case 'warning':
      return '提醒:' + message.text
    case 'success':
      return '成功:' + message.text
    default:
      return message.text
  }
}

function progressDedupeKey(
  jobId: string
): string {
  return 'stl-export:' + jobId + ':progress'
}

无障碍公告 API、焦点策略和 action 的组件写法应按项目目标 API 文档核实。错误变成 sticky 时不能强制把焦点从用户当前操作抢走;可以提供清晰的关闭按钮和任务详情入口。用户主动关闭后,重复同一错误应遵循冷却和状态变化规则。

9. 验证矩阵、故障排查与证据边界

输入顺序预期 currentpending/合并行为关键断言
info A → info BA 满足策略后再 BB 排队A 不被立即覆盖
progress 1→2→3最新进度同 dedupeKey 合并队列不膨胀
info → export errorerrorinfo 可回队或结束error 优先
error → successerrorsuccess 排队error 不一闪而过
error sticky → actionerror 到点击action 后关闭可恢复
旧 timer → 新 current新消息旧 generation 忽略不误关新消息
队列超过8条高优先级保留丢弃/合并低级 info错误另有记录
底部安全区变化同一消息offset 重算不被手势区遮住
键盘弹出按产品规则移动不重复入队文本和按钮可见
页面离开隐藏或移交应用级timer 清理无离页回调改 State
现象优先核对可能原因处理方向
错误仍被成功提示盖掉priority 与 canPreempt所有消息仍同级由 code/kind 集中映射
同一进度排满队列dedupeKeyjobId 未传或 key 每次变化稳定任务键合并
新消息莫名消失timer generation旧 timeout 关闭新 current代次核对
Toast挡住底栏bottom inset 与底栏高度仍固定 96vp动态合并三个偏移
按钮点不到hitTest 和层级沿用全透明命中仅操作卡片可命中
页面离开仍弹消息队列所有权eventHub/timer 未清明确页面级或应用级
屏幕阅读反复播报进度更新频率每条都公告阶段化播报
详情泄露路径文案映射直接显示底层异常诊断码与用户文案分离

本文静态核对了当前单文本、单 timer 的 showToast(),新消息先 clear 旧 timer、时长 clamp、eventHub 的 m/d 协议、多种成功与错误调用点,以及浮层固定 bottom 96、最多八行和透明命中。这些是 live 源码事实。它们说明新消息会覆盖旧消息,但不能证明某次导出错误已经在真机一闪而过。

ToastMessage、优先队列、generation、sticky/action、Web v2 协议、动态安全区和无障碍策略均为本文建议,未改入参考项目。本文没有运行构建、组件测试、屏幕阅读器、键盘/手势导航或真实 STL 导出。因此队列调度、点击命中、辅助公告和各窗口模式的偏移仍需在实现后逐项验证;短暂反馈之外的关键错误还应落到可回看的任务记录,不能只靠 Toast。

Logo

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

更多推荐