Chuyển đến nội dung chính

Push notifications in Flutter: the four states your app can be in

The notification arrives. Sometimes the app shows it, sometimes the system does, sometimes tapping it opens the right screen and sometimes it dumps the user on the home page. The behaviour feels random until you see the structure underneath: the same message takes four different paths depending on what the app was doing.

App stateWho displays itWhich handler runs
ForegroundNobody, by defaultonMessage
BackgroundThe OSonBackgroundMessage (separate isolate)
TerminatedThe OSonBackgroundMessage (separate isolate)
Opened by tappingonMessageOpenedApp, or getInitialMessage if it was terminated

Every notification bug I have debugged was one of those rows being unhandled. Wire all four and most of the mystery disappears.

Notification messages versus data messages

Before the code, the distinction that determines everything: an FCM payload can contain a notification block, a data block, or both.

  • notification present — the OS displays it automatically when the app is backgrounded or terminated. Your handler may not run at all on iOS unless you also opt into it.
  • data only — nothing is displayed automatically. Your handler always gets a chance to run, and you display something yourself.

Data-only messages give control and cost reliability: the OS is free to delay or drop them under battery restrictions. Notification messages are reliable and rigid. For most apps the right answer is both — a notification block so the user reliably sees something, plus a data block carrying the routing information for the tap.

{
  "notification": { "title": "New reply", "body": "Alex replied to your post" },
  "data": { "type": "post", "id": "1234" }
}

Setup, and the part that must be top-level

@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  // Runs in its own isolate: no access to your app's state or providers.
  debugPrint('Background message: ${message.messageId}');
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);

  runApp(const MyApp());
}

Two annotations-worth of detail hide a lot of pain.

The handler must be a top-level or static function, not a closure and not an instance method. It is looked up by name and invoked in a fresh isolate.

@pragma('vm:entry-point') stops tree-shaking from removing it in release builds. Without it, background messages work perfectly in debug and silently do nothing in release — which is the kind of bug that reaches production.

Because the handler runs in a separate isolate, it shares nothing with your running app: no providers, no singletons, no open database handles you opened elsewhere. It can write to disk or call a network endpoint; it cannot call setState or read your Riverpod container.

Permissions and tokens

final class PushService {
  PushService(this._messaging);

  final FirebaseMessaging _messaging;

  Future<bool> requestPermission() async {
    final settings = await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );
    return settings.authorizationStatus == AuthorizationStatus.authorized ||
        settings.authorizationStatus == AuthorizationStatus.provisional;
  }

  Future<void> syncToken() async {
    final token = await _messaging.getToken();
    if (token != null) await _api.registerDevice(token);

    _messaging.onTokenRefresh.listen(_api.registerDevice);
  }
}

Subscribe to onTokenRefresh and treat it as the primary path, not a fallback. Tokens rotate — on reinstall, on restore to a new device, occasionally for no visible reason — and an app that only registers the token at first launch accumulates users who silently stop receiving anything.

Ask for permission at a moment where the value is obvious, not on first launch. On iOS the prompt is one-shot: a user who declines cannot be asked again from inside the app, only via Settings.

Displaying in the foreground

By default, a foreground app shows nothing. You choose:

FirebaseMessaging.onMessage.listen((message) {
  final notification = message.notification;
  if (notification == null) return;

  _localNotifications.show(
    notification.hashCode,
    notification.title,
    notification.body,
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'messages',
        'Messages',
        importance: Importance.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
    payload: jsonEncode(message.data),
  );
});

The Android channel id here must match a channel you created at startup, and channel settings are fixed at creation time — changing importance in code after the channel exists does nothing until the app is reinstalled. Create channels deliberately, and use a new id if you genuinely need different behaviour.

Alternatively, show nothing and update in-app UI instead. For a chat app where the user is already looking at the conversation, an in-place update beats a banner.

Routing a tap

This is the row most often missed — the terminated case needs a separate call:

Future<void> setupTapRouting(GoRouter router) async {
  void handle(RemoteMessage message) {
    final type = message.data['type'];
    final id = message.data['id'];
    if (type == 'post' && id != null) router.go('/posts/$id');
  }

  // App was terminated and launched by the notification.
  final initial = await FirebaseMessaging.instance.getInitialMessage();
  if (initial != null) handle(initial);

  // App was backgrounded and resumed by the notification.
  FirebaseMessaging.onMessageOpenedApp.listen(handle);
}

getInitialMessage returns non-null exactly once, on the launch caused by the tap. Call it after your router exists but before the first frame settles, or the navigation happens against a router that is not ready.

Treat the payload as untrusted input. router.go(message.data['route']) with a server-supplied path is a route-injection primitive; map from a small set of known types instead, as above.

Testing it honestly

Four checks, all worth doing manually at least once per release:

  1. Foreground with the app open.
  2. Backgrounded with the home button, then tap.
  3. Force-quit the app (swipe away), then send and tap. This is the case that breaks.
  4. Release build, not debug — the vm:entry-point failure only shows here.

On iOS, remember that a notification requires a real device with a valid APNs key, uploaded to the Firebase project, plus the Push Notifications and Background Modes capabilities in Xcode. The simulator will not deliver remote pushes.

FAQ

Why do notifications work on Android but not iOS?

Almost always APNs configuration: a missing or wrong key in the Firebase console, or missing Xcode capabilities. The Dart code is rarely at fault.

Why does my background handler not run in release?

Missing @pragma('vm:entry-point'), or the handler is not top-level. Both work in debug and fail in release.

Can I run heavy work in the background handler?

You get a short window and the OS decides. Do the minimum — store the payload, schedule real work for next launch. Long work will be killed.

Should I use data-only messages for silent updates?

Only if the update is genuinely optional. Both platforms throttle silent pushes aggressively, and neither guarantees delivery.

How do I stop duplicate notifications?

Usually caused by both showing the OS notification and posting a local one for the same message. Show a local notification only in onMessage, where the OS shows nothing.


The four delivery paths, the @pragma('vm:entry-point') requirement, message-type semantics and Android channel behaviour described here are documented in the Firebase and platform references linked above. The recommendation to send both blocks, the untrusted-payload routing caution, and the four-case manual test list are my own judgement from shipping notification features. FCM’s Flutter APIs change between major versions — check the package changelog against your version.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

Announcing Flutter 2

  Phụ lục: Flutter on the web Flutter 2 on desktops, foldables, and embedded devices The growing Flutter ecosystem Dart: The secret sauce behind Flutter Flutter 2: Available now Hôm nay, chúng tôi sẽ công bố Flutter 2: một bản nâng cấp lớn cho Flutter cho phép các nhà phát triển tạo các ứng dụng đẹp, nhanh chóng và di động cho bất kỳ nền tảng nào. Với Flutter 2, bạn có thể sử dụng cùng một cơ sở mã để gửi các ứng dụng gốc cho năm hệ điều hành: IOS, Android, Windows, macOS và Linux; cũng như trải nghiệm web nhắm mục tiêu các trình duyệt như Chrome, Firefox, Safari hoặc Edge. Flutter thậm chí có thể được nhúng vào ô tô, TV và thiết bị gia dụng thông minh, mang đến trải nghiệm di động và lan tỏa nhất cho thế giới điện toán xung quanh. Mục tiêu của chúng tôi là thay đổi cơ bản cách các nhà phát triển nghĩ về việc xây dựng ứng dụng, bắt đầu không phải với nền tảng bạn đang nhắm mục tiêu mà là với trải nghiệm bạn muốn tạo. Flutter cho phép bạn tạo ra những trải nghiệm tuyệt đẹp trong đó ...