角标(Badge)是 UI 里最小的信息单元——一个小圆点或两位数字,却承担着"这里有新东西"的全部注意力调度。它也是最容易被滥用的组件:处处标红等于没有重点,数字不准比不显示更伤信任。健康菜谱助手的角标体系经历过一次校准事故(购物清单角标多加了三,用户下单后发现对不上),从那之后建立了一套计数与显示的纪律。这篇复盘 Badge 的完整工程实践:显示策略、计数口径、多端同步、以及"数字消失"的体验设计。

角标体系

一、角标的四种形态与显示策略

Badge 组件四种形态:纯红点(有新内容,不计数)、数字角标(精确计数)、文本角标(NEW/HOT 之类标签)、自定义角标(复杂内容)。四种形态对应四类信息:红点用于"不需要知道多少"的场景(我的页有新通知);数字用于"数量影响决策"的场景(购物清单三项待买);文本用于运营位(活动入口的 HOT);自定义几乎不用(复杂信息应该用别的组件)。显示策略的关键决策是"红点何时消失":进入对应页面即消失(通知类)还是处理完才消失(任务类)。购物清单用任务语义——未买够就显示;系统通知用浏览语义——点进去就清。两种语义混用是角标混乱的根源,每个角标归属哪种语义,在定义时就写死。

形态适用场景消失时机
红点有新内容,数量无意义进入即清
数字数量影响决策任务完成即清
文本运营标签活动结束
自定义特殊设计需求按业务定

二、计数口径:数字从哪来

数字角标的信任建立在"点进去数字对得上"上。计数口径必须回答三个问题:数什么(购物清单数未购买项,不是全部项)、在哪数(本地缓存加服务端增量的合并结果)、何时数(数据变更事件驱动,不轮询)。口径写进一个纯函数 badgeCount(source),所有展示位调用同一函数——Tab 角标、TabContent 内的红点、深链外显的桌面角标,三处数字永远同源。事故复盘:清单角标曾由两处独立计算(一处数全部项、一处数未购买项),缓存刷新时序不同导致 Tab 显示三、页面显示六。归一到一个函数后这类分裂绝迹。计数口径的纪律:宁可少显示(删掉口径模糊的角标),不可显示对不上的数字。口径变更必须走代码评审并同步更新回归清单——口径是角标的法律,法律的修改要留痕。

// 计数口径唯一化:全部展示位共用
function badgeCount(items: ShoppingItem[]): number {
  return items.filter(i => !i.purchased).length   // 只数未购买
}
// Tab 角标与页面内红点同源
Tabs().tabBar(this.listBar(badgeCount(this.items)))

三、数字的上限与省略:99+ 的细节

数字角标的显示上限工程取值:99+ 封顶。超过 99 的精确数字没有信息价值(用户只需要知道"很多"),还可能撑破布局。配套三个细节:99+ 的字号比数字略小一号,避免突兀;清空瞬间角标消失加缩放过渡(生硬消失像出 BUG);从 99+ 降到两位数时数字跳变要自然(购买一项从 103 变 102 仍显示 99+,用户无感是正常的,不要为了"数字动起来"把 99+ 拆开精确显示)。零值不显示角标——显示 0 是明确的反模式,0 和"无"在角标语言里必须是同一个意思。这三个细节没一个有技术难度,全是"想没想过"的区别。

四、多端同步:角标的一致性难题

手机、平板、2in1 三端跑同一应用,角标的一致性靠数据同步而不是各自计算。同步模型:角标计数是数据的派生值,数据同步(分布式数据对象)后各端各自重算——绝不同步"角标数值"本身(同步计数会产生双倍计数:两端各加一)。同步的时序差异要接受:手机上买了两样,平板的角标晚几秒更新,这是分布式数据对象的正常延迟,处理方式是"到达即更新",不做过期判断。特殊场景的一端清除语义要明确:手机上清空通知,平板的红点也该消失——清除动作作为数据变更同步,派生角标随之归零。桌面角标(桌面图标上的数字)走系统的 badge 接口,与应用内角标同一数据源,三处一处同步差都没出过。

// 数据变更驱动重算,不同步数值本身
distributedData.on('shopping_changed', () => {
  this.items = this.store.loadAll()
  this.badge = badgeCount(this.items)      // 各端各自派生
  badgeManager.setDesktopBadge(this.badge) // 桌面角标同源
})

多端同步

五、真实事故:多加了三的角标

上线第二周收到反馈:清单角标显示五,进页面只有两样待买。排查链路:角标计数在两处被叠加——本地数据变更事件加一,同步回调(从平板同步来的增量)又加一,同一条数据变更在单端被计了两次。根因是计数逻辑用"增量累加"而不是"全量重算":累加模式对事件去重的容错为零,任何一次事件重放都造成计数漂移。修复:计数统一改成全量重算(数据源变更后 filter 出未购买项重新计数),增量只用于数据合并,不参与计数。全量重算的成本在千级数据量下可以忽略,换来的正确性是结构性的——无论事件来几遍,数字永远对得上数据。计数类 UI 的第一原则从这次起写进规范:派生值重算,不累加。

六、红点的阈值控制:不是所有"有新"都值得红

红点的价值在于稀少。阈值控制两处:内容型红点(菜谱库有更新)只在有实质新内容时亮,且单端一天最多亮一次(数据没变就别亮);任务型红点(清单未完成)是持续状态不算"提醒",不参与阈值。另有一个反向场景:用户明确表达"不关心"(长按红点选择忽略),记录偏好该类红点降级为不亮——尊重用户的拒绝比坚持提醒重要。阈值策略集中在 BadgePolicy 里:shouldShowBadge(type, payload, userPrefs) 一个函数回答亮不亮,策略可测可调,红点不再是散落的 if。清洗后全应用常亮的红点从七处降到三处:通知、清单、更新,用户的注意力终于有重点。

function shouldShowBadge(type: string, payload: object, prefs: BadgePrefs): boolean {
  if (prefs.muted.includes(type)) return false          // 用户拒绝过
  if (type === 'notice') return payload.count > 0       // 内容型:有新的
  if (type === 'task') return payload.pending > 0       // 任务型:未完成
  return false
}

七、位置与遮挡:角标的视觉锚定

角标的位置纪律:始终锚定在宿主控件的右上(图标右上角偏移四到六像素),偏移量全局统一。遮挡问题三个场景:角标叠角标(图标上已有小圆点又有数字,设计上禁止——一个图标一个角标);角标被相邻元素压住(Stack 层级问题,zIndex 显式声明);大字号下角标与文字重叠(数字角标的高度随字体缩放,超一倍字号时角标位置下移到图标正上方)。位置问题全是"看起来对不对"的问题,真机截图评审比读代码有效——角标这类十像素级的 UI,评审流程里固定一步:三档字号加深浅色四张截图并排看。

位置锚定

八、角标与通知的边界:桌面上的双重提醒

应用内角标和系统通知是两个通道:角标是"应用内状态的持续展示",通知是"及时的一条推送"。边界纪律:一个事件要么推通知要么亮角标,不双发(下单提醒既推通知又亮角标,用户被打扰两次,信息重复);清单类持续状态走角标不推通知(每天推"你还有三样没买"是骚扰,角标安静地常驻即可);时效事件(链接失效)走通知不靠角标(用户不在应用里就错过了)。桌面角标介于两者之间:它常驻但克制,只反映应用内的核心未处理数,不做营销计数(活动角标上桌面图标是严重的自毁信任行为)。边界写进产品文档,开发按文档执行,运营需求来时按文档拒绝。

九、动效纪律:角标的出现与消失

角标虽小,动效却最显眼——它出现在用户视线常驻的导航区。三条动效纪律:其一,出现用缩放弹入(scale 从零到一并带回弹),时长 200 毫秒,生硬浮现像系统错误;其二,数字变化用翻动或淡入淡出,禁止直接跳变(从 8 到 12 的瞬间跳变让用户怀疑漏看了);其三,消失用缩放收起,与出现对称。三类动效统一封装进 BadgeGroup 组件,业务侧零动效代码——角标动效的正确性靠组件保证,不靠每个使用方自觉。反面教训是早期各处自己叠 animateTo,同一颗红点在不同页面出现方式不一样,用户感知不到细节但感觉得到"不整齐"。动效统一这件事,只有组件化一条路:凡是"多处出现的小元素",它的动效就该长在组件里。

@Component
export struct AppBadge {
  @Prop @Watch('onCountChange') count: number = 0
  @State appearScale: number = 0
  build() {
    if (this.count > 0) {
      Text(this.count > 99 ? '99+' : String(this.count))
        .fontSize(this.count > 99 ? 9 : 10)
        .scale({ x: this.appearScale, y: this.appearScale })
        .animation({ duration: 200, curve: Curve.Friction })
    }
  }
  onCountChange() { this.appearScale = this.count > 0 ? 1 : 0 }
}

十、静默时段:角标的作息

角标也要有作息:夜间静默时段(默认 22:00 到次日 8:00)内容型红点不亮,累积到早晨一并提示——深夜弹出的"有新内容"红点,用户看到时已经过了一夜,提示的时效价值归零,只剩下打扰。任务型角标不受静默限制(未买清单是持续状态,不存在"弹出"时刻)。静默时段的实现放在 BadgePolicy:shouldShowBadge 增加时间维度判断,策略函数的三参数扩成四参(类型、数据、偏好、时刻)。作息的另一面是清零的时机:静默期间累积的内容,早晨首次打开应用时一次性呈现,配合"一次看完"的清零动作——用户早上打开,处理完通知,角标归零,一天清爽。角标的作息让"持续提醒"变成了"按时报到",打扰感降到最低。作息表的时段与节假日调整都收在策略函数里,一处配置全端生效。

十一、数据看板:角标的健康度

角标体系的长期维护靠一页健康度看板:每个角标的当前计数、最近七天亮起的次数、清除路径的完成率。看板回答三个问题——哪个角标常亮不灭(阈值失灵,多半是计数口径又漂了)、哪个角标从未被点击(白亮,该砍)、哪个角标清除率低(用户看到了但没进,入口或文案有问题)。看板数据全部来自既有打点,零新增埋点成本,聚合脚本每天跑一次输出到内部页。上线三个月,看板淘汰了两个白亮的红点、修正了一次口径漂移——角标体系的信任不是设计出来的,是看板盯出来的。任何"小而多"的组件(角标、气泡、Toast)都需要这样一页账,否则劣化是无感的。

十二、生产验证:角标的回归清单

角标回归固定五项:一、计数口径——Tab 角标、页面红点、桌面角标三处数字一致(改数据后逐处核对);二、语义正确——通知类进入即清、任务类完成才清,各走各的;三、上限与零值——99+ 封顶、0 不显示、清空有过渡;四、多端同步——两端操作后另一端角标在数秒内归位;五、阈值与偏好——静音类型不亮,常亮数量符合白名单。五项里第一项是事故专项:数字一致性是角标的命,任何重构后三处数字对不上,发布流程直接打回。

十三、总结:角标的四条军规

收束成四条:一、派生重算——计数从数据源全量重算,绝不用增量累加,同步不同步数值;二、口径唯一——所有展示位调用同一个计数函数,三处数字永远同源;三、形态克制——红点稀少才有价值,数字精确才有信任,0 与无同义;四、边界分明——应用内角标、桌面角标、系统通知三通道各管各的,不双发不营销。角标是十像素级的组件,但它调度的是用户最宝贵的注意力——数字对得上、红点亮得有道理,这十个像素才配得上用户的一眼。最后一句话送给正在做角标的人:上线只是开始,看板上的每一次异常都在提醒你,信任的维护没有终点。

参考资料:

  • 华为开发者:Badge 角标组件
  • 华为开发者:分布式数据对象
  • 华为开发者:桌面角标接口

#鸿蒙 #HarmonyOS 7(API 26)

Logo

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

更多推荐