平台能力与原生互通篇(6/6):推送、权限与系统生命周期管理

5 阅读6分钟

推送、权限与系统生命周期管理

系列收官篇,目标是把「推送 + 权限 + 生命周期」打通成一条可落地链路,避免线上最常见的消息丢失、重复弹窗、后台状态错乱问题。
适用场景:Flutter App(iOS/Android 双端),有 IM/活动通知/运营推送需求。


1. 问题背景:业务场景 + 现象

在业务里,推送相关问题通常不是“收不到消息”这么简单,而是以下复合故障:

  • 前台收到 push 但 UI 不更新(角标不变、列表不刷新)
  • 用户拒绝权限后反复弹窗,投诉“每次打开都问”
  • 冷启动点击通知进入错误页面(路由参数丢失、登录态还没恢复)
  • 后台切前台后重复处理同一条消息(重复弹 Toast/重复请求)
  • iOS 和 Android 行为不一致,测试口径难统一

本质上是三套系统叠加:

  1. 推送通道(APNs/FCM/厂商通道)
  2. 权限系统(授权状态与弹窗策略)
  3. App 生命周期(foreground/background/terminated)

2. 原因分析:核心原理 + 排查过程

核心原理(先统一认知)

  • 推送只负责“送达”,不负责你的业务状态更新。
  • 权限只决定“能不能弹/收”,不决定“消息怎么处理”。
  • 生命周期决定回调入口:前台、后台点击、冷启动点击,往往是不同回调。

常见根因

  1. 消息处理没有幂等:同一 messageId 被多入口消费。
  2. 权限流程与业务流程耦合:页面初始化就直接请求权限,缺少策略层。
  3. 路由恢复时机错误:收到通知即跳转,但 Session/Config 尚未恢复。
  4. 事件分发散落:native 回调直接改 UI,跨页面失控。

排查顺序(实战推荐)

  1. 先打全链路日志:messageIdsourceappStatehandledAt
  2. 对照生命周期验证入口是否唯一(前台回调/点击回调/初始消息)
  3. 检查权限状态机是否可重放(首次拒绝、永久拒绝、系统设置后返回)
  4. 检查路由跳转前置条件(登录、配置、必要数据预拉)

3. 解决方案:方案对比 + 最终选择

方案对比

  • 方案 A:页面各自监听推送

    • 优点:改动快
    • 缺点:重复处理、难维护、跨模块混乱
  • 方案 B:集中式 PushService + 事件总线(推荐)

    • 优点:入口统一、可测试、便于幂等与监控
    • 缺点:初次搭建略复杂

最终选择(推荐架构)

采用 三层职责分离

  1. PushAdapter 层(平台适配)
    • 只管接 SDK 回调(token、message、notification click)
  2. PushService 层(业务编排)
    • 做幂等、消息解析、策略分发(跳转/刷新/静默)
  3. AppCoordinator 层(应用协调)
    • 结合生命周期、登录态、权限状态,决定何时真正执行

4. 关键代码:最小可运行骨架(详细示例)

示例偏工程化,核心是“统一入口 + 幂等 + 生命周期协调”。
你可替换为 firebase_messaging、极光、个推等任意 SDK。

4.1 消息模型与幂等键

enum PushSource {
  foregroundMessage, // 前台收到
  notificationTap,   // 后台/前台点击通知
  initialMessage,    // 冷启动点击通知
}

class PushEvent {
  final String messageId;
  final String bizType; // e.g. chat/order/activity
  final Map<String, dynamic> payload;
  final PushSource source;
  final DateTime receivedAt;

  PushEvent({
    required this.messageId,
    required this.bizType,
    required this.payload,
    required this.source,
    required this.receivedAt,
  });
}
class PushDeduplicator {
  final _handled = <String>{};

  bool tryMarkHandled(String messageId) {
    if (_handled.contains(messageId)) return false;
    _handled.add(messageId);
    return true;
  }

  void clearExpired() {
    // 可扩展:按时间窗口淘汰,避免集合无限增长
  }
}

4.2 生命周期协调器(前后台状态统一)

class AppLifecycleTracker with WidgetsBindingObserver {
  AppLifecycleState _state = AppLifecycleState.resumed;
  AppLifecycleState get state => _state;

  final _controller = StreamController<AppLifecycleState>.broadcast();
  Stream<AppLifecycleState> get stream => _controller.stream;

  void start() {
    WidgetsBinding.instance.addObserver(this);
  }

  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _controller.close();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    _state = state;
    _controller.add(state);
  }

  bool get isForeground => _state == AppLifecycleState.resumed;
}

4.3 权限策略(避免“每次都问”)

enum PushPermissionState {
  notDetermined,
  authorized,
  denied,
  permanentlyDenied, // Android 常见
}

abstract class PushPermissionGateway {
  Future<PushPermissionState> current();
  Future<PushPermissionState> request();
  Future<void> openSystemSettings();
}

class PushPermissionPolicy {
  // 是否可以主动弹权限请求(比如新用户引导阶段)
  bool canPrompt({
    required PushPermissionState state,
    required bool hasShownEducationDialog,
  }) {
    if (state == PushPermissionState.notDetermined) return true;
    if (state == PushPermissionState.denied && !hasShownEducationDialog) {
      // 先弹“教育弹窗”,再决定是否引导到系统设置
      return false;
    }
    return false;
  }
}

4.4 PushService:统一处理入口

class PushService {
  PushService({
    required this.deduplicator,
    required this.lifecycleTracker,
    required this.router,
    required this.session,
    required this.logger,
  });

  final PushDeduplicator deduplicator;
  final AppLifecycleTracker lifecycleTracker;
  final AppRouter router;
  final SessionManager session;
  final Logger logger;

  Future<void> onPushEvent(PushEvent event) async {
    logger.info('[Push] recv id=${event.messageId}, source=${event.source}, state=${lifecycleTracker.state}');

    // 1) 幂等控制:同一 messageId 只处理一次
    if (!deduplicator.tryMarkHandled(event.messageId)) {
      logger.warn('[Push] duplicated id=${event.messageId}');
      return;
    }

    // 2) 冷启动/未登录:先排队,待 session ready 再消费
    if (!session.isReady) {
      session.enqueuePendingPush(event);
      logger.info('[Push] queued, session not ready');
      return;
    }

    // 3) 按业务类型分发
    switch (event.bizType) {
      case 'chat':
        await _handleChat(event);
        break;
      case 'order':
        await _handleOrder(event);
        break;
      case 'activity':
        await _handleActivity(event);
        break;
      default:
        logger.warn('[Push] unknown bizType=${event.bizType}');
    }
  }

  Future<void> _handleChat(PushEvent event) async {
    final roomId = event.payload['roomId']?.toString();
    if (roomId == null) return;

    // 前台:只刷新数据,不强跳
    if (lifecycleTracker.isForeground && event.source == PushSource.foregroundMessage) {
      // 通知聊天模块刷新会话列表
      EventBus.instance.emit('chat.refresh', {'roomId': roomId});
      return;
    }

    // 点击通知或冷启动:可导航
    router.to('/chat/room', arguments: {'roomId': roomId});
  }

  Future<void> _handleOrder(PushEvent event) async {
    final orderId = event.payload['orderId']?.toString();
    if (orderId == null) return;
    router.to('/order/detail', arguments: {'orderId': orderId});
  }

  Future<void> _handleActivity(PushEvent event) async {
    final activityId = event.payload['activityId']?.toString();
    if (activityId == null) return;
    router.to('/activity/detail', arguments: {'activityId': activityId});
  }
}

4.5 App 启动阶段串联(重点)

Future<void> bootstrapApp() async {
  WidgetsFlutterBinding.ensureInitialized();

  final lifecycle = AppLifecycleTracker()..start();
  final pushService = buildPushService(lifecycle);

  // 1. 初始化推送 SDK(token、监听器)
  await PushSdk.instance.init();

  // 2. 绑定前台消息
  PushSdk.instance.onMessage((raw) async {
    final event = parseRawPush(raw, PushSource.foregroundMessage);
    await pushService.onPushEvent(event);
  });

  // 3. 绑定点击通知
  PushSdk.instance.onNotificationTap((raw) async {
    final event = parseRawPush(raw, PushSource.notificationTap);
    await pushService.onPushEvent(event);
  });

  // 4. 处理冷启动初始消息(最容易漏)
  final initial = await PushSdk.instance.getInitialMessage();
  if (initial != null) {
    final event = parseRawPush(initial, PushSource.initialMessage);
    await pushService.onPushEvent(event);
  }

  runApp(MyApp());
}

4.6 iOS/Android 原生侧注意点(简版)

  • iOS:UNUserNotificationCenterDelegate 前台展示策略需明确(是否弹横幅/声音)
  • Android:通知渠道(channel)必须提前创建,重要级别不要动态乱改
  • 两端都要上报 token 刷新事件,否则换机/重装后可能收不到

5. 效果验证:数据/截图/日志

上线前建议定义 4 个核心指标:

  1. Push 到达率(收到 SDK 回调 / 服务端下发)
  2. 点击到达率(点击通知后成功进入目标页)
  3. 重复消费率(同 messageId 二次处理比例)
  4. 权限转化率(notDetermined -> authorized)

示例日志字段(建议统一):

push_id=xxx
source=foregroundMessage|notificationTap|initialMessage
app_state=resumed|paused|inactive
biz_type=chat|order|activity
route_target=/chat/room
handled=true|false
reason=duplicated|session_not_ready|success

6. 可复用结论:通用经验 + 避坑清单

通用经验

  • 推送系统要按“事件驱动”设计,而不是“页面驱动”
  • 冷启动链路优先级最高,先确保“点通知一定进对页”
  • 权限要有“教育层 + 系统层”两步策略,不要硬弹
  • 所有消息处理必须幂等(messageId 是生命线)

避坑清单

  • 未处理 getInitialMessage(冷启动消息丢失)
  • 前台消息直接导航,打断用户当前操作
  • 未做重复消费控制,导致重复跳转/重复请求
  • 权限拒绝后无限弹窗,触发差评
  • token 刷新未上报,部分用户长期离线

时序图

图 1:冷启动点击通知全链路

sequenceDiagram
    participant U as 用户
    participant OS as iOS/Android系统
    participant APP as Flutter App
    participant SDK as Push SDK
    participant PS as PushService
    participant SES as SessionManager
    participant R as Router

    U->>OS: 点击通知
    OS->>APP: 拉起应用(terminated -> launching)
    APP->>SDK: getInitialMessage()
    SDK-->>APP: rawPush(messageId,payload)
    APP->>PS: onPushEvent(initialMessage)
    PS->>PS: 幂等校验(messageId)
    alt Session未就绪
        PS->>SES: enqueuePendingPush(event)
        SES-->>PS: session ready
        PS->>PS: 重新消费队列
    end
    PS->>R: routeToTarget(payload)
    R-->>U: 打开目标页面

图 2:前台收到推送(不强跳)

sequenceDiagram
    participant S as Push Server
    participant SDK as Push SDK
    participant APP as Flutter App
    participant PS as PushService
    participant BUS as EventBus
    participant UI as 当前页面

    S->>SDK: 下发消息
    SDK->>APP: onMessage(rawPush)
    APP->>PS: onPushEvent(foregroundMessage)
    PS->>PS: 幂等校验
    PS->>BUS: emit(chat.refresh/order.refresh)
    BUS->>UI: 刷新列表/角标
    UI-->>UI: 轻提示(可选,不跳转)

结语

这一篇的关键不是接哪个推送 SDK,而是建立一条稳定的工程链路:权限可控、消息可追踪、生命周期可预测
当你把“入口统一 + 幂等 + 冷启动优先”做扎实,推送相关线上故障会明显下降。