Skip to content

24 · 推送通知与深链接 ​

目标:打通"用户收到通知 → 点击 → 落到 App 内正确页面"的完整链路,并理清权限、渠道、各类点击场景。

深链接的路由部分与 13 篇衔接,权限申请部分与 15 篇衔接。


开篇:前端概念对照 ​

前端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 管理 ​

bash
flutter pub add firebase_core firebase_messaging flutter_local_notifications
dart
// 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 管理的三个要点:

  1. Token 会变(重装、清数据、系统更新),必须监听 onTokenRefresh 并重新上报
  2. 用户登出时必须解绑,否则别人登录同一设备会收到上一个用户的通知(隐私事故)
  3. Android 国内环境:FCM 可能不可用,需接厂商通道(小米/华为/OPPO/vivo)[视目标市场而定],通常通过聚合推送服务或各厂商 SDK 分别接入(本质是 Platform Channel,见 21 篇)

三、四种场景必须分别处理 ​

场景触发回调处理方式
前台(App 打开着)onMessage系统不弹通知,需用 flutter_local_notifications 自己弹
后台(App 在后台存活)点击通知 → onMessageOpenedApp处理跳转
冷启动(App 被杀)点击通知 → getInitialMessage()处理跳转(最易漏)
静默推送(仅数据)onMessage / onBackgroundMessage不展示,只更新数据

3.1 前台自己弹通知 ​

dart
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) ​

dart
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 篇讲过,此处只是换个入口触发。


四、深链接:从链接到页面 ​

三种链接形态:

类型示例用途
自定义 Schemehaierenergy://order/123App 内跳转、推送 payload
Universal Link / App Linkhttps://example.com/order/123网页/微信分享点击直接唤起 App(未安装则打开网页,体验最好)
Deferred Deep Link安装后仍跳转到原链接需第三方服务,用于推广场景

4.1 GoRouter 配置深链接(与 13 篇一致) ​

dart
final router = GoRouter(
  navigatorKey: navigatorKey,
  routes: [
    GoRoute(
      path: '/order/:id',
      builder: (context, state) => OrderDetailPage(id: state.pathParameters['id']!),
    ),
  ],
);

自定义 Scheme 需在原生侧声明:

xml
<!-- 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 的路由。

dart
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 模拟深链接触发(不需要真发推送):

bash
adb shell am start -a android.intent.action.VIEW -d "haierenergy://order/123"

分别测试 App 在前台、后台、未启动三种状态下的行为差异。


七、自检清单 ​

  • [ ] 能画出推送全链路图,说清服务端与 App 各自负责什么
  • [ ] 能说出四种消息场景及其对应回调,尤其冷启动的 getInitialMessage()
  • [ ] 知道 Token 需要监听刷新、登出需解绑
  • [ ] 能区分自定义 Scheme 与 Universal Link 的适用场景
  • [ ] 能处理"深链接在初始化完成前到达"的时序问题

导航:上一章 23 · 打包签名与发布上架 | 返回 专题总览