极光推送 ID 上传的指数退避重试机制:从"上传失败就完蛋"到"自动恢复"

推送是子女端 App 的生命线。老人摔倒、紧急呼叫、健康告警、睡眠异常……所有关键事件都靠极光推送触达子女。但推送要工作,前提是 App 必须把极光 registrationId 上传到自己的服务器,让服务器知道"这个用户的推送通道在哪"。

看似简单的一步,却是最容易掉链子的环节——网络弱、接口超时、用户未登录、SDK 初始化慢……任何一个原因都会导致上传失败。一旦失败,用户后续所有推送都收不到。

这篇博客记录我设计的指数退避重试机制,以及踩过的几个大坑。

一、问题背景:为什么上传极光 ID 这么难?

正常的上传流程:

用户登录 → 初始化极光 SDK → 获取 registrationId → 调用接口上传到服务器

每一步都可能出问题:

  1. 极光 SDK 异步初始化getRegistrationID() 可能在 SDK 还没初始化完成时被调用,返回空。
  2. 网络不稳定:弱网下接口超时,但 SDK 已经返回了 ID,重试时如果不去重新获取,可能还是用旧 ID。
  3. 用户未登录:用户注销后,UserManager.instance.isLogin 是 false,不应该上传。
  4. 接口偶发失败:服务器 5xx,但 ID 是有效的,重试就能成功。
  5. 流程触发时机散落:登录后、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。上传失败这种"沉默的错误"必须上报,否则线上问题没法发现

错误消息里特意带了 phoneuserId

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 又会调用 retryUploadJPushIdretryUploadJPushId 里调用 _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");

线上排障的核心是上下文。一个错误不带上下文,等于没上报。

十一、收获

  1. 任何"沉默失败"都必须有重试机制。推送、定位、上报这类后台操作,失败用户感知不到,必须自动恢复。
  2. 指数退避是重试的最佳实践。比固定间隔更智能,比立即重试更友好。pow(2, i + 1) 是经典公式。
  3. 重试要有上限。无限制重试会耗尽资源,5 次 + 指数退避覆盖大部分场景。
  4. 状态机要清晰canUploadId / finishUpload / uploading / _hasAttemptedRetryUpload 四个 bool,每个都有明确职责,组合起来覆盖所有场景。
  5. 避免递归重试。重试方法不要调用会触发重试的方法,否则次数失控。
  6. 错误上报要带上下文。userId、phone、堆栈、状态,缺一不可。
  7. 生命周期事件要利用好。App 回到前台是恢复"沉默失败"的最佳时机。
  8. 注销要重置状态。多个账号切换时,状态残留会导致隐蔽 bug。
  9. 超时差异要体现优先级。主流程快速失败,重试可以多等。

十二、监控指标

这套机制上线后,需要监控几个指标:

  • 首次上传成功率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、蓝牙、推送五个领域,每一个都是踩坑踩出来的经验。希望对你有帮助。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容