推送是一条会断的链路
前两节把未读数合并稳定了,也把已读位置对齐了,但它们都默认「新消息到达时端上知道」。现实里,端上知道的方式只有两条:应用开着时靠长连接或轮询,应用没开时靠系统推送。系统推送不是一条永远可用的通道,它会因为权限、系统开关、令牌轮换随时断掉,而且断掉时通常没有任何报错。
这一节只讨论推送不可用时怎么补:三种失效如何区分、各自的恢复动作是什么、打开应用时如何做补偿同步、推送携带的角标与本地已读谁优先。它不重复未读数的来源合并(10.1),也不重复多端已读位置的计算(10.2);我们假定数字本身已经能算对,问题只在于「什么时候算」。
四种状态,不是「有推送 / 没推送」
把推送当成布尔值,会导致所有失败都走同一条兜底路径:关掉通知的用户和令牌过期的用户看到同样的提示,而后者其实只需要客户端重新注册一次。实际至少有三层状态:
| 层 | 取值 | 客户端能做什么 |
|---|---|---|
| 用户权限 | granted / denied / prompt | 请求一次;被拒后只能引导去系统设置 |
| 系统开关 | 开 / 关 | 只能引导去设置,应用内无法绕过 |
| 推送令牌 | valid / expired / missing | 重新注册,属于客户端可自动修复 |
| 应用前后台 | 前台 / 后台 | 前台不展示系统通知,改用应用内提醒 |
这四层要按顺序判断。权限是 prompt 时,先请求一次;已经是 denied 时,再请求也不会弹系统弹窗,只会静默失败,必须跳设置。系统开关关闭时,权限可能仍是 granted,但通知不会展示。令牌失效时,权限和开关都可能正常,推送服务却投递不到这台设备。同样是「收不到推送」,恢复动作完全不同。
权限:能问、不能再问、只能去设置
权限请求只值得在用户能理解上下文的时刻发一次,例如用户第一次关注某人、第一次打开通知设置页。冷启动就弹窗,拒绝率最高,而且被拒之后没有第二次机会。
被拒之后的正确动作不是反复请求,而是:
- 在通知设置页明确说明「系统通知已关闭,可能错过评论和关注」;
- 提供一个按钮跳转到系统设置;
- 用户从设置返回时重新读取权限,而不是假设他已经打开;
- 在权限恢复前,所有提醒走应用内通道,不假装推送还在工作。
「重新读取」这一步经常被漏掉。用户在设置里打开了通知,回到应用,客户端仍缓存着 denied,于是继续不注册令牌、不更新角标,直到下次冷启动才恢复。正确做法是监听应用回到前台的事件,每次前台恢复都重新检查权限与令牌。
令牌失效是服务端可修复的
推送令牌会过期、会被系统轮换、会在重装后变化。这类失败不应该打扰用户,客户端在登录后、启动时、以及收到「令牌无效」响应时重新注册即可。
需要区分两种失败:注册请求失败(网络问题,稍后重试)和注册成功但投递失败(服务端或系统侧问题)。前者客户端能自愈,后者只能等。把两者都提示成「请打开通知权限」,会让用户去设置里反复确认一个本来就开着的开关。
令牌重新注册完成之前,应用仍处于降级态:打开应用要补偿同步,角标要来自服务端快照,而不是等推送。
打开应用时的补偿同步
推送是加速器,不是事实来源。事实来源永远是服务端:未读总数、已读位置、通知列表都在服务端。只要推送链路不完整,打开应用就必须做一次完整同步;即使推送正常,距上次同步超过阈值也应该同步,因为推送可能丢、可能延迟、可能被系统折叠。
补偿同步至少包含四件事:
- 合并服务端已读位置(10.2 的最大位置规则);
- 拉取未读总数与分类计数(10.1 的快照与水位);
- 拉取通知列表第一页,保证点进去能看到内容;
- 用同一次快照原子地更新角标,避免先显示旧值再跳。
顺序很重要。如果先渲染角标、再合并已读位置,用户会看到角标先变大再变小;如果先拉列表、后拉总数,列表里的条数和角标可能短暂不一致。把「读位置、总数、列表、角标」放在同一个同步事务里,渲染层只接收最终结果。
同步阈值是产品与成本的权衡。太短会在每次切前台时发请求,耗电且浪费流量;太长会让推送丢失后的空窗期变长。五分钟是一个常见起点,但要根据消息类型调整:私信可以更短,系统公告可以更长。
角标与推送是两条通道
推送载荷里经常带一个角标数字。这个数字是发送时刻的快照,不是实时值。用户读完消息、本地角标清零之后,一条两分钟前发出的推送才到达,如果直接用它覆盖,角标会重新亮起,用户以为又有新消息。
正确的合并规则是给本地角标和推送角标都带时间:
- 本地每次已读、每次同步成功,都更新
updatedAt; - 推送携带
sentAt; sentAt不晚于本地updatedAt的推送角标直接丢弃;- 时间相同时本地优先,因为用户的已读操作发生在端上,不能被一条同时刻的推送覆盖。
更严谨的做法是让推送携带服务端的已读位置或水位,端上按位置比较,而不是按时间比较。时间只能解决「明显更旧」的情况,解决不了时钟偏差与同毫秒竞态;但在推送载荷无法扩展时,sentAt 是最低成本的保护。
推送点击还要处理冷启动:用户点通知进入应用时,应用可能是被系统重新拉起的,内存里没有未读状态。这时不能假设推送里的角标就是最新值,仍然要走一次同步,再决定跳到哪个页面。
降级矩阵
把上面的判断固化成矩阵,客户端就不需要在每个调用点重新想一遍:
| 场景 | 系统推送 | 应用内提醒 | 恢复动作 | 打开应用 |
|---|---|---|---|---|
权限 prompt | 不可用 | 可用 | 请求一次权限 | 强制同步 |
权限 denied | 不可用 | 可用 | 跳系统设置 | 强制同步 |
| 系统开关关闭 | 不可用 | 可用 | 跳系统设置 | 强制同步 |
| 令牌失效 | 不可用 | 可用 | 重新注册令牌 | 强制同步 |
| 应用在前台 | 不展示 | 可用 | 前台轮询或长连接 | 按阈值同步 |
| 链路正常且在后台 | 可用 | 不需要 | 无 | 按阈值同步 |
矩阵的价值不在于穷举,而在于让每个分支都有明确的「下一步」。没有恢复动作的分支,最终都会变成用户永远收不到提醒。
一个可运行的降级计划器
下面的 push-fallback.ts 属于 L1:它不调用任何系统 API,只根据传入的状态返回通道、动作与原因;同步判断与角标合并也是纯函数,测试不需要真实定时器或推送服务。
// file: src/notify/push-fallback.ts
// tier: L1
// verify: node --test test/notify/push-fallback.test.ts
export type PermissionState = "granted" | "denied" | "prompt";
export type TokenState = "valid" | "expired" | "missing";
export interface PushEnv {
permission: PermissionState;
systemEnabled: boolean;
token: TokenState;
appForeground: boolean;
}
export type PushChannel = "system" | "in-app";
export type PushAction =
| "request-permission"
| "open-settings"
| "register-token"
| "sync-on-open"
| "foreground-poll";
export interface PushPlan {
channel: PushChannel;
systemReachable: boolean;
actions: PushAction[];
reason: string;
}
// 降级不是「没有推送就什么都不做」,而是按失败原因给出不同的恢复动作。
export function planPush(env: PushEnv): PushPlan {
if (env.permission === "prompt") {
return {
channel: "in-app",
systemReachable: false,
actions: ["request-permission", "sync-on-open"],
reason: "尚未授权,先向用户请求一次",
};
}
if (env.permission === "denied") {
return {
channel: "in-app",
systemReachable: false,
actions: ["open-settings", "sync-on-open"],
reason: "权限被拒,只有用户去系统设置才能恢复",
};
}
if (!env.systemEnabled) {
return {
channel: "in-app",
systemReachable: false,
actions: ["open-settings", "sync-on-open"],
reason: "系统通知开关关闭,应用内无法绕过",
};
}
if (env.token !== "valid") {
return {
channel: "in-app",
systemReachable: false,
actions: ["register-token", "sync-on-open"],
reason: env.token === "expired" ? "推送令牌过期,需要重新注册" : "推送令牌缺失,需要重新注册",
};
}
if (env.appForeground) {
return {
channel: "in-app",
systemReachable: false,
actions: ["foreground-poll"],
reason: "应用在前台,系统通知不展示,改用应用内提醒",
};
}
return { channel: "system", systemReachable: true, actions: [], reason: "推送链路可用" };
}
export const SYNC_MAX_AGE_MS = 5 * 60_000;
export interface SyncDecision {
shouldSync: boolean;
full: boolean;
reason: string;
}
// 推送只是加速器;只要链路不完整,打开应用就必须做一次完整补偿同步。
export function decideOpenSync(
env: PushEnv,
now: number,
lastSyncAt: number,
maxAgeMs = SYNC_MAX_AGE_MS,
): SyncDecision {
if (!Number.isInteger(maxAgeMs) || maxAgeMs < 0) throw new Error("maxAgeMs 必须是非负整数");
const degraded =
env.permission !== "granted" || !env.systemEnabled || env.token !== "valid";
if (degraded) {
return { shouldSync: true, full: true, reason: "推送链路不可用,打开应用必须补偿同步" };
}
const age = Math.max(0, now - lastSyncAt);
if (age >= maxAgeMs) {
return { shouldSync: true, full: true, reason: "距上次同步超过阈值" };
}
return { shouldSync: false, full: false, reason: "推送可用且同步足够新" };
}
export interface BadgeState {
total: number;
updatedAt: number;
}
export interface PushBadgePayload {
badge: number;
sentAt: number;
}
// 推送携带的角标是发送时刻的快照。晚到的旧快照不能把用户刚读掉的数字重新点亮。
export function applyPushBadge(local: BadgeState, payload: PushBadgePayload): BadgeState {
if (!Number.isInteger(local.total) || local.total < 0) throw new Error("local.total 必须是非负整数");
if (!Number.isInteger(local.updatedAt) || local.updatedAt < 0) {
throw new Error("local.updatedAt 必须是非负整数");
}
if (!Number.isInteger(payload.badge) || payload.badge < 0) {
throw new Error("payload.badge 必须是非负整数");
}
if (!Number.isInteger(payload.sentAt) || payload.sentAt < 0) {
throw new Error("payload.sentAt 必须是非负整数");
}
// 同一毫秒时本地优先:用户刚做的已读操作不应被一条同时刻的推送覆盖。
if (payload.sentAt <= local.updatedAt) return { ...local };
return { total: payload.badge, updatedAt: payload.sentAt };
}
测试与真实输出
本机运行结果如下:
$ node --test test/notify/push-fallback.test.ts
✔ 权限、系统开关、令牌都正常时走系统推送 (0.805667ms)
✔ 权限被拒时只能应用内提醒,并引导去系统设置 (0.116708ms)
✔ 尚未授权时先请求一次,而不是直接放弃 (0.06025ms)
✔ 系统通知开关关闭与权限被拒需要同样的设置引导 (0.060916ms)
✔ 令牌失效时先重新注册,注册完成前仍按降级处理 (0.542958ms)
✔ 应用在前台时不弹系统通知,改用应用内轮询 (0.131041ms)
✔ 推送链路不可用时打开应用强制补偿同步 (0.145833ms)
✔ 迟到的推送角标不覆盖更新的本地已读 (0.114ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 117.794583
八条用例覆盖了正常链路、权限待请求、权限被拒、系统开关、令牌失效、前台抑制、补偿同步判断与旧角标丢弃。它们证明的是决策逻辑,不是系统 API 的真实行为;权限弹窗、设置跳转与令牌注册仍然必须在真机上验证。
失败复盘:关掉通知的用户永远看不到红点
「回声」早期只在推送处理器里更新角标:收到推送,解析 badge,写入本地,渲染。这个设计默认「有未读就一定有推送」,而通知权限被拒的用户恰好推翻了这个前提。
反馈很一致:用户说「我明明关掉了通知,但打开应用也看不到红点」。排查发现,应用内列表其实有 20 条未读,但角标只在推送到达时更新,推送被系统拦住,角标就永远是 0。更糟的是,打开应用时客户端还会发一次「标记已读」的回执,把服务端未读也清掉了,用户彻底失去线索。
修复分三步。第一,角标不再依赖推送:启动、回前台、权限变化时都从服务端拉一次快照,用快照渲染角标。第二,权限被拒时不再发送自动已读回执,只有用户真正打开通知页才标记已读。第三,降级状态下每次打开应用都强制完整同步,不再等推送。上线后,「看不到红点」的反馈消失,代价是这部分用户每次打开应用多一个请求。
第二个事故是旧角标复活。用户读完 5 条,角标本应清零;两分钟前发出的一条推送这时才到达,携带 badge: 5,客户端直接覆盖,红点重新亮起。修复是给本地角标记录 updatedAt,推送携带 sentAt,旧于本地的直接丢弃。这个修复很小,但它把「推送是快照」这件事写进了代码,而不是留在文档里。
补偿同步的代价与边界
补偿同步不是免费的。冷启动时多一次请求会拉长首屏时间,频繁切前台会耗电,弱网下还可能失败。工程上要做三件事:
- 给同步设最小间隔,避免前后台切换抖动触发连续请求;
- 同步失败时保留上一次的角标与列表,并标记「数据可能不是最新」,不要清空;
- 把「完整同步」和「只刷角标」分开,前者用于降级恢复,后者用于阈值内的轻量刷新。
多账号场景还要注意令牌与角标都按账号隔离。同一个设备切换账号后,如果沿用旧令牌,推送会投递到错误的人;如果沿用旧角标,新账号会看到别人的未读数。账号切换时必须清空本地通知状态并重新注册令牌。
最后是隐私边界。锁屏通知的预览内容、角标数字本身都可能泄露信息;这些属于通知内容的展示策略,不属于投递链路,但客户端在降级与补偿时同样要遵守:同步下来的内容不能因为「反正是自己拉的」就绕过锁屏策略。
小结
推送是一条会断的链路,降级要按原因分流:权限待请求就请求一次,权限被拒与系统开关关闭只能引导设置,令牌失效由客户端重新注册,前台则改用应用内提醒。推送不可用时,打开应用必须补偿同步;角标来自服务端快照,旧推送不能覆盖新已读。把恢复动作写进矩阵,才能避免「收不到推送」变成「永远没有提醒」。
三节合起来,通知与未读数这条链路才算闭合:10.1 决定数字怎么合并,10.2 决定已读以谁为准,10.3 决定没有推送时怎么补。下一章离开社交互动,进入内容审核与安全边界,看端上预检能拦什么、不能拦什么。
阅读导航:上一节:10.2 多端已读:一致性从哪里来 · 下一节:11.1 端上预检:能拦什么、不能拦什么 。
常见错误与边界
- 把推送当成布尔值,权限被拒与令牌失效走同一条兜底路径。
- 冷启动就请求通知权限,被拒后反复请求,系统不再弹窗。
- 用户从设置返回后不重新读取权限,仍按缓存的
denied运行。 - 令牌失效时提示用户去开权限,而客户端其实可以自动重新注册。
- 角标只在推送处理器里更新,关掉通知的用户永远看不到红点。
- 直接用推送携带的角标覆盖本地,迟到的旧推送让红点复活。
- 降级状态下打开应用不做补偿同步,等推送等到天荒地老。
- 多账号切换时沿用旧令牌或旧角标,推送与数字串到另一个账号。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。