24 · 推送通知与深链接
目标:打通"用户收到通知 → 点击 → 落到 App 内正确页面"的完整链路,并理清权限、渠道、各类点击场景。
开篇:前端概念对照
| 前端 | Flutter |
|---|---|
| Web Push(Notification API + Service Worker) | FCM(Android)/ APNs(iOS)经 firebase_messaging |
用户授权 Notification.requestPermission() | notificationSettings.authorizationStatus 授权请求 |
通知点击 notificationclick 事件 | FirebaseMessaging.onMessageOpenedApp / 冷启动 getInitialMessage() |
URL 路由 /order/123 | 深链接 scheme://host/order/123(GoRouter 消费,见 13 篇) |
navigator.serviceWorker 常驻 | 原生通道由系统托管,App 未启动也能收到 |
心智模型差异:Web Push 在浏览器沙箱里,能力有限;移动端通知由系统级服务托管,即使 App 被杀也能送达,但代价是要处理四种不同的点击场景(第三节)以及厂商通道适配。
一、链路全貌
你的服务端 ──▶ FCM / APNs ──▶ 系统推送服务 ──▶ 用户设备
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
App 前台 App 后台 App 未启动(被杀)
onMessage onMessageOpenedApp getInitialMessage()
(自己弹提示) (点击通知进入) (点击通知冷启动)关键分工:
| 环节 | 谁做 |
|---|---|
| 把设备的推送 Token 上报给你的服务端 | App(第二节) |
| 调用 FCM/APNs 发推送 | 你的服务端(不要放在 App 里,密钥会泄露) |
| 通知展示(App 后台时) | 系统自动展示 |
| 通知展示(App 前台时) | App 自己处理(系统默认不弹) |
| 点击后跳转 | App(第三节) |
二、接入与 Token 管理
flutter pub add firebase_core firebase_messaging flutter_local_notifications// push_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';
// ⚠️ 必须是顶层函数(不能是类方法/闭包),因为它在独立 isolate 执行
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// 后台消息:此处做数据落库等轻量处理,不能操作 UI
debugPrint('后台消息: ${message.messageId}');
}
class PushService {
static Future<void> init() async {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// ① 请求权限(iOS 必须;Android 13+ 也需通知运行时权限,见 15 篇)
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true, badge: true, sound: true,
);
debugPrint('授权状态: ${settings.authorizationStatus}');
// ② 拿 Token 并上报服务端(Token 可能刷新,必须监听)
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await _reportToken(token);
FirebaseMessaging.instance.onTokenRefresh.listen(_reportToken);
// ③ 前台消息
FirebaseMessaging.onMessage.listen(_showLocalNotification);
// ④ 后台 → 点击通知进入(App 在后台但存活)
FirebaseMessaging.onMessageOpenedApp.listen(_handleNavigation);
// ⑤ 冷启动 → 点击通知进入(App 被杀)
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) _handleNavigation(initial);
}
static Future<void> _reportToken(String token) async {
// 上报到你的服务端,与用户账号绑定
// 下线/登出时要调服务端解绑,否则会给已登出用户发消息
}
}Token 管理的三个要点:
- Token 会变(重装、清数据、系统更新),必须监听
onTokenRefresh并重新上报 - 用户登出时必须解绑,否则别人登录同一设备会收到上一个用户的通知(隐私事故)
- Android 国内环境:FCM 可能不可用,需接厂商通道(小米/华为/OPPO/vivo)
[视目标市场而定],通常通过聚合推送服务或各厂商 SDK 分别接入(本质是 Platform Channel,见 21 篇)
三、四种场景必须分别处理
| 场景 | 触发回调 | 处理方式 |
|---|---|---|
| 前台(App 打开着) | onMessage | 系统不弹通知,需用 flutter_local_notifications 自己弹 |
| 后台(App 在后台存活) | 点击通知 → onMessageOpenedApp | 处理跳转 |
| 冷启动(App 被杀) | 点击通知 → getInitialMessage() | 处理跳转(最易漏) |
| 静默推送(仅数据) | onMessage / onBackgroundMessage | 不展示,只更新数据 |
3.1 前台自己弹通知
final _local = FlutterLocalNotificationsPlugin();
Future<void> _initLocalNotifications() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
await _local.initialize(const InitializationSettings(android: androidSettings));
// Android 8+ 必须创建渠道,否则通知不显示
const channel = AndroidNotificationChannel(
'default_channel', '默认通知',
importance: Importance.high,
);
await _local
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
Future<void> _showLocalNotification(RemoteMessage message) async {
final notification = message.notification;
if (notification == null) return;
// payload 里放路由信息,点击时取出跳转
await _local.show(
notification.hashCode,
notification.title,
notification.body,
const NotificationDetails(
android: AndroidNotificationDetails('default_channel', '默认通知',
importance: Importance.high, priority: Priority.high),
),
payload: jsonEncode(message.data), // 如 {"route":"/order","id":"123"}
);
}
// 本地通知的点击(与 FCM 的 onMessageOpenedApp 是两条路,都要接)
void _onLocalNotificationTap(String? payload) {
if (payload == null) return;
final data = jsonDecode(payload) as Map<String, dynamic>;
_navigateByRoute(data);
}3.2 跳转统一收口(衔接 13 篇 GoRouter)
void _handleNavigation(RemoteMessage message) {
final data = message.data;
_navigateByRoute(data);
}
void _navigateByRoute(Map<String, dynamic> data) {
final route = data['route'] as String?;
if (route == null) return;
// 用全局 navigatorKey 跳转(不受当前页面 context 限制)
navigatorKey.currentState?.pushNamed(route, arguments: data);
}路由定义、传参方式(extra/query/path)已在 13 篇讲过,此处只是换个入口触发。
四、深链接:从链接到页面
三种链接形态:
| 类型 | 示例 | 用途 |
|---|---|---|
| 自定义 Scheme | haierenergy://order/123 | App 内跳转、推送 payload |
| Universal Link / App Link | https://example.com/order/123 | 网页/微信分享点击直接唤起 App(未安装则打开网页,体验最好) |
| Deferred Deep Link | 安装后仍跳转到原链接 | 需第三方服务,用于推广场景 |
4.1 GoRouter 配置深链接(与 13 篇一致)
final router = GoRouter(
navigatorKey: navigatorKey,
routes: [
GoRoute(
path: '/order/:id',
builder: (context, state) => OrderDetailPage(id: state.pathParameters['id']!),
),
],
);自定义 Scheme 需在原生侧声明:
<!-- android/app/src/main/AndroidManifest.xml -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="haierenergy"/>
</intent-filter>Universal Link 还需:iOS 配置 Associated Domains、Android 配置 assetlinks.json 并放在域名 /.well-known/ 下(autoVerify="true")。
4.2 冷启动时序(最容易出 bug 的地方)
用户点链接 → App 冷启动 → 路由初始化 + 登录态判断 → 才能决定跳去哪
↑
这中间可能有异步等待(读 Token、鉴权)正确做法:先记录"待跳转的目标",等初始化完成后再执行;不要把深链接目标塞进还没 ready 的路由。
String? _pendingRoute; // 暂存待跳转
void handleIncomingLink(String route) {
if (_isReady) {
navigatorKey.currentState?.pushNamed(route);
} else {
_pendingRoute = route; // 初始化完成后统一消费
}
}
void onAppReady() {
_isReady = true;
if (_pendingRoute != null) {
navigatorKey.currentState?.pushNamed(_pendingRoute!);
_pendingRoute = null;
}
}配合 13 篇鉴权守卫:未登录时先跳登录,登录成功后再回到原目标页(这才是完整体验)。
五、常见陷阱速查
| 陷阱 | 症状 | 解法 |
|---|---|---|
只处理 onMessageOpenedApp | 冷启动点击不跳转(最高频的漏场景) | 必须补 getInitialMessage() |
| 后台处理器写成类方法 | Android 收不到后台消息 | 用顶层函数 + @pragma('vm:entry-point') |
| Android 8+ 未建通知渠道 | 通知完全不显示 | 创建 AndroidNotificationChannel |
| 前台收不到任何提示 | 系统本就不弹前台通知 | 用 flutter_local_notifications 自己弹 |
| 用户登出未解绑 Token | 下一用户收到上一个用户的通知 | 登出时调用服务端解绑 |
| 未处理 Token 刷新 | 一段时间后推送失效 | 监听 onTokenRefresh |
| 深链接在初始化完成前跳转 | 跳了但页面空白/跳回首页 | 暂存 pendingRoute,ready 后再跳 |
| iOS 模拟器测推送 | 推送在模拟器上不可用 | 必须真机测试 |
| 热重载测冷启动逻辑 | 测不到 | 冷启动必须重跑(stop + run) |
六、动手练习
练习 24.1(四场景矩阵):列一张表,把"前台/后台/冷启动/静默"四种场景的预期行为写出来;用 Firebase 控制台发测试消息逐条验证,重点确认冷启动跳转这一条确实生效。
练习 24.2(Token 生命周期):打印 Token,登出后再登录,验证服务端绑定关系正确切换;模拟 Token 刷新,确认重新上报。
练习 24.3(深链接 + 鉴权):实现"未登录点击 haierenergy://order/123 → 跳登录 → 登录成功后进入订单 123",对照 13 篇鉴权守卫三件套完成 pendingRoute 暂存逻辑。
练习 24.4(进阶):用 adb 模拟深链接触发(不需要真发推送):
adb shell am start -a android.intent.action.VIEW -d "haierenergy://order/123"分别测试 App 在前台、后台、未启动三种状态下的行为差异。
七、自检清单
- [ ] 能画出推送全链路图,说清服务端与 App 各自负责什么
- [ ] 能说出四种消息场景及其对应回调,尤其冷启动的
getInitialMessage() - [ ] 知道 Token 需要监听刷新、登出需解绑
- [ ] 能区分自定义 Scheme 与 Universal Link 的适用场景
- [ ] 能处理"深链接在初始化完成前到达"的时序问题
导航:上一章 23 · 打包签名与发布上架 | 返回 专题总览