Start Debugging

Migrate a Flutter App from WidgetsBindingObserver.didChangeAppLifecycleState to AppLifecycleListener (Flutter 3.13+)

Replace hand-rolled WidgetsBindingObserver lifecycle code with AppLifecycleListener: the callback mapping, the state machine it enforces, the new hidden state that breaks exhaustive switches, desktop exit requests, and the test that suddenly asserts. Tested on Flutter 3.44.8 with Dart 3.12.2.

If your Flutter app still mixes WidgetsBindingObserver into a State just to override didChangeAppLifecycleState, the replacement is AppLifecycleListener, added in Flutter 3.13.0. You create one listener object in initState, pass it only the callbacks you care about (onResume, onPause, onHide, onShow, onInactive, onRestart, onDetach, onStateChange, onExitRequested), and call dispose() on it. No mixin, no addObserver/removeObserver pair, no switch over AppLifecycleState. The migration of one observer takes about 10 minutes, and it is behavior-preserving as long as you know two things: since 3.13 the framework inserts the intermediate states (resumed to paused now arrives as inactive, hidden, paused), and the new AppLifecycleState.hidden value turns every exhaustive switch without a default into a compile error. Everything below was run on Flutter 3.44.8 (Dart 3.12.2).

Why move off the raw observer

What changes in the app

AreaChangeSeverity
switch (state) statementsAppLifecycleState.hidden (added in 3.13) makes switches without default fail with non_exhaustive_switch_statementhigh
Widget tests calling handleAppLifecycleStateChanged directlyJumping from resumed to paused now fails an assertion inside the listenerhigh
Observer State classesMixin and addObserver/removeObserver removed, replaced by one field and dispose()medium
Callback orderonStateChange fires after the transition callback, once per intermediate statelow
Exit handlingdidRequestAppExit override moves to onExitRequested; AppExitResponse comes from dart:uilow

Pre-flight checklist

Migration steps

  1. Map each case to a callback. The listener’s callbacks are named after transitions, not states. This is the mapping, taken from the source of AppLifecycleListener.didChangeAppLifecycleState in Flutter 3.44.8:

    New statePrevious stateCallback
    resumedinactive (or none)onResume
    inactiveresumed (or none)onInactive
    inactivehiddenonShow
    hiddeninactive (or none)onHide
    hiddenpausedonRestart
    pausedhidden (or none)onPause
    detachedpaused (or none)onDetach
    anyanyonStateChange(state), after the specific callback

    A case AppLifecycleState.paused: becomes onPause, and case AppLifecycleState.resumed: becomes onResume. If the old code did something on inactive regardless of direction, use onStateChange and keep the if, because inactive is reached from two directions and maps to two different callbacks. Verify: every branch of the old switch has exactly one destination in your notes.

  2. Replace the mixin with a listener field. This is the typical before:

    // Flutter 3.44.8, Dart 3.12.2 - BEFORE
    class PlayerScreen extends StatefulWidget {
      const PlayerScreen({super.key});
      @override
      State<PlayerScreen> createState() => _PlayerScreenState();
    }
    
    class _PlayerScreenState extends State<PlayerScreen>
        with WidgetsBindingObserver {
      @override
      void initState() {
        super.initState();
        WidgetsBinding.instance.addObserver(this);
      }
    
      @override
      void didChangeAppLifecycleState(AppLifecycleState state) {
        switch (state) {
          case AppLifecycleState.resumed:
            _player.resume();
          case AppLifecycleState.paused:
            _player.pause();
            _saveProgress();
          default:
            break;
        }
      }
    
      @override
      void dispose() {
        WidgetsBinding.instance.removeObserver(this);
        super.dispose();
      }
    
      // _player, _saveProgress and build() omitted
    }

    And the after:

    // Flutter 3.44.8, Dart 3.12.2 - AFTER
    class _PlayerScreenState extends State<PlayerScreen> {
      late final AppLifecycleListener _lifecycle;
    
      @override
      void initState() {
        super.initState();
        _lifecycle = AppLifecycleListener(
          onResume: () => _player.resume(),
          onPause: () {
            _player.pause();
            _saveProgress();
          },
        );
      }
    
      @override
      void dispose() {
        _lifecycle.dispose();
        super.dispose();
      }
    
      // _player, _saveProgress and build() unchanged
    }

    The constructor calls binding.addObserver(this) itself, so creating the object is the subscription. Verify: flutter analyze reports no unused WidgetsBindingObserver import issues and the class no longer has a with clause (unless it still needs other observer callbacks, see step 4).

  3. Decide what hidden means for your logic. AppLifecycleState.hidden was added in Flutter 3.13 for desktop and web, where an app can be minimized or in a background tab without being “paused” in the mobile sense. On iOS and Android the framework synthesizes it between inactive and paused. If you used onPause to stop a video, that still works everywhere it worked before. If you want the video to stop when a desktop window is minimized too, onHide is the callback that fires on every platform, with onShow as its counterpart. For anything that should only happen when the user leaves the app on mobile (saving a draft, flushing analytics), onPause plus onRestart is the pair. Verify: write down, per callback, which platforms you ship to and whether it fires there (onRestart and onDetach are documented as mobile-only).

  4. Keep the mixin only for non-lifecycle callbacks. AppLifecycleListener covers didChangeAppLifecycleState and didRequestAppExit and nothing else. If an observer also overrides didChangeMetrics to react to keyboard insets, leave the mixin in place for that override and move only the lifecycle method out. Verify: grep the class for @override methods from WidgetsBindingObserver after the edit; if none remain, remove the mixin and the addObserver/removeObserver calls.

  5. Move didRequestAppExit to onExitRequested (desktop only). The callback returns a Future<AppExitResponse>, and that enum lives in dart:ui, not in package:flutter/widgets.dart or services.dart. Without the import you get Undefined name 'AppExitResponse'.

    // Flutter 3.44.8, Dart 3.12.2
    import 'dart:ui' show AppExitResponse;
    import 'package:flutter/material.dart';
    
    class _EditorState extends State<Editor> {
      late final AppLifecycleListener _lifecycle;
      bool _dirty = false;
    
      @override
      void initState() {
        super.initState();
        _lifecycle = AppLifecycleListener(
          onExitRequested: () async {
            if (!_dirty) return AppExitResponse.exit;
            final discard = await showDialog<bool>(
              context: context,
              builder: (context) => AlertDialog(
                title: const Text('Discard unsaved changes?'),
                actions: [
                  TextButton(
                    onPressed: () => Navigator.pop(context, false),
                    child: const Text('Cancel'),
                  ),
                  TextButton(
                    onPressed: () => Navigator.pop(context, true),
                    child: const Text('Discard'),
                  ),
                ],
              ),
            );
            return discard == true ? AppExitResponse.exit : AppExitResponse.cancel;
          },
        );
      }
    
      @override
      void dispose() {
        _lifecycle.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) => const Placeholder();
    }

    Only cancelable exits reach this callback. ServicesBinding.instance.exitApplication(AppExitType.required) exits without asking. Note that WidgetsBinding.handleRequestAppExit asks every observer, even after one has answered cancel, and cancels the exit if any of them did. One listener anywhere in the app can veto the exit, and every other listener’s onExitRequested still runs, so do not put irreversible cleanup there. Verify: run on desktop, make the editor dirty, close the window, and confirm the dialog appears and “Cancel” keeps the window open.

  6. Fix the switch statements that the hidden value broke. If you keep a switch (for example inside onStateChange), it must cover hidden. This compiles on Flutter 3.10 and fails on 3.13+:

    // Flutter 3.44.8, Dart 3.12.2 - fails flutter analyze
    String describe(AppLifecycleState state) {
      switch (state) {
        case AppLifecycleState.resumed:
          return 'resumed';
        case AppLifecycleState.inactive:
          return 'inactive';
        case AppLifecycleState.paused:
          return 'paused';
        case AppLifecycleState.detached:
          return 'detached';
      }
    }

    The analyzer reports: “The type ‘AppLifecycleState’ isn’t exhaustively matched by the switch cases since it doesn’t match the pattern ‘AppLifecycleState.hidden’” (non_exhaustive_switch_statement). Add an explicit case AppLifecycleState.hidden: rather than a default, so the next enum value added by Flutter is a compile error again instead of a silent fall-through. Verify: flutter analyze is clean.

  7. Update the tests that drive lifecycle changes. See the next section; this is where most migrations actually stall. Verify: flutter test passes.

Verification

The fastest proof that behavior is unchanged is a widget test that sends lifecycle changes the same way the engine does: a string on the flutter/lifecycle channel. That path goes through the binding’s transition generator, exactly like a real device.

// Flutter 3.44.8, Dart 3.12.2 - test/lifecycle_test.dart
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> sendLifecycle(WidgetTester tester, AppLifecycleState state) async {
  final message = const StringCodec().encodeMessage(state.toString());
  await tester.binding.defaultBinaryMessenger
      .handlePlatformMessage('flutter/lifecycle', message, (_) {});
}

void main() {
  testWidgets('background and foreground', (tester) async {
    final log = <String>[];
    final listener = AppLifecycleListener(
      onResume: () => log.add('onResume'),
      onInactive: () => log.add('onInactive'),
      onHide: () => log.add('onHide'),
      onShow: () => log.add('onShow'),
      onPause: () => log.add('onPause'),
      onRestart: () => log.add('onRestart'),
      onStateChange: (s) => log.add('state:${s.name}'),
    );
    addTearDown(listener.dispose);

    await sendLifecycle(tester, AppLifecycleState.resumed);
    await sendLifecycle(tester, AppLifecycleState.paused);
    await sendLifecycle(tester, AppLifecycleState.resumed);

    expect(log, [
      'onResume', 'state:resumed',
      'onInactive', 'state:inactive',
      'onHide', 'state:hidden',
      'onPause', 'state:paused',
      'onRestart', 'state:hidden',
      'onShow', 'state:inactive',
      'onResume', 'state:resumed',
    ]);
  });
}

This passes on 3.44.8 and documents three facts in one place. A platform jump from resumed straight to paused arrives as three steps. The way back runs onRestart, onShow, onResume, in that order. And onStateChange always comes after the named callback for the same step. Sending resumed twice in a row produces nothing the second time, because both the binding and the listener drop repeated states.

Then the manual checks: on Android and iOS, background the app and bring it back, and confirm the work you moved to onPause and onResume still happens once each. On desktop, minimize and restore if you moved anything to onHide/onShow, and close the window if you implemented onExitRequested.

Rolling back

The migration is a pure code change; no persisted state is involved. git revert the commit and you are back on the observer. The only thing that will not revert cleanly is a switch you fixed for hidden: keep that fix, because the old code was already broken on 3.13+.

Gotchas from a real migration

Tests that call handleAppLifecycleStateChanged now assert

Many test suites simulate backgrounding with tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused). That method dispatches the given state directly, without the synthesized intermediate states. A plain observer does not care. The listener validates every transition, so with the app in resumed this test fails:

// Flutter 3.44.8, Dart 3.12.2 - fails
testWidgets('direct jump', (tester) async {
  final listener = AppLifecycleListener(onPause: () {});
  addTearDown(listener.dispose);
  tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
  tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
});

The failure is an assertion thrown while dispatching to observers: “Invalid state transition from AppLifecycleState.resumed to AppLifecycleState.paused”, and onPause is never called. Either send each intermediate state yourself (inactive, hidden, paused) or use the flutter/lifecycle channel helper from the Verification section, which lets the framework generate them. The assertion only exists in debug builds, so release behavior is not affected; the risk is purely a red test suite.

The listener only sees changes after it is created

The constructor snapshots binding.lifecycleState and treats it as the previous state. If you create the listener on a screen pushed long after startup, no onResume fires for the current state, because there was no transition. The old observer had the same behavior, but code sometimes relied on an initState call to “do the resume logic once”. Keep that explicit: read WidgetsBinding.instance.lifecycleState in initState if you need the current state, the same as before.

Old observers also see the synthesized states

The intermediate-state generation lives in ServicesBinding, not in the listener. In the repro above, a plain WidgetsBindingObserver registered alongside the listener received inactive, hidden, paused for a single platform paused message, too. So if an observer you have not migrated yet does “on anything other than resumed, pause playback”, it now runs that code three times per backgrounding. Make such handlers idempotent or migrate them to a single named callback.

Creating a listener before runApp

AppLifecycleListener() defaults its binding to WidgetsBinding.instance, which throws if the binding is not initialized yet. A listener owned by a service that is built in main() before runApp needs WidgetsFlutterBinding.ensureInitialized() first. For services that outlive any widget, also make sure something calls dispose(); with Riverpod that is ref.onDispose(listener.dispose) in the provider that creates it.

onExitRequested is not a mobile back-button hook

It is tempting to treat onExitRequested as “the app is about to close” on Android. It is not: in the engine source, only the Windows, macOS and Linux embedders send exit requests, so it never fires on Android or iOS. To intercept the Android back gesture, use PopScope. To run cleanup when a mobile app goes away, use onPause; onDetach is not guaranteed to run before the OS kills a backgrounded process.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back