推送是子女端 App 的生命线。老人摔倒、紧急呼叫、健康告警、睡眠异常……所有关键事件都靠极光推送触达子女。但推送要工作,前提是 App 必须把极光
registrationId上传到自己的服务器,让服务器知道"这个用户的推送通道在哪"。看似简单的一步,却是最容易掉链子的环节——网络弱、接口超时、用户未登录、SDK 初始化慢……任何一个原因都会导致上传失败。一旦失败,用户后续所有推送都收不到。
这篇博客记录我设计的指数退避重试机制,以及踩过的几个大坑。
一、问题背景:为什么上传极光 ID 这么难?
正常的上传流程:
用户登录 → 初始化极光 SDK → 获取 registrationId → 调用接口上传到服务器
每一步都可能出问题:
-
极光 SDK 异步初始化:
getRegistrationID()可能在 SDK 还没初始化完成时被调用,返回空。 - 网络不稳定:弱网下接口超时,但 SDK 已经返回了 ID,重试时如果不去重新获取,可能还是用旧 ID。
-
用户未登录:用户注销后,
UserManager.instance.isLogin是 false,不应该上传。 - 接口偶发失败:服务器 5xx,但 ID 是有效的,重试就能成功。
- 流程触发时机散落:登录后、App 回到前台、获取权限后,多个时机都可能触发上传,要避免重复上传。
最早的代码就是一个简单的 try-catch:
void uploadJPushId() async {
try {
final regId = await jpush?.getRegistrationID();
await MeServiceApi.updateUserInfo(registrationId: regId);
} catch (e) {
LogUtil.e(tag, "上传失败: $e");
}
}
这种代码上线后,线上日志里大量"上传失败",但没人知道,也没人能恢复。用户卸载重装 App 才能恢复推送——这是不可接受的体验。
二、状态管理:四个 bool 锁住流程
要解决问题,先要管住状态。我加了四个 bool:
class JpushManager {
///是否可以开始上传极光ID
bool canUploadId = false;
///极光ID是否已上传成功
bool finishUpload = false;
///正在上传中
bool uploading = false;
///是否已发起过重试(防止多次触发重试入口)
bool _hasAttemptedRetryUpload = false;
}
每个 bool 的职责:
-
canUploadId:是否满足前置条件(用户已授权推送) -
finishUpload:是否已成功上传(成功后不再上传) -
uploading:是否正在上传中(防止并发上传) -
_hasAttemptedRetryUpload:是否已触发过重试(防止多次触发重试流程)
这四个 bool 组成一个状态机:
canUploadId=false → canUploadId=true → uploading=true → finishUpload=true
↓ 失败
uploading=false
↓
_hasAttemptedRetryUpload=true
↓
重试流程(最多5次)
三、主入口:checkUploadId
主入口 checkUploadId 处理所有触发场景:
Future<void> checkUploadId() async {
final canPush = await PermissionUtil.hadPushPermission();
if (canPush) {
canUploadId = true;
}
// 可以开始上传,且还没上传完成,且不是上传中
if (canUploadId &&
!finishUpload &&
!uploading &&
UserManager.instance.id != null) {
try {
uploading = true;
final regId = await jpush?.getRegistrationID()
.timeout(const Duration(seconds: 3));
if (regId != null && regId.isNotEmpty && UserManager.instance.isLogin) {
LogUtil.e(tag, "registrationID:$regId");
final result = await MeServiceApi.updateUserInfo(registrationId: regId);
if (result.isSuccess) {
LogUtil.e(tag, "极光id上传成功registrationID:$regId");
finishUpload = true;
} else {
// 接口返回失败
final errorMsg = "极光ID上传接口返回失败 - registrationId: $regId, ...";
LogUtil.e(tag, errorMsg);
ErrorUtil.reportCustomError(errorMsg, detail: "接口返回: msg=${result.msg}");
_triggerRetryIfNeeded();
}
} else {
if (regId == null || regId.isEmpty) {
// ID 为空,触发重试
final errorMsg = "获取registrationId失败 - ...";
LogUtil.e(tag, errorMsg);
ErrorUtil.reportCustomError(errorMsg, detail: "...");
_triggerRetryIfNeeded();
} else if (!UserManager.instance.isLogin) {
LogUtil.i(tag, "用户未登录,跳过上传极光ID");
}
}
} catch (e, stackTrace) {
final errorMsg = "上传极光ID异常 - error: ${e.toString()}, ...";
LogUtil.e(tag, errorMsg);
ErrorUtil.reportCustomError(errorMsg, detail: "堆栈信息: $stackTrace...");
} finally {
LogUtil.e(tag, "结束=========finally===");
uploading = false;
}
}
}
几个关键设计:
1. timeout(const Duration(seconds: 3))
getRegistrationID() 在弱网下可能卡 10 秒以上。3 秒超时是经过权衡的——太短会误判 SDK 初始化中,太长会让主流程卡住。
2. UserManager.instance.isLogin 二次检查
进入方法时检查了一次 id != null,但 await 之后用户可能注销了。await 之后必须重新检查 isLogin,否则会把已注销用户的 ID 上传上去。
3. finally 释放 uploading
无论成功失败,uploading 都要在 finally 里置 false。如果不释放,下一次 checkUploadId 会被 uploading 挡住,永远无法重试。
4. 失败时上报自定义错误
ErrorUtil.reportCustomError 是项目里的错误上报工具,会把错误堆栈发到 Bugly。上传失败这种"沉默的错误"必须上报,否则线上问题没法发现。
错误消息里特意带了 phone 和 userId:
final errorMsg = "极光ID上传接口返回失败 - registrationId: $regId, "
"phone: ${UserManager.instance.user?.phone} "
"userId: ${UserManager.instance.id}, "
"result: ${result.toString()}";
这是为了线上排障。看到错误日志时,能立即定位是哪个用户出问题,方便复现。
四、触发重试:_triggerRetryIfNeeded
void _triggerRetryIfNeeded() {
if (!_hasAttemptedRetryUpload && UserManager.instance.isLogin) {
_hasAttemptedRetryUpload = true;
LogUtil.i(tag, "触发重试上传极光ID");
retryUploadJPushId();
}
}
关键点:_hasAttemptedRetryUpload 只允许触发一次。
为什么不允许多次触发?因为 checkUploadId 可能在多个时机被调用:
- 用户登录后
- App 回到前台
- 获取推送权限后
- 接口请求失败后
如果不限制,每个时机都触发一次重试,会有多个重试流程并发执行,互相干扰。
_hasAttemptedRetryUpload 是"全局只触发一次"的开关。一旦触发,后续所有失败都靠那一次重试流程处理。
五、指数退避:retryUploadJPushId
Future<void> retryUploadJPushId() async {
const maxRetries = 5;
const maxDelay = Duration(minutes: 2);
for (int i = 0; i < maxRetries; i++) {
if (finishUpload) {
LogUtil.i(tag, "极光ID已成功上传,停止重试");
return;
}
// 指数退避:2, 4, 8, 16, 32 秒
int delaySeconds = pow(2, i + 1).toInt();
Duration delay = Duration(seconds: delaySeconds);
if (delay > maxDelay) delay = maxDelay;
LogUtil.i(tag, "第 ${i + 1} 次重试,等待 ${delay.inSeconds} 秒后开始");
await Future.delayed(delay);
// 等待期间可能已被其他流程上传成功
if (finishUpload) {
LogUtil.i(tag, "等待期间极光ID已成功上传,停止重试");
return;
}
LogUtil.i(tag, "开始第 ${i + 1} 次重试上传极光ID");
await _doUpload();
if (finishUpload) {
LogUtil.i(tag, "第 ${i + 1} 次重试上传成功");
return;
}
}
final errorMsg = "极光ID重试上传失败 - 已达到最大重试次数($maxRetries), ...";
LogUtil.w(tag, errorMsg);
ErrorUtil.reportCustomError(errorMsg, detail: "...");
}
指数退避的核心:
int delaySeconds = pow(2, i + 1).toInt(); // 2, 4, 8, 16, 32...
| 第几次重试 | 等待时间 |
|---|---|
| 1 | 2 秒 |
| 2 | 4 秒 |
| 3 | 8 秒 |
| 4 | 16 秒 |
| 5 | 32 秒 |
为什么用指数退避而不是固定间隔?
- 第 1 次重试:可能是偶发失败,立即重试(2 秒后)就能成功
- 第 2-3 次:可能是临时网络问题,等几秒等网络恢复
- 第 4-5 次:可能是服务器问题,等更久避免给服务器压力
如果是固定 5 秒间隔,5 次重试 25 秒内全部完成,弱网下根本没等来网络恢复就放弃了。指数退避总时长 62 秒,覆盖了大部分临时故障的恢复周期。
maxDelay = Duration(minutes: 2) 是上限保护。如果指数退避算出来超过 2 分钟(比如第 7 次 128 秒),就限制为 2 分钟。避免后端临时故障时,客户端等待过久。
六、_doUpload:不递归的上传逻辑
Future<void> _doUpload() async {
if (!UserManager.instance.isLogin || UserManager.instance.id == null) {
LogUtil.i(tag, "_doUpload: 用户未登录,跳过");
return;
}
if (uploading) {
LogUtil.i(tag, "_doUpload: 正在上传中,跳过本次重试");
return;
}
try {
uploading = true;
final regId = await jpush?.getRegistrationID().timeout(const Duration(seconds: 5));
if (regId == null || regId.isEmpty) {
LogUtil.e(tag, "_doUpload: 获取registrationId为空");
return;
}
final result = await MeServiceApi.updateUserInfo(registrationId: regId);
if (result.isSuccess) {
LogUtil.e(tag, "_doUpload: 上传成功 registrationId=$regId");
finishUpload = true;
} else {
LogUtil.e(tag, "_doUpload: 上传失败 msg=${result.msg}");
}
} catch (e) {
LogUtil.e(tag, "_doUpload: 异常 ${e.toString()}");
} finally {
uploading = false;
}
}
为什么不直接调用 checkUploadId,而是写一个 _doUpload?
checkUploadId 内部失败时会调用 _triggerRetryIfNeeded,而 _triggerRetryIfNeeded 又会调用 retryUploadJPushId,retryUploadJPushId 里调用 _doUpload。
如果 _doUpload 改成 checkUploadId,就形成递归:
checkUploadId → 失败 → _triggerRetryIfNeeded → retryUploadJPushId → checkUploadId → 失败 → ...
递归会无限触发重试,违反"只触发一次"的设计。所以 _doUpload 是"纯净版"的上传逻辑,不触发重试,让 retryUploadJPushId 的 for 循环控制重试次数。
uploading 检查也很关键。重试期间,可能 App 回到前台触发了 checkUploadId,正在上传中。这时 _doUpload 跳过本次重试,避免并发上传。
七、超时差异:3 秒 vs 5 秒
注意两个超时不一样:
// checkUploadId 里
final regId = await jpush?.getRegistrationID().timeout(const Duration(seconds: 3));
// _doUpload 里
final regId = await jpush?.getRegistrationID().timeout(const Duration(seconds: 5));
为什么主流程 3 秒,重试 5 秒?
- 主流程:用户在等,要快速失败,3 秒
- 重试:后台静默执行,可以多等一会儿,5 秒
这是性能和可靠性的权衡。主流程优先保证响应速度,重试优先保证成功率。
八、生命周期监听:App 回到前台时检查
void init({bool hadPermission = false}) {
// ...
AppLifecycleStatus.instance.addAppLifeCycleListen((value) {
if (value == AppLifecycleState.resumed) {
LogUtil.e(tag, "checkUploadId========监听生命周期");
checkUploadId();
clearBadge();
}
});
// ...
}
为什么回到前台要重新检查?
iOS 上 App 后台超过 30 秒,网络连接会被系统断开。如果上传时 App 进了后台,可能网络中断导致失败。回到前台时重新检查,能恢复这种"沉默失败"。
AppLifecycleStatus 是项目里封装的生命周期管理器:
class AppLifecycleStatus extends WidgetsBindingObserver {
static AppLifecycleStatus? _instance;
late Rx<AppLifecycleState> _state;
bool get isResume => _state.value == AppLifecycleState.resumed;
bool get isHidden => _state.value == AppLifecycleState.hidden;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
_state.value = state;
}
Worker addAppLifeCycleListen(ValueChanged<AppLifecycleState> onChanged) {
return ever(_state, onChanged);
}
}
用 Rx<AppLifecycleState> + ever 监听,比直接用 WidgetsBindingObserver 更灵活——可以在任意位置注册监听,不用层层传递 observer。
九、状态重置:resetUploadStatus
用户注销时需要重置所有状态:
void resetUploadStatus() {
canUploadId = false;
finishUpload = false;
uploading = false;
_hasAttemptedRetryUpload = false;
}
void clearAll() {
clearBadge();
clearNotification();
clearInitNotification();
resetUploadStatus();
}
为什么注销时要重置? 用户注销后可能登录另一个账号,新账号需要重新上传极光 ID。如果不重置,finishUpload = true 会让新账号永远上传不了,新账号收不到推送。
这是非常隐蔽的 bug——一个设备上登录两个账号,第二个账号收不到推送,用户根本不知道是上传失败。resetUploadStatus 是必须的。
十、踩过的坑
坑 1:递归触发重试
最早版本 retryUploadJPushId 失败时调用 checkUploadId,导致递归:
retryUploadJPushId → checkUploadId → 失败 → _triggerRetryIfNeeded → retryUploadJPushId → ...
重试次数指数级增长,几秒钟内发起几十次请求,触发服务端限流。
修复: 拆出 _doUpload,不调用 checkUploadId,让 for 循环控制次数。
坑 2:上传成功的时机错过
某次重试期间,用户在另一个页面触发了 checkUploadId 并上传成功。但重试流程不知道,继续重试。
修复: 每次 await Future.delayed 后都检查 finishUpload:
await Future.delayed(delay);
// 等待期间可能已被其他流程上传成功
if (finishUpload) {
LogUtil.i(tag, "等待期间极光ID已成功上传,停止重试");
return;
}
这是"乐观锁"思想——重试前先 check 状态,避免无意义的工作。
坑 3:用户注销后还在重试
用户注销账号时,重试流程还在执行,把已注销用户的 ID 上传上去。
修复: _doUpload 里检查 isLogin:
if (!UserManager.instance.isLogin || UserManager.instance.id == null) {
LogUtil.i(tag, "_doUpload: 用户未登录,跳过");
return;
}
但这里只是跳过本次,重试流程会继续。更彻底的修复是注销时取消正在执行的重试——这需要把 retryUploadJPushId 改成可取消的,复杂度会上升,暂时没做。
坑 4:getError 信息丢失
最早的错误上报只有 e.toString(),没有任何上下文。线上日志看到 上传极光ID异常 - error: TimeoutException,根本不知道是哪个用户、哪个手机号、什么时候出的问题。
修复: 错误消息里拼上 phone、userId、registrationId、状态信息:
final errorMsg = "上传极光ID异常 - error: ${e.toString()}, "
"userId: ${UserManager.instance.id} "
"phone: ${UserManager.instance.user?.phone}";
LogUtil.e(tag, errorMsg);
ErrorUtil.reportCustomError(errorMsg, detail: "堆栈信息: $stackTrace\ncanUploadId: $canUploadId, finishUpload: $finishUpload");
线上排障的核心是上下文。一个错误不带上下文,等于没上报。
十一、收获
- 任何"沉默失败"都必须有重试机制。推送、定位、上报这类后台操作,失败用户感知不到,必须自动恢复。
-
指数退避是重试的最佳实践。比固定间隔更智能,比立即重试更友好。
pow(2, i + 1)是经典公式。 - 重试要有上限。无限制重试会耗尽资源,5 次 + 指数退避覆盖大部分场景。
-
状态机要清晰。
canUploadId / finishUpload / uploading / _hasAttemptedRetryUpload四个 bool,每个都有明确职责,组合起来覆盖所有场景。 - 避免递归重试。重试方法不要调用会触发重试的方法,否则次数失控。
- 错误上报要带上下文。userId、phone、堆栈、状态,缺一不可。
- 生命周期事件要利用好。App 回到前台是恢复"沉默失败"的最佳时机。
- 注销要重置状态。多个账号切换时,状态残留会导致隐蔽 bug。
- 超时差异要体现优先级。主流程快速失败,重试可以多等。
十二、监控指标
这套机制上线后,需要监控几个指标:
-
首次上传成功率:
finishUpload = true在第一次checkUploadId中达成的比例 - 重试成功率:每次重试的成功率分布
- 最终失败率:5 次重试后仍失败的比例(这是核心指标)
-
平均上传时长:从用户登录到
finishUpload = true的时长
如果"最终失败率"高于 0.1%,说明还有优化空间。可能的优化方向:
- 增加重试次数到 7 次
- 监听网络状态变化,网络恢复时立即重试
- 接入第三方推送通道(小米、华为、OPPO、vivo)作为备份
总结
推送 ID 上传是 App 里最容易被忽视的"基础设施"。它不复杂,但失败时静默无感;它不重要,但失败后整个推送系统瘫痪。
好代码不是写得多炫,而是把每种失败场景都想到、都处理好。 这个重试机制代码不到 100 行,但每一行都对应一个真实踩过的坑。
完整流程图:
[触发时机]
├── 用户登录后
├── App 回到前台
├── 获取推送权限后
└── 接口失败后
│
▼
[checkUploadId]
├── 检查权限 → canUploadId = true
├── 检查状态 → !finishUpload && !uploading && isLogin
├── 获取 ID → timeout(3s)
├── 调用接口上传
├── 成功 → finishUpload = true ✅
└── 失败 → 上报错误 → _triggerRetryIfNeeded
│
▼
[retryUploadJPushId] (只触发一次)
├── for i in 0..5
│ ├── 检查 finishUpload
│ ├── 等待 pow(2, i+1) 秒
│ ├── 再次检查 finishUpload
│ └── _doUpload (不递归)
└── 全部失败 → 上报错误
这是子女端 App 实战系列的最后一篇。5 篇博客覆盖了状态机、音视频通讯、WebSocket、蓝牙、推送五个领域,每一个都是踩坑踩出来的经验。希望对你有帮助。