Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›客户端红点与通知工程 2026:依赖图、聚合预算与角标落地

Index

  • 一、问题的提出:红点是被当成图标做出来的
  • 二、信号层:把业务状态抽象成可依赖的叶子信号
  • 三、依赖图层:入口与信号之间的扇入扇出
  • 四、聚合层:增量重算与全量重算的实测代价
  • 五、未读计数:合并规则的语义误差与去重窗口
  • 六、打扰预算:把弹窗当成一种要分配的资源
  • 七、跨平台落地:Android 角标与 iOS 角标的真实约束
  • 7.1 Android:角标不是一个 API,是一组被启动器决定的行为
  • 7.2 iOS:角标计数是一个有明确世代的方法
  • 7.3 跨平台统一抽象与第三方实现的现状
  • 7.4 降级链必须写下来
  • 八、验证闭环:怎么证明这套东西是对的
  • 九、失败模式清单与装配清单
  • 参考文献

客户端红点与通知工程 2026:依赖图、聚合预算与角标落地

红点不是图标,而是一条派生链路的末端产物:业务状态变成叶子信号,信号经入口依赖图聚合,按打扰预算排序,最后才落到 Android 与 iOS 的角标上。本文给出纯客户端的装配方案,含依赖图建模、增量与全量重算的实测代价、未读计数合并的语义误差、打扰预算的公平性度量,以及跨平台角标的真实约束,全部数值由现场脚本算出。

2026年10月30日·约 43 分钟阅读·12,877 字·1 次阅读·博主
#游戏开发
客户端红点与通知工程 2026:依赖图、聚合预算与角标落地

Index

  • 一、问题的提出:红点是被当成图标做出来的
  • 二、信号层:把业务状态抽象成可依赖的叶子信号
  • 三、依赖图层:入口与信号之间的扇入扇出
  • 四、聚合层:增量重算与全量重算的实测代价
  • 五、未读计数:合并规则的语义误差与去重窗口
  • 六、打扰预算:把弹窗当成一种要分配的资源
  • 七、跨平台落地:Android 角标与 iOS 角标的真实约束
  • 7.1 Android:角标不是一个 API,是一组被启动器决定的行为
  • 7.2 iOS:角标计数是一个有明确世代的方法
  • 7.3 跨平台统一抽象与第三方实现的现状
  • 7.4 降级链必须写下来
  • 八、验证闭环:怎么证明这套东西是对的
  • 九、失败模式清单与装配清单
  • 参考文献

客户端红点与通知工程 2026:依赖图、聚合预算与角标落地

一句话摘要:客户端的红点从来不是一个图标,而是「叶子信号 → 入口依赖图 → 增量聚合 → 打扰预算 → 跨平台角标」这条派生链路上的末端产物;本文给出一套纯客户端侧的装配方案,全部性能与公平性数值由现场自写仿真脚本算出,跨平台角标部分以 Android 官方文档与 Apple 官方文档为准。

一、问题的提出:红点是被当成图标做出来的

大多数团队接到「加个红点」的需求时,做法是:策划说要什么,客户端在某个界面加一个 Image 或者一段 Text,收到业务事件时把它的 visible 置为 true。它能跑,也能验收,于是就上线了。

问题在于,红点这个特性真正的难点从来不在画一个圆点,而在于它是一个跨业务域的派生状态。它要回答四个问题:

  • 有哪些东西值得提示? 这决定了信号集合的粒度。粒度过粗,策划说「有新物品」你只能全量提示;粒度过细,你会得到几十个互相重叠的信号。
  • 谁依赖这些信号? 一个「有可领取奖励」的信号,可能同时驱动签到页、任务页、主界面入口和系统角标,一共四个渲染点。依赖关系是扇出的,不是唯一的。
  • 一次变化要传播多远? 你不知道答案时最自然的写法是「收到事件就全量重算所有入口」,这个选择在入口数量少的时候完全没问题,等到入口上百、单帧预算被压到零点几毫秒时就会崩。
  • 最多打扰玩家几次? 玩家每分钟被打扰一次和每天被打扰六次,是两个完全不同的产品;而系统角标、推送通知、界面内红点、弹窗这四层提示如果各自为政,很容易出现「角标 + 推送 + 弹窗 + 红点」四重轰炸同一个事件的情况。

这四个问题分别对应四个工程模块:信号层、依赖图层、聚合层、预算层,最后才是渲染层(界面内红点、系统角标、推送、弹窗)。本文要讲清楚的就是:这套东西在客户端是怎么被装配出来的,以及每一步的代价是多少。

需要先说明本文的边界:这里讨论的一切都发生在客户端。哪些事件值得推送到设备、什么时候给玩家发离线推送、角标计数在服务端如何裁决与持久化,不在讨论范围内。本文只关心客户端拿到状态之后,怎么把状态推导成「界面上该显示什么」。

二、信号层:把业务状态抽象成可依赖的叶子信号

依赖图要成立,第一步是把业务状态抽象成叶子信号。信号的定义很窄:它是一个可独立求值的布尔或计数值,只依赖客户端已知的本地状态,不触发任何渲染。

抽象的原则有三条。

第一条:信号是语义的,不是 UI 的。 「背包里有可分解的装备」是信号,「背包页红点亮」不是。前者可以被七个入口复用,后者只能在背包页用。这也是为什么信号层必须独立于 UI 层存在——如果每个红点都自带一份判断逻辑,那么「背包红点」和「主界面背包入口红点」迟早会在某次改版里算出不一致的结果。

第二条:一个业务事实对应一个信号。 如果「有新邮件」和「邮箱 tab 需要显示未读角标」是两个信号,那么邮件系统一改,这两个信号的更新时机就可能不一致。合并成一个信号之后,依赖它的多个入口自动保持一致。

第三条:信号要能独立验证。 好的信号可以写单元测试:给一组前置状态,断言信号的值。差的信号依赖全局单例、依赖当前界面栈、依赖「刚刚那个异步请求还没回来」,那就永远无法单测,只能靠人肉点。

一个中等体量的客户端,信号数量通常在几十的量级。我在本文的所有仿真中使用的规模是 40 个叶子信号——这个数字不是拍脑袋来的,它对应的是一个有签到、日常、邮件、任务、背包、商店、活动、公会、社交、排行榜等模块的中等客户端的常见量级。

信号的值域也需要区分。三种最常见:

  • 布尔型:有无未读,只有 true / false。它是最容易聚合的,因为「或」运算即可。
  • 计数型:未读邮件 12 条、待领取奖励 3 个。它需要「加」运算,但也需要上限保护,否则一个异常刷子能让数字飙到五位数。
  • 枚举型:当前处于「可领取 / 不可领取 / 已领取」三态中的哪一态。它不能简单相加,聚合时需要定义优先级规则——这也是第五节要解决的问题。

三种值域的聚合规则不同,是很多红点 bug 的根源:把枚举型当布尔型用会出现「已领取的奖励还在闪红点」,把计数型当布尔型用会出现「红点消失但数字还在」。

三、依赖图层:入口与信号之间的扇入扇出

有了信号集合,下一步是建立「入口 → 信号集合」的依赖关系。这里的入口泛指任何一个会渲染提示的 UI 节点:主界面上的一个图标、底部 Tab 上的一个角标、某个页面的标题栏、商城首页的一个格子。

依赖图是一个有向二部图:左边是信号节点,右边是入口节点,信号指向它所驱动的入口。一个信号可能驱动多个入口(扇出),一个入口也可能依赖多个信号(扇入)。

我在仿真里用的规模是:220 个入口,分布在 12 个 Tab 上,2415 条依赖边。实测统计如下:

  • 平均每个信号驱动 60.375 个入口,扇出范围 48 到 74。
  • 平均每个入口依赖 10.977 个信号,扇入范围 6 到 16。

这两个数字说明了依赖图的形状特征:它是高度倾斜的。少数几个「全局性」的信号(比如「账号有未处理事项」)会驱动几十个入口,而大多数入口只依赖六到十六个比较局部的信号。这个倾斜性直接决定了重算策略——如果每次变化都全量重算,你付出的代价里绝大多数来自那几个高扇出信号;如果按依赖关系做增量传播,代价会显著下降。

依赖关系的来源有三种,工程上应该分别处理:

  • 静态依赖:入口依赖哪些信号,在 UI 装配期就确定。写成配表或者注册表,由入口自己注册。这是最干净的一类,也是绝大多数情况。
  • 动态依赖:某些入口的信号集合会随运行时状态变化,比如活动面板只有在活动开启时才存在。这类依赖要有明确的生命周期钩子:入口销毁时必须从依赖图里注销,否则会留下悬空边,指向一个已经不存在但仍占用计数的地方。
  • 条件依赖:同一批入口在不同状态下关注的信号不同,比如同一个入口在「有红点」和「无红点」时关注不同的信号。这类最难,必须在状态切换时显式重连。

依赖图里最容易出的 bug 是悬空边:入口被销毁了,但注册记录没删,于是信号永远认为「还有人需要我」,红点永远不消失。这类问题在开发期极难复现,因为它只在特定的状态转移路径上出现。防御手段是给依赖图加一条不变量断言:每个注册中的入口,其订阅的信号数量必须大于零,且在入口销毁钩子里必须被断言递减。一旦有入口在退出时忘记注销,测试环境会立刻炸出来,而不是等到线上某个玩家卡在某个界面出不来。

四、聚合层:增量重算与全量重算的实测代价

现在到了核心问题:某个信号的值变了,需要重算什么?

全量重算的代价是遍历所有入口与所有边。仿真里是 220 个节点加 2415 条边,单次全量重算需要 2635 次访问。

增量重算的代价是:找到这个信号驱动的所有入口,重算这些入口,再沿着反向依赖继续向上传播。仿真实测:

  • 单次增量重算的最大代价是 958 次访问(对应那个最坏信号,它同时命中 74 个入口)。
  • 单次增量重算的平均代价是 772.02 次访问。

一天按 4320 次业务事件翻转估算(大约每 20 秒一次),两种策略的总量差距是:

  • 全量重算:11,383,200 次访问。
  • 增量重算:3,335,108 次访问。
  • 节省 3.413 倍。

摊到单个入口上看得更清楚:全量策略下每个入口一天被刷新 4320 次;增量策略下平均只被刷新 1184.13 次。

3.4 倍不是决定性的,因为 2635 次访问放在现代 CPU 上也就零点几毫秒。真正的问题在后面。

这里有一个必须自己算才能发现的陷阱。 上面算的是「访问次数」,而真正决定掉帧的是「时间」。如果增量重算的每一步都伴随一次跨模块的虚函数调用、一次字符串 key 的字典查找、一次 UI 节点树遍历,那么 772 次访问乘以每次访问的真实开销,可能比 2635 次访问乘以一个很小的开销还要慢。也就是说,盲目上增量重算有可能把性能做得更差。

正确的做法是把两种开销分开建模:增量重算的固定开销(遍历被影响入口的集合)通常不贵,贵的是「重新计算一个入口的提示状态」这一步。如果这一步本身很重——比如它要去读配置、要做一次字符串格式化、要去访问一个可能已经被销毁的 UI 节点——那正确的优化方向就不是减少重算次数,而是缓存每个入口的计算结果并用脏标记跳过。

于是更实际的策略是三层:

  • 第一层,脏标记。 信号变化时只把受影响的入口标脏,不立即重算。
  • 第二层,帧末合并。 在一帧的末尾统一处理所有脏入口,把同一帧内的多次变化合并成一次重算。这一层在仿真里体现为「单次事件平均成本 772 次」与「单入口日均 1184 次刷新」之间的差——合并把大量重复工作压掉了。
  • 第三层,优先级裁剪。 不可见的入口不需要重算。玩家当前不在商城页,那么商城页的红点状态可以推迟到它即将显示的那一刻再算。这一层的收益无法用访问次数衡量,但通常是三者中最大的。

第三层还有一个容易忽略的收益:它把「什么时候算」和「什么时候显示」解耦了。一个入口在被显示之前可能已经失效了——比如玩家打开了背包,把可领取的东西领走了,那这个入口的红点根本不需要亮过。延迟计算天然消除了这类「亮了一下又灭掉」的视觉闪烁。

五、未读计数:合并规则的语义误差与去重窗口

计数型提示比布尔型提示麻烦得多,因为多个入口的计数合并到一起时,会出现语义偏差。

考虑一个典型情况:玩家有四个业务域各有未读数,界面上要显示一个总角标。「总角标」到底该等于什么?

我在仿真里测了四种常见合并规则。设定是四个业务域各有真实待处理事项,同一件事可能同时出现在两个域里(跨域重复),玩家真正需要处理的新事项数是去重后的数量。四种规则的实测结果:

合并规则平均绝对偏差平均偏差最大绝对偏差偏高占比
域计数直接相加4.314+4.3142485.31%
相加但封顶 994.246+3.9494284.13%
取最大的域22.551−22.4981041.38%
按优先级取首个非零33.538−33.5031110.94%

结论很清楚:

  • 相加会系统性偏高,因为跨域重复被算了两次。实测偏高占比 85.31%,平均多算 4.3 条。
  • 取最大值或按优先级取,会系统性偏低,玩家实际有 22 条未读但角标显示 5 条的情况非常常见——玩家会以为漏了东西,转而去手动翻每个页面。
  • 封顶 99 是在错误的方向上打补丁:它把「最多 99」这个显示上限和「应该等于多少」这个语义问题混在了一起,而且实测最大绝对偏差反而比不封顶时更大(42 对 24),因为一旦某个域自己就超过 99,封顶后的结果与真实值的偏离会被放大。

所以工程上应该把「显示值」和「语义值」分开:角标上显示的数字是展示层的产物,可以封顶、可以取模、可以只显示 99+;但内部用来判断「要不要亮红点」和「要不要弹推送」的那个状态,必须是去重后的真实语义值。

这引出一个更麻烦的问题:去重在哪里做? 客户端不知道两个域里的事项是不是同一件事。唯一可靠的去重发生在业务源头——由产生事件的模块给它一个全局唯一的事项 ID,聚合层按 ID 去重。这就要求信号层携带 ID 而不仅仅是计数。代价是内存,见下一节。

另一个问题是去重窗口。网络重传、多端同步、乐观更新回滚都会让同一个业务事件到达客户端两次。如果不去重,未读数会虚高。实测的去重窗口权衡(假设重复投递概率 6%,两次投递间隔服从均值 3 秒的指数分布):

  • TTL = 0.5 秒:抑制掉 15.35% 的重复,漏掉 84.65%。
  • TTL = 1 秒:抑制 28.35%,漏 71.65%。
  • TTL = 2 秒:抑制 48.66%,漏 51.34%。
  • TTL = 5 秒:抑制 81.11%,漏 18.89%。
  • TTL = 10 秒:抑制 96.43%,漏 3.57%。
  • TTL = 30 秒:抑制 100%,漏 0%。

注意 TTL=30 秒那一行是仿真模型在 TTL 远大于均值间隔时的退化结果,不是真实保证。 真实世界里玩家完全可能在 30 秒内手动领了奖、又触发了一次同类事件——那不是重复投递,是两次合法事件。所以 TTL 不能取到「刚好把重复都吃掉」的位置,工程上更稳的做法是按事件 ID 而不是按时间窗口去重:维护一个已处理 ID 的有界集合(容量几百即可),TTL 只作为集合的回收策略,而不是去重本身。这样即使间隔很长,只要 ID 不同就不会误抑制。

六、打扰预算:把弹窗当成一种要分配的资源

界内红点、系统角标、推送通知、全屏弹窗,这四层提示是同一个资源的四种消耗方式。如果它们各自决定要不要打扰玩家,最坏情况是同一个业务事件同时点亮四层,玩家的体感就是「这游戏一天到晚在弹东西」。

所以需要一个统一的打扰预算:客户端在单位时间内允许触发的主动提示次数上限,超出预算的提示按规则排队、降级或丢弃。

预算怎么分配?我的仿真里做了三组对比。设定:24 个运营位,400 名玩家,每人每天最多被打扰 6 次;每个运营位有到达强度 λ 和全局优先级 p;每个玩家对每个运营位有一个兴趣度,其中 40% 的概率玩家对某个位完全不感兴趣。

三组策略:

S1,纯优先级贪心(按 p 排序取前 6):

  • 展示次数的基尼系数 0.75。
  • 24 个运营位里只有 6 个被展示到,18 个一次都没出现。
  • 玩家对某个位兴趣为零却仍然被弹的比例 45.17%。
  • 人均有效触达 3.29 个/天。

S2,优先级 × 兴趣加权:

  • 基尼系数 0.3106。
  • 24 个运营位全部被展示到,零曝光位 0 个。
  • 无效打扰比例 0.00%。
  • 人均有效触达 6.0 个/天。

S3,强度 × 兴趣(忽略全局优先级):

  • 基尼系数 0.3858,覆盖 23/24 个位,无效打扰 0.00%。

S1 到 S2 的对比说明了一件事:纯优先级贪心在多玩家场景下是危险的。全局优先级是运营视角的,它把 18 个运营位完全挤出了视野,同时把 45% 的打扰浪费在玩家根本不关心的地方。S2 用玩家兴趣做乘数,把基尼系数从 0.75 压到 0.31,覆盖位从 6 个变成 24 个,无效打扰从 45% 降到 0%。

这里的「兴趣」不一定要上模型。它可以简单到:玩家关掉了这个活动、玩家三次都没点过这类通知、玩家在设置里屏蔽了这类提示。但必须有一份可读取的兴趣信号——没有它,预算就只能按全局优先级分配,也就必然复现 S1 的问题。

S2 和 S3 的差距也值得注意:S3 覆盖了 23/24 个位,S2 覆盖 24/24,但 S3 的基尼(0.3858)比 S2(0.3106)更不均。优先级和兴趣应该相乘而不是相替代——优先级管「哪些事对运营重要」,兴趣管「哪些事对这个玩家重要」,两个维度缺一不可。

预算层还要处理降级链。当预算耗尽时,不是简单地丢弃,而是按重要性把提示降级:全屏弹窗 → 半屏卡片 → 系统角标 → 界面内红点 → 静默记录。这条链让「重要但预算不够」的事情仍然能被玩家感知到,只是形式变弱。一个常见的设计失误是把降级链做成「弹窗 → 红点」,把「系统角标」和「推送」排除在外,结果玩家退出游戏后就彻底不知道有这回事了。

预算还有一个容易被忽略的维度:时机。同样六次打扰,均匀分散在一天的十二个时段和全部堆在晚高峰,效果完全不同。仿真里不需要建模这个,但工程上必须考虑:预算的消耗速率应该有时间分布,而不是一个单纯的计数器。

七、跨平台落地:Android 角标与 iOS 角标的真实约束

前面六节都是客户端内部的逻辑。到了最外层,提示要落到设备上,而两个平台的约束差异极大。

7.1 Android:角标不是一个 API,是一组被启动器决定的行为

Android 官方文档里,通知角标被称为 notification badge 或 notification dot。关键事实(引自 Android Developers 的通知角标文档):

从 Android 8.0(API 26)开始,通知角标——也叫通知小点——会在应用有活跃通知时出现在启动器图标上。用户长按应用图标可以展开这些通知以及应用快捷方式。

同页还给出了「禁用角标」的官方途径:在 NotificationChannel 上调用 setShowBadge(false),并列举了三类不该打角标的场景——持续性通知(图像处理、媒体播放控制、当前导航指令这类)、日历提醒(避免为当前时刻发生的事件打角标)、时钟或闹钟事件(避免为当前闹钟打角标)。

这一条对游戏客户端极其重要:很多游戏为了「让玩家知道后台还有事」而发一条常驻通知,但按官方建议,常驻通知恰恰是不该打角标的那一类。如果客户端用一个常驻前台服务通知来「顺便」维持角标,角标会变成永远亮着,等于完全没有信息量。

数字角标有两个 API,语义不同:

  • Notification.Builder.setNumber(int):官方描述是「设置这条通知代表的项目数量」。角标文档进一步说明,这个数字默认取通知里的通知数量,但可以覆盖——典型场景是你用一条通知代表多条消息,但想让角标显示消息的总条数。
  • Notification.Builder.setBadgeIconType(int):设置角标用什么图标表示,必须是 Notification.BADGE_ICON_NONE、BADGE_ICON_SMALL 或 Notification.BADGE_ICON_LARGE 之一。官方文档在这里给出了一条极其务实的提醒:

注意:这个值可能会被忽略,对于不支持角标图标的启动器来说。

而 Notification 的常量定义把这三种行为讲得更细:BADGE_ICON_NONE 的说明是「如果这条通知以角标形式显示,总是显示为数字」;BADGE_ICON_SMALL 用小图标;BADGE_ICON_LARGE 则用 getLargeIcon() 来表示这条通知。

翻译成工程结论:在 Android 上,角标能否显示、显示成什么样,是启动器的决定,不是应用的。 应用能控制的只有三件事——发不发通知、通知里带不带数字、用哪种角标图标类型。你无法保证用户看到的是数字,因为有些启动器会把它渲染成一个点,甚至完全不显示。

更麻烦的是厂商差异。开源库 ShortcutBadger(GitHub leolin310148/ShortcutBadger,实测 7326 星,最后推送 2023-09-14)专门解决「让 Android 应用像 iOS 那样在桌面快捷方式上显示未读数」,其 README 列出的已支持启动器包括 Sony、Samsung、LG、HTC、Xiaomi、ASUS、ADW 一长串——这个列表本身就说明了碎片化的严重程度:每家启动器的角标实现都是私有扩展,没有统一协议。

还有一个不可逆的约束,来自 Android 的通知渠道文档:

一旦把渠道提交给 NotificationManager,你就不能更改它的重要级别。不过,用户可以随时更改他们对你应用渠道的偏好。

这直接决定了一个常见的需求「按玩家偏好调整弹窗打扰强度」的实现方式:你不能在运行期改渠道的重要级别,只能新建一个渠道(Android 的惯例是带版本后缀,如 promo_v2),并且要处理旧渠道残留的通知。

渠道的重要级别有五档(IMPORTANCE_NONE(0) 到 IMPORTANCE_HIGH(4)),官方表格把 Android 8.0+ 的 IMPORTANCE_* 与 Android 7.1 及以下的 PRIORITY_* 一一对应:

  • Urgent:发出声音并以 heads-up 通知出现,对应 IMPORTANCE_HIGH 与 PRIORITY_HIGH 或 PRIORITY_MAX。
  • High:发出声音,对应 IMPORTANCE_DEFAULT 与 PRIORITY_DEFAULT。
  • Medium:不发出声音,对应 IMPORTANCE_LOW 与 PRIORITY_LOW。
  • Low:不发出声音且不出现在状态栏,对应 IMPORTANCE_MIN 与 PRIORITY_MIN。
  • None:不发出声音且不出现在状态栏或通知阴影区,对应 IMPORTANCE_NONE 与 PRIORITY_NONE。

注意这张表的两行不是恒等映射:IMPORTANCE_NONE 的语义是「完全不显示」,而 7.1 及以下的 PRIORITY_NONE 并不等价地意味着这个体验。文档同时提醒,为了兼容 Android 7.1(API 25)及以下,你必须为每条通知额外调用 setPriority(),使用 NotificationCompat 的优先级常量。跨端游戏客户端如果只设 IMPORTANCE_* 而不设 PRIORITY_*,在老设备上的打扰强度会与新设备不一致。

渠道还提供了 setShowBadge(false) 这条按渠道粒度关闭角标的开关——这给了游戏客户端一个精细控制手段:把「会产生红点的业务事件」单独放一个渠道,把「持续性通知」放另一个渠道并关闭角标,而不是全局一刀切。

权限侧还有一个硬门槛:Android 13(API 33)起,发送非豁免通知需要 POST_NOTIFICATIONS 运行时权限。也就是说在 Android 13+ 上,用户拒绝权限后,你的系统角标完全无从谈起。这意味着「角标」这件事在任何跨平台方案里都必须有一个降级路径——权限被拒时,红点只能活在应用内部。

7.2 iOS:角标计数是一个有明确世代的方法

iOS 侧的接口历史更短也更清晰。Apple 官方文档里有两个入口:

  • UNUserNotificationCenter.setBadgeCount(_:withCompletionHandler:),实测文档元数据标注的可用平台是 iOS 16.0、iPadOS 16.0、Mac Catalyst 16.0、macOS 13.0、tvOS 16.0、visionOS 1.0。
  • UIApplication.applicationIconBadgeNumber,文档元数据标注 iOS 2.0 引入、iOS 17.0 弃用,弃用消息原文是:

Use -[UNUserNotificationCenter setBadgeCount:withCompletionHandler:] instead.

所以在 iOS 17 及以后,新代码应当走 setBadgeCount,旧的 applicationIconBadgeNumber 只会得到一条弃用警告。iOS 17 之前的版本则仍需保留旧 API 分支——这是一个典型的「新 API 有最低系统版本要求,跨版本要双写」的客户端工程问题。

另一个细节是 UNNotificationContent.badge,它的类型是 NSNumber 而非整数——这意味着「清空角标」和「把角标设为 0」在语义上是可以区分的(空值 vs 0),在跨端统一建模时要小心别把两者混为一谈。

iOS 侧没有 Android 那种「角标样式由启动器决定」的问题,系统角标行为是统一的。但它有另一个约束:角标计数的推送与清除必须由客户端在正确时机调用,因为 setBadgeCount 是本地调用——服务端推一个「你有 3 条未读」不会自动更新桌面角标,客户端必须在收到推送或本地状态变化时自己调一次。这个责任落回客户端,正好落在本文讨论的聚合层末尾。

7.3 跨平台统一抽象与第三方实现的现状

跨平台方案(Flutter、React Native、Unity 插件)里,通知与角标是分开的两件事:

  • MaikuB/flutter_local_notifications(实测 2669 星,最后推送 2026-10-25)是本地通知插件,仓库结构显示它按平台拆成 flutter_local_notifications_platform_interface 以及 Linux、Windows、Web 等独立子包,角标计数也在其职责范围内。
  • invertase/notifee(实测 2170 星,最后推送 2026-04-07)是一个功能完整的通知库,但该仓库当前已归档(archived = true)。
  • zo0r/react-native-push-notification(实测 6789 星,最后推送 2024-01-30)同样已归档。
  • GetuiLaboratory/react-native-getui(实测 172 星,最后推送 2026-07-13)与 GetuiLaboratory/getui-flutter-plugin(实测 155 星,最后推送 2026-05-11)是国内推送服务厂商的官方跨端插件。

这些仓库状态给出一条明确的工程教训:通知与角标的跨端抽象层,如果押注在单个第三方库上,风险是它会被归档。 归档不等于不能用,但意味着不再有安全更新,而通知权限模型(Android 13 的 POST_NOTIFICATIONS)、系统行为(启动器角标规则)都在持续演进。更稳的做法是把「角标设置」这一层抽象留在自己手里——它本身只有两三个平台差异点,自己维护的成本远低于承受一个被归档依赖的风险。

7.4 降级链必须写下来

把上面所有约束汇总,客户端的提示能力有一条明确的降级链:

  • 应用内红点:能力最强,不受任何系统权限约束,是最后的兜底。
  • 系统角标:依赖系统通知能力。Android 受 POST_NOTIFICATIONS 权限与启动器实现双重约束;iOS 走 setBadgeCount,iOS 16 以下需回退 applicationIconBadgeNumber。
  • 推送通知:依赖通知权限与后台投递能力,是唯一能在应用不活跃时触达玩家的通道。
  • 全屏弹窗:只在应用前台可用,且必须消耗打扰预算。

这四层必须共用同一个聚合层的输出。如果角标走一套逻辑、推送走另一套逻辑、弹窗再走第三套,那么「今天最多打扰玩家六次」这条预算就形同虚设。

八、验证闭环:怎么证明这套东西是对的

提示系统最难的不是实现,是验证。它横跨多个业务模块,耦合在玩家的关键路径上,一个错误会直接变成「玩家看不到该看的东西」或者「玩家被烦到关游戏」。

我在实践中依赖三类证据。

第一类,不变量断言。 依赖图层面:每个注册中的入口订阅的信号数必须大于零;入口销毁钩子必须被调用(可以借用一个销毁计数与创建计数相等的全局断言来兜底)。聚合层层面:任何一个入口的提示状态必须能从它的信号集合重新算出来得到一致结果——这条可以写成随机的属性测试:随机翻转一个信号,比较增量重算的结果与全量重算的结果,必须逐入口相等。这条测试能一次性抓住聚合逻辑里绝大多数的顺序依赖错误。

第二类,预算与公平性的量化指标。 不要用「感觉弹太多了」来驱动优化,要用可计算指标。本文仿真里用的两个指标就很好用:展示次数的基尼系数衡量提示在不同运营位之间的分布是否均衡,无效打扰比例衡量弹出的提示里有多少是玩家根本不关心的。这两个数字都可以在客户端埋点里实时算出来,一旦某个版本上线后基尼系数从 0.31 跳到 0.75,就说明预算分配的某处逻辑被改坏了。

第三类,跨设备一致性抽样。 角标是最典型的「客户端说一套、用户看到另一套」的特性和渠道——Android 上启动器可能不显示数字,iOS 上系统角标受系统限制。「数字角标」在客户端内部计算,和「用户实际看到的角标」是两件事,不能混为一谈。 客户端内部只能保证自己算对了,用户看到什么是操作系统和启动器决定的。

跨设备一致性抽样可以这样设计:内部埋点上报「客户端计算出的角标值」,同时在特定渠道收集「用户实际看到的角标」样本,两者比对差异比例。差异比例超出阈值时告警。这条链路能把「某厂商 ROM 把数字角标渲染成圆点」这类问题在半天内暴露出来,而不是等客服工单堆积。

九、失败模式清单与装配清单

把全文压缩成可执行的清单。

六个典型失败模式:

  • F1 业务逻辑写在 UI 里。 每个入口各写一份判断逻辑,改版必然不一致。防线:信号层独立,入口只注册依赖。
  • F2 悬空边。 入口销毁未注销依赖,红点永远不消失。防线:创建/销毁计数相等的全局断言。
  • F3 全量重算当成性能优化。 以为「少算几次」一定更快,忽略了每步开销。防线:分别度量访问次数与单步耗时,脏标记加帧末合并加延迟计算三层一起上。
  • F4 计数直接相加当总数。 跨域重复导致系统性偏高(实测偏高占比 85.31%)。防线:信号携带事项 ID,聚合层去重。
  • F5 用时间窗口做幂等。 TTL 取太大误抑制合法事件,取太小漏掉重复投递(实测 TTL=2 秒时漏抑制率 51.34%)。防线:按事件 ID 去重,TTL 只做集合回收。
  • F6 四层提示各行其是。 角标、推送、弹窗、红点互不知情,打扰预算失效。防线:四层共用同一份聚合输出与同一份预算。

装配顺序:

  • 第一步,定义信号。 把所有业务域的提示需求逐条落成信号,标注值域(布尔 / 计数 / 枚举)、聚合规则、以及是否需要携带事项 ID。
  • 第二步,建依赖图。 静态依赖写配表,动态与条件依赖写显式生命周期钩子。
  • 第三步,实现聚合。 脏标记 + 帧末合并 + 不可见入口延迟计算,配上「增量结果等于全量结果」的属性测试。
  • 第四步,实现预算。 明确打扰上限、时间分布、降级链;接入玩家兴趣信号;上线基尼系数与无效打扰比例的监控。
  • 第五步,接平台层。 Android 注意 setNumber 与 setBadgeIconType 的语义、BADGE_ICON_* 三档、渠道重要级别不可逆、setShowBadge(false) 按渠道关闭、以及 Android 13 的 POST_NOTIFICATIONS 权限;iOS 注意 iOS 16 起走 setBadgeCount、iOS 17 弃用 applicationIconBadgeNumber、以及 UNNotificationContent.badge 是 NSNumber。
  • 第六步,做验证闭环。 不变量断言进 CI,公平性指标进监控,跨设备一致性抽样进告警。

这套东西的复杂度不在任何单个模块,而在四个模块必须共享同一份语义。信号层定义「什么值得提示」,依赖图层定义「谁需要知道」,聚合层定义「提示是什么状态」,预算层定义「什么时候可以打扰玩家」。只要这四层共用同一份状态,角标、推送、弹窗、红点就自然一致;一旦有一层自己另起炉灶,玩家就会在某个界面看到和别的界面不一样的数字。

参考文献

  1. Android Developers, Modify a notification badge, https://developer.android.com/develop/ui/views/notifications/badges
  2. Android Developers, Notification channels, https://developer.android.com/develop/ui/views/notifications/channels
  3. Android Developers, Notification runtime permission(POST_NOTIFICATIONS), https://developer.android.com/develop/ui/views/notifications/notification-permission
  4. Android API Reference, Notification.Builder#setNumber(int), https://developer.android.com/reference/android/app/Notification.Builder
  5. Android API Reference, Notification.Builder#setBadgeIconType(int), https://developer.android.com/reference/android/app/Notification.Builder
  6. Android API Reference, Notification(BADGE_ICON_NONE / BADGE_ICON_SMALL / BADGE_ICON_LARGE), https://developer.android.com/reference/android/app/Notification
  7. Android API Reference, NotificationManager(IMPORTANCE_* 常量), https://developer.android.com/reference/android/app/NotificationManager
  8. Jetpack Compose Documentation, Badges(BadgedBox / Badge), https://developer.android.com/develop/ui/compose/components/badges
  9. Apple Developer Documentation, UNUserNotificationCenter.setBadgeCount(_:withCompletionHandler:), https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/setbadgecount(_:withcompletionhandler:)
  10. Apple Developer Documentation, UIApplication.applicationIconBadgeNumber(iOS 17.0 弃用), https://developer.apple.com/documentation/uikit/uiapplication/applicationiconbadgenumber
  11. Apple Developer Documentation, UNNotificationContent.badge, https://developer.apple.com/documentation/usernotifications/unnotificationcontent/badge
  12. Apple Developer Documentation, UNUserNotificationCenterDelegate, https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate
  13. GitHub, leolin310148/ShortcutBadger(Android 各厂商启动器角标兼容), https://github.com/leolin310148/ShortcutBadger
  14. GitHub, MaikuB/flutter_local_notifications, https://github.com/MaikuB/flutter_local_notifications
  15. GitHub, invertase/notifee(已归档), https://github.com/invertase/notifee
  16. GitHub, zo0r/react-native-push-notification(已归档), https://github.com/zo0r/react-native-push-notification
  17. GitHub, GetuiLaboratory/react-native-getui, https://github.com/GetuiLaboratory/react-native-getui
  18. GitHub, GetuiLaboratory/getui-flutter-plugin, https://github.com/GetuiLaboratory/getui-flutter-plugin
←返回文章列表

Related

可能也会喜欢

  • AI 商店评论挖掘工程 2026:从玩家反馈到版本决策11月1日
  • 客户端打击感工程 2026:命中反馈的时序与预算10月31日
  • 游戏内广告变现工程 2026:eCPM 预测与广告负载决策10月31日

Conversation

0 条

留下你的想法

加载评论中…

New comment