推送、权限与系统生命周期管理
系列收官篇,目标是把「推送 + 权限 + 生命周期」打通成一条可落地链路,避免线上最常见的消息丢失、重复弹窗、后台状态错乱问题。
适用场景:Flutter App(iOS/Android 双端),有 IM/活动通知/运营推送需求。
1. 问题背景:业务场景 + 现象
在业务里,推送相关问题通常不是“收不到消息”这么简单,而是以下复合故障:
- 前台收到 push 但 UI 不更新(角标不变、列表不刷新)
- 用户拒绝权限后反复弹窗,投诉“每次打开都问”
- 冷启动点击通知进入错误页面(路由参数丢失、登录态还没恢复)
- 后台切前台后重复处理同一条消息(重复弹 Toast/重复请求)
- iOS 和 Android 行为不一致,测试口径难统一
本质上是三套系统叠加:
- 推送通道(APNs/FCM/厂商通道)
- 权限系统(授权状态与弹窗策略)
- App 生命周期(foreground/background/terminated)
2. 原因分析:核心原理 + 排查过程
核心原理(先统一认知)
- 推送只负责“送达”,不负责你的业务状态更新。
- 权限只决定“能不能弹/收”,不决定“消息怎么处理”。
- 生命周期决定回调入口:前台、后台点击、冷启动点击,往往是不同回调。
常见根因
- 消息处理没有幂等:同一 messageId 被多入口消费。
- 权限流程与业务流程耦合:页面初始化就直接请求权限,缺少策略层。
- 路由恢复时机错误:收到通知即跳转,但 Session/Config 尚未恢复。
- 事件分发散落:native 回调直接改 UI,跨页面失控。
排查顺序(实战推荐)
- 先打全链路日志:
messageId、source、appState、handledAt - 对照生命周期验证入口是否唯一(前台回调/点击回调/初始消息)
- 检查权限状态机是否可重放(首次拒绝、永久拒绝、系统设置后返回)
- 检查路由跳转前置条件(登录、配置、必要数据预拉)
3. 解决方案:方案对比 + 最终选择
方案对比
-
方案 A:页面各自监听推送
- 优点:改动快
- 缺点:重复处理、难维护、跨模块混乱
-
方案 B:集中式 PushService + 事件总线(推荐)
- 优点:入口统一、可测试、便于幂等与监控
- 缺点:初次搭建略复杂
最终选择(推荐架构)
采用 三层职责分离:
- PushAdapter 层(平台适配)
- 只管接 SDK 回调(token、message、notification click)
- PushService 层(业务编排)
- 做幂等、消息解析、策略分发(跳转/刷新/静默)
- 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 个核心指标:
- Push 到达率(收到 SDK 回调 / 服务端下发)
- 点击到达率(点击通知后成功进入目标页)
- 重复消费率(同 messageId 二次处理比例)
- 权限转化率(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,而是建立一条稳定的工程链路:权限可控、消息可追踪、生命周期可预测。
当你把“入口统一 + 幂等 + 冷启动优先”做扎实,推送相关线上故障会明显下降。