Android has a contract with every app: your UI exists inside an Activity, and the system controls when that Activity lives, sleeps, and dies. The Activity lifecycle is a state machine with defined transitions — onCreate, onStart, onResume, onPause, onStop, onDestroy — and the system expects your code to respond correctly at each transition.
Native Kotlin apps are built around this contract. Every component — ViewModels, LiveData, saved instance state, the navigation back stack — is designed with the lifecycle in mind. The View system integrates directly with lifecycle callbacks. The architecture components library (androidx.lifecycle) exists specifically to tie code to lifecycle transitions.
Flutter was designed to ignore all of this.
The Dart side of your Flutter app has its own lifecycle model: AppLifecycleState with states like resumed, inactive, paused, detached, hidden. It looks simpler. In some ways it is simpler. But the mapping between Android's Activity lifecycle and Flutter's app lifecycle is where subtle bugs live — and understanding the mapping requires understanding both sides.
The Activity lifecycle, concretely
The lifecycle states aren't abstract categories. They correspond to specific, observable conditions:
`onCreate` — The Activity object exists in memory but has no visible UI. The window hasn't been created yet. This is where FlutterActivity loads the Flutter engine, creates the FlutterView, and starts the Dart VM. Before onCreate returns, the engine is initialising but hasn't rendered a frame. The native splash screen is visible.
`onStart` — The Activity is now visible (its window has been added to the window manager), but it's not in the foreground. This can happen when the Activity is partially visible behind a transparent or dialog-themed Activity. In practice, onStart and onResume often fire in quick succession.
`onResume` — The Activity is in the foreground, interactive, receiving touch events. This is the normal running state. For Flutter, this is when the engine is rendering frames and the event loop is active. The Flutter framework reports AppLifecycleState.resumed.
`onPause` — The Activity is losing foreground status but is still visible. This happens when a dialog appears on top, when the notification shade is pulled down, when picture-in-picture mode activates, or when another Activity starts in the foreground. For Flutter, the engine may still be rendering frames (the Activity is still visible), but touch events may not be delivered. Flutter reports AppLifecycleState.inactive.
`onStop` — The Activity is no longer visible. The user pressed Home, switched to another app, or a full-screen Activity covered yours. The Flutter engine stops rendering frames (no point rendering to a surface nobody can see). Flutter reports AppLifecycleState.paused (confusingly, not hidden — the mapping isn't one-to-one).
`onDestroy` — The Activity object is being destroyed. This happens in two scenarios: the user explicitly finishes the Activity (back button, finish()), or the system is destroying it to reclaim resources (configuration change, or the process is being cleaned up). After onDestroy, the Activity object is garbage-collected by ART.
Process death — This isn't a lifecycle callback. There is no onProcessDeath. The kernel sends SIGKILL and the process ceases to exist (Post 5). No callbacks fire. No state is saved. This is the scenario most Flutter developers forget about.
The state machine
The transitions form a specific pattern:
onCreate
│
▼
onStart
│
▼
┌─────── onResume ◄──────────┐
│ │ │
│ ▼ │
│ [RUNNING] │
│ │ │
│ ▼ │
│ onPause ───────────┐│
│ │ ││
│ ▼ ││
└──────── onStop ││
│ ││
▼ ││
onDestroy ││
│ ││
▼ ││
[DEAD] ││
││
(partially visible, e.g. dialog) ──┘│
(back to foreground) ───────────────┘Two transitions happen frequently in normal use: onResume → onPause → onResume (app briefly loses foreground, e.g., notification shade) and onResume → onPause → onStop → onStart → onResume (app goes to background and returns).
The destructive path — onStop → onDestroy → onCreate → onStart → onResume — happens during configuration changes (screen rotation, locale change, font size change) and when the system recreates the Activity after process death. This is the path that causes the most pain in Flutter.
Flutter's lifecycle mapping
Flutter's AppLifecycleState is a simplified abstraction over the platform's lifecycle. Here's how the Android states map:
| Android callback | Flutter AppLifecycleState | What's happening |
|---|---|---|
| onResume() | resumed | Foreground, interactive |
| onPause() | inactive | Visible but not interactive (dialog on top, notification shade) |
| onStop() | hidden | Not visible (backgrounded). Then quickly → paused |
| (process alive, Activity stopped) | paused | Background, engine not rendering |
| onDestroy() → onCreate() | detached → resumed | Activity recreated (config change or process death recovery) |
The mapping isn't perfectly clean because the two systems were designed with different assumptions.
Android assumes: the UI might be destroyed and recreated at any time (configuration changes, process death), and the app must save and restore its visual state across these recreations. The architecture is built around this assumption — onSaveInstanceState, ViewModel, SavedStateHandle.
Flutter assumes: the widget tree is ephemeral and rebuilt every frame, but the Dart runtime persists for the process's lifetime. State lives in Dart objects (in your BLoC, your Riverpod providers, your stateful widgets). The widget tree rebuilds from that state whenever needed. There's no onSaveInstanceState in Dart — because in the normal case, the Dart state is still in memory when the Activity restarts.
The conflict appears when the process dies.
The process death problem
Here's the scenario that breaks the assumption:
- User opens your Flutter app. The Dart runtime starts. Your app's state is in Dart objects — logged-in user, navigation stack, form data, cached API responses.
- User presses Home. The Activity goes through
onPause → onStop. The process stays alive. The Dart state is fine. - User opens several other apps. Memory pressure increases. The Low Memory Killer (Post 5) kills your process. The Dart state — all of it — is gone.
- User taps your app in the recents screen. Android doesn't start your app from scratch — it recreates the Activity as if returning from a configuration change. The new process gets a
savedInstanceStateBundle from the old Activity'sonSaveInstanceStatecall.
The problem: onSaveInstanceState saved the Android framework state — the Activity's window, the View hierarchy, the fragments back stack. It did not save your Dart state, because the Dart state is not part of the Android framework. When the new Dart VM starts and main() runs, it's a cold start. Your app begins from its initial state. The user expects to be where they left off — they tapped the recents entry, after all, which shows a screenshot of their previous session. But the app shows the login screen, or the home screen, or whatever main() renders.
This is the gap between Android's lifecycle model and Flutter's runtime model. Android expects apps to survive process death via onSaveInstanceState. Flutter doesn't participate in that mechanism natively.
Flutter's response: RestorationMixin
The Flutter framework provides RestorationMixin and the RestorationManager to bridge this gap. The idea: your Dart widgets register restorable properties (strings, numbers, small data structures) that get serialised and stored in the Android savedInstanceState Bundle.
class _CounterPageState extends State<CounterPage> with RestorationMixin {
final RestorableInt _counter = RestorableInt(0);
@override
String? get restorationId => 'counter_page';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_counter, 'counter');
}
// ...
}When onSaveInstanceState fires, the restoration data is written to the Bundle and saved by the Android framework. When the Activity is recreated after process death, the restoration data is passed back to the Dart side, and registered widgets can read their previous values.
In practice, this works but has significant limitations:
The data must be small. It goes through the savedInstanceState Bundle, which is a Binder transaction to ActivityManagerService (Post 4). The 1MB Binder buffer limit applies. A navigation stack with route names and a few IDs is fine. A serialised data cache is not.
It requires explicit opt-in for every piece of state. Unlike Android's ViewModel + SavedStateHandle, which can hold any Serializable/Parcelable object, Flutter's restoration system requires each widget to declare its restorable properties. In an app with dozens of screens and complex state management, this is a lot of manual work.
It doesn't cover all state. Authentication tokens, cached API responses, in-flight network requests, Riverpod/BLoC state — none of these are automatically restored. The restoration system covers widget state. Application state requires a separate strategy — typically persisting to disk (SharedPreferences, SQLite, secure storage) and reading it back at startup.
Most Flutter apps don't implement it. The honest assessment: state restoration in Flutter is underdeveloped relative to the native Android equivalent. Most Flutter apps handle process death by detecting it (e.g., checking if the user is logged in) and navigating to the appropriate starting point, rather than restoring the exact previous state.
Configuration changes
Screen rotation is the classic configuration change. On native Android, a rotation triggers: onPause → onStop → onSaveInstanceState → onDestroy → onCreate → onStart → onResume. The Activity is destroyed and recreated. ViewModels survive (they're scoped to the Activity's ViewModelStore, which the framework preserves across configuration changes). Views are re-created from saved state.
Flutter handles this differently. FlutterActivity overrides android:configChanges in the manifest:
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|
smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|
density|uiMode">This tells Android: don't destroy and recreate the Activity for these configuration changes. Instead, call onConfigurationChanged(). The Activity stays alive, the Dart VM stays alive, all state stays alive. The Flutter engine receives the new configuration (new screen dimensions, new orientation) and triggers a rebuild of the widget tree with the updated MediaQuery values.
This is a deliberate choice. Flutter doesn't need Activity recreation to handle configuration changes because the widget tree rebuilds reactively from state. Destroying and recreating the Activity would kill the Dart VM and lose all in-memory state — a massive cost for something the widget system handles naturally.
The downside: Flutter apps don't get tested against the "Activity destroyed and recreated" path during normal development, because configuration changes don't trigger it. This means the process-death path (which does destroy the Activity) is rarely tested and often broken.
The practical lifecycle events in Flutter
For most Flutter apps, the lifecycle events that matter are:
`AppLifecycleState.resumed` — The app is visible and interactive. Resume any paused operations: restart animations, reconnect WebSockets, refresh stale data.
`AppLifecycleState.inactive` — The app is partially obscured (dialog, notification shade, split-screen transition). Pause any operations that should only run when the user is actively looking at the app (video playback, camera preview). But don't release heavy resources yet — the app might return to resumed immediately.
`AppLifecycleState.hidden` — The app is no longer visible but the process is alive. This is the state between inactive and paused on Android (corresponds roughly to the onStop callback). The engine stops rendering frames.
`AppLifecycleState.paused` — The app is in the background. This is the state where you should reduce memory footprint: release image caches, trim in-memory data, persist any unsaved state to disk. Your app might stay in this state for seconds or hours. It might never come back — the LMK could kill the process at any time without further notification.
`AppLifecycleState.detached` — The engine is running but not attached to a hosting surface. This can happen during Activity recreation or when the engine is being shut down. In practice, this state is rarely observed in normal app usage.
The listening pattern:
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
// Going to background. Save state, trim caches.
_persistUnsavedState();
_trimImageCache();
break;
case AppLifecycleState.resumed:
// Returning to foreground. Refresh data, check auth.
_refreshIfStale();
break;
default:
break;
}
}
}What the system does to your Activity
Beyond the lifecycle callbacks your app receives, the system takes actions that affect your Flutter app at the Activity level:
Trim memory. onTrimMemory(TRIM_MEMORY_BACKGROUND) arrives via Binder when the system is reclaiming memory. The Flutter engine responds by releasing some internal caches (font cache, shader cache). On the Dart side, this arrives as a didHaveMemoryPressure() callback (via SystemChannels.system), or through the newer AppLifecycleListener API.
Window insets. When the keyboard appears or disappears, the system sends window inset changes to the Activity. The Flutter engine translates these into viewInsets in MediaQuery. If you've ever had a layout that breaks when the keyboard appears, the root cause is in this path — the inset data flows from the window manager, through the Activity, through the engine, into MediaQuery, into your layout.
Multi-window. Split-screen and freeform window modes change the Activity's window size dynamically. The engine handles this via onConfigurationChanged (since screenSize is in the configChanges override). Your Flutter layout gets a new MediaQuery.of(context).size and rebuilds.
Picture-in-picture. Entering PiP mode triggers onPause (the Activity is visible but not fully interactive in PiP). The Flutter engine continues rendering, but the surface size is much smaller. If your app supports PiP, the layout needs to respond to the dramatically reduced dimensions.
Debugging lifecycle issues
When lifecycle-related bugs appear — state lost after backgrounding, incorrect behaviour after screen lock, crashes on rotation — the diagnostic approach involves watching both the Android and Dart lifecycle simultaneously:
On the Android side:
adb logcat -s FlutterActivity:V ActivityManager:VThis shows the Activity lifecycle callbacks as they happen: onResume, onPause, onStop, onSaveInstanceState, onDestroy, onCreate. It also shows ActivityManagerService's perspective — when it triggers the transitions and why.
On the Dart side, add logging in your didChangeAppLifecycleState:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
debugPrint('AppLifecycle: $state');
// ...
}Comparing the two streams shows the mapping in real time. When you see onPause on the Android side but no corresponding state change on the Dart side, or a delay between them, you're seeing the cross-runtime communication path (JNI → engine → Dart event loop).
For the process death scenario, the definitive test:
# Background your app
# Then:
adb shell am kill your.package.nameThis simulates the LMK killing your process. Now reopen the app from recents. Whatever happens on screen is what your users experience after real process death. If the app shows the wrong screen, loses authentication, or crashes — that's the bug.
The honest summary
Flutter's relationship with the Activity lifecycle is pragmatic, not elegant. Flutter overrides most configuration changes to avoid Activity recreation. It maps Android's granular lifecycle callbacks to a simpler Dart-side model. It provides a restoration framework that few apps use. It assumes the Dart runtime persists for the process's lifetime and treats process death as an exceptional case.
This works well enough for most apps. The lifecycle mapping is correct for the common paths (foreground → background → foreground). The configuration change override eliminates the most common source of state loss. The process death scenario is rare enough on modern devices with ample RAM that many apps ship without handling it.
But if you're building an app where state loss is costly — a form-heavy app, a creative tool, a financial app — you need to understand both sides of the lifecycle. The Android system will destroy your Activity under specific, documented conditions. The Flutter framework will protect you from most of those conditions but not all. The gap is where your app's resilience strategy needs to live.
The next post moves to the visual layer: how your rendered frames get from Impeller to actual pixels on the display, through SurfaceFlinger and the hardware compositor.
This is Post 7 of the Android Under the Surface series. Previous: ART and the Flutter Engine: Two Runtimes, One Process. Next: From Render Tree to Pixels: The Display Pipeline.*