气泡提示(Tooltips)是 UI 里最轻的说明手段——不弹窗、不跳页,一个小气泡指着一个控件讲一句话。但轻不等于随便:弹得太多变成骚扰,弹得不是时候变成噪音,文案写得含糊等于没写。健康菜谱助手的气泡使用经历过一次"大清洗":全应用二十四处气泡砍到九处,砍掉的依据只有一条——"这句话用户不看也会用"。这篇复盘气泡提示的完整治理:分工边界、文案纪律、触发时机,以及一次气泡挡住关键按钮的事故。

气泡治理

一、Tooltips 与 Toast 的分工:谁该说什么话

气泡和 Toast(轻提示)长得像,职责完全不同:气泡指向一个具体控件,解释"这是什么/为什么这样",有锚点,用户视线自然衔接;Toast 屏幕中部或底部飘一条,告知"刚才那个操作的结果",无锚点。混用的后果:操作成功提示用气泡(用户根本没在看那个控件,气泡弹了也白弹);功能解释用 Toast(没头没脑一句"长按可多选",用户不知道在说谁)。分工标准一句话:有"它"要说就气泡,有"事"要报就 Toast。清洗时先按这条把二十四处归位,再按"不看也会用"砍掉一半。清洗后全应用的浮层提示互相不打架,用户对提示的注意力反而集中了——提示的稀缺性就是它的价值。

维度气泡(Tooltips)Toast
锚点指向具体控件无锚点,屏幕飘字
内容解释功能/状态反馈操作结果
时长随用户关闭/切换固定两秒自动消失
典型场景新功能指引、状态解释已保存/已收藏
打断强度低但持续高但短暂

二、bindPopup:接口面与最小用法

气泡的绑定是 bindPopup(isShow 状态, PopupOptions 配置)。最小用法:一个状态控制显隐,PopupOptions 里 message 一句话。工程化三点:其一,isShow 用 @State 驱动,气泡的打开与关闭都走状态变更,和模态的收口原则一致;其二,PopupOptions 的 placement 决定气泡在控件哪一侧(Bottom 默认),顶部工具栏的气泡用 Bottom 避免顶出屏;其三,onStateChange 回调监听气泡的开关,引导类气泡关闭后把"已看过"写进持久化标记,下次不再弹——没有标记机制的气泡每次进入页面都弹,是骚扰之源。清洗时把全部气泡过一遍"有没有关闭标记",缺的补齐。

@State showBatchTip: boolean = !storage.getBool('tip_batch_seen')

Image($r('app.media.ic_batch'))
  .bindPopup(this.showBatchTip, {
    message: '长按菜谱卡可批量选择',
    placement: Placement.Bottom,
    onStateChange: (e) => {
      if (!e.isVisible) {           // 用户看过即记,永不再弹
        storage.setBool('tip_batch_seen', true)
        this.showBatchTip = false
      }
    }
  })

三、文案纪律:一句话讲清的三个标准

气泡文案只有一句话的空间,三个标准:其一,说"能做什么"不说"是什么"——"长按卡片可批量选择"优于"批量选择模式";其二,动作词开头——"长按""点击""滑动"打头,用户读到动词就知道该做什么;其三,不超过二十字,写不进二十字说明这个功能本身需要重新设计(说明太复杂的功能不配用气泡引导)。清洗时把二十四处气泡文案逐条按标准重写,最典型的一条从"此功能需要您先在设置中开启离线缓存权限后方可使用"改成"先到设置开启离线缓存"——十四个字讲清动作和去向。文案纪律看似咬文嚼字,实际是功能设计的镜子:写不出短句,功能大概率绕。

四、触发时机:三种该弹与三种不该弹

该弹的三种:新功能首次出现(批量选择入口上线时弹一次);用户卡住的节点(连续两次空搜索后提示"试试食材名");状态异常的解释(离线模式下分享按钮旁解释"离线状态暂不可分享")。不该弹的三种:常规操作路径上的每个控件(用户不需要每个按钮都被介绍);用户已经用对的功能(用对了还弹是打扰);纯装饰性信息(版本号之类的气泡没有任何行动价值)。触发时机的实现要点:引导气泡的触发是"首次进入+未看过标记"两个条件的与;卡点气泡的触发是行为序列判断(连续两次空搜索),这类判断收敛到一个提示策略函数,不在 UI 里散写计数器。策略函数可单测,气泡时机就不再是玄学。

// 卡点提示:行为序列判断收敛到策略函数
function shouldShowSearchTip(events: SearchEvent[]): boolean {
  const recent = events.slice(-2)
  return recent.length === 2 && recent.every(e => e.resultCount === 0)
    && !storage.getBool('tip_search_seen')
}
// 返回 true 时置 isShow,弹一次即记

触发时机

五、与新手引导的配合:气泡不是引导的替代

新手引导(onboarding)和气泡的关系要理清:引导页讲产品定位(第一次打开的三页图文),气泡讲单点操作(某个入口怎么用)。两者的配合原则:引导页里预告过的能力,入口首次出现时才配气泡——引导页说了"支持批量选择",首页批量入口首次出现时弹气泡衔接;引导页没提的隐藏功能(长按直达),气泡承担全部解释责任。反面是引导页与气泡各自为政:引导页承诺了五个功能,入口气泡只覆盖两个,剩下三个用户找不到入口——引导页写完就断线的功能等于没写。清单化对齐:引导页提到的每个能力,必须有对应的入口+气泡或空态提示,这是引导闭环的检查项。

六、自定义气泡:复杂内容的边界

PopupOptions 的 builder 参数支持完全自绘气泡内容,可以塞图片、按钮、多段文字。但自定义的边界要守住:气泡里出现按钮(如"去设置")时,它已经不是气泡而是"迷你弹窗"——点击行为、关闭逻辑、焦点管理都要按弹窗标准做,不如直接用半模态或对话框。项目里只有一处自定义:离线模式气泡里放了一个"查看教程"链接,即便如此也只放一个链接不做按钮交互。自定义气泡的三个底线:不超过两行文字加一个链接;不做二级交互(弹窗套气泡);必须有明显的关闭途径(点外部或关按钮),不能强拉用户注意力。守住边界,自定义是锦上添花;越界,就是交互债。

.bindPopup(this.showOfflineTip, {
  builder: this.offlineTipBuilder(),   // 自绘:两行说明+教程链接
  placement: Placement.Top,
  mask: false                          // 不加遮罩,不拦操作
})
@Builder
offlineTipBuilder() {
  Column() {
    Text('离线状态暂不可分享').fontSize(13)
    Text('查看教程').fontSize(13).fontColor('#007DFF')
      .onClick(() => this.openTutorial())
  }
}

七、真实事故:气泡挡住了关键按钮

上线"批量选择"功能首日,反馈炸了:找不到导出按钮。原因:导出按钮和批量入口相邻,批量功能的气泡向下弹出,正好盖住导出按钮,气泡持续显示直到用户手动关闭——所有第一次进入页面的用户都先被气泡挡住了旁边的按钮。修复三层:气泡位置换到上方(placement Top,不再遮挡下方按钮);显示时长加自动关闭(五秒后自动收起,不再等用户手动关);引导逻辑改为"用户首次点击批量入口时才弹"而不是"进页面就弹"(此时用户意图明确,气泡是解释而不是拦截)。这条事故的普适结论:气泡是页面上真实占位的元素,它的位置和存活时间会影响相邻控件的一切交互——气泡上线前,把它周围的控件点一遍,是最低成本的验证。

// 修复:换位+自动收起+点击才弹
onAppear: () => {
  setTimeout(() => { this.showBatchTip = false }, 5000)   // 5秒自动收
}
// 触发改为首次点击批量入口
Image($r('app.media.ic_batch'))
  .onClick(() => {
    if (!storage.getBool('tip_batch_seen')) this.showBatchTip = true
    this.enterBatchMode()
  })

气泡遮挡

八、频控体系:全局提示预算

单个气泡的规则守住了,还有一个全局问题:同一屏、同一会话里气泡的总密度。清洗后的九处气泡如果都合理地弹,用户在十分钟里也可能被弹五次——每个单看都克制,加起来就是轰炸。解法是全局提示预算:会话级计数器,同一会话气泡弹出不超过三次,超出后本会话的引导类气泡全部静默(错误解释类不受限——异常说明是刚需)。预算的实现是提示中心模块:所有气泡的弹出请求先经过它,它查标记、查预算、查频控,通过才放行。提示中心同时承担了标记的统一读写,原先散在四处的 storage 判定收敛一处。预算制上线后,单会话气泡均值从二点七次降到一点二次,而关键功能的引导到达率没降——用户对提示的敏感度是有限资源,省着用。

// 提示中心:标记+预算+频控三查
class TipCenter {
  static sessionCount = 0
  static request(key: string, isShow: Object, type: 'guide' | 'error'): boolean {
    if (type === 'error') return true                    // 异常解释不受预算限
    if (storage.getBool('tip_' + key + '_seen')) return false
    if (TipCenter.sessionCount >= 3) return false        // 会话预算
    TipCenter.sessionCount++
    return true
  }
}

九、数据视角:气泡的转化率观测

气泡好不好,用户的行为说话。观测指标两条:引导气泡的"动作转化率"(气泡弹出的会话里,用户完成所述动作的比例)与"误触率"(气泡弹出后用户点击了被遮挡或相邻控件的次数)。两条指标接进既有的打点体系,气泡弹出、气泡关闭、目标动作完成三个事件用同一个提示 key 串联。清洗前抽测了两处:批量选择气泡的动作转化率 41%(弹了之后真去用的比例),说明引导有效;搜索技巧气泡转化率 9%,结合卡点场景的判断,砍成只在连续空搜索时弹,转化率升到 33%。没有数据的气泡治理是审美争论,有数据就是加减法——每个气泡的存活都要用转化率续期。

观测项事件判定
动作转化率弹出→目标动作完成低于15%重写文案或砍掉
误触率弹出→误点相邻控件高于5%换位置或缩短存活
重复打扰同会话弹出次数预算制封顶三次

十、消失时机:气泡的礼貌退出

气泡什么时候消失,和什么时候出现同样重要。消失时机四选一:用户点击其他区域(默认,mask 关闭时也生效)、用户完成气泡描述的动作(操作成功即收)、超时自动收(引导类五秒)、页面切换(必然收起)。工程上最容易出问题的是"任务完成即收":气泡引导用户做某事,用户做完了气泡还在——像被提醒之后又被嘲讽一遍。实现是动作成功的回调里同步置 isShow false。另一个细节:气泡开着时用户旋转屏幕或切深浅色,布局变化可能让气泡错位到控件之外——系统多数场景会重排,自绘气泡要实测旋转场景,错位就是 BUG。气泡的礼貌:来的时候有用,走的时候利落,不赖着,不突然。

十一、无障碍:气泡与读屏的配合

气泡的无障碍三条:气泡弹出时读屏自动播报内容(TalkBack 对 popup 有原生支持,自绘气泡要补 accessibilityText);气泡描述的控件本体要有完整语义(气泡是补充,控件自己的 accessibilityText 独立成立——读屏用户关掉气泡提示也能理解控件);替代路径——气泡引导的操作对读屏用户可达(批量入口除了长按还有无障碍菜单入口)。实测发现读屏用户极少看气泡(焦点在控件上),气泡对他们的价值主要靠控件本体语义承载——这条想通了,气泡文案的优先级就排对了:控件语义 > 气泡文案,本末不能倒置。

十二、总结:气泡治理的五条军规

收束成五条:一、分工守边——有锚点讲功能用气泡,报结果用 Toast,绝不混用;二、文案三标准——动词开头、说能做什么、二十字内;三、触发有策略——首见、卡点、异常解释三种才弹,全部带已看标记;四、位置与存活——弹出前检查是否遮挡相邻控件,引导类加自动收起;五、退出要利落——动作完成即收,不赖不突然。气泡是最轻的提示,轻到用户可以无视;治理的目标就是让它在该出现的时候出现一次,讲清楚一句话,然后体面地消失。

参考资料:

  • 华为开发者:bindPopup 与 PopupOptions
  • 华为开发者:PopupCommonOptions 说明
  • 华为开发者:无障碍 accessibilityText

#鸿蒙 #HarmonyOS 7(API 26)

Logo

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

更多推荐