There's something unusual about a Flutter app running on Android, and it's hidden by the fact that everything just works.
Your process has two complete runtime systems running simultaneously, in the same address space, sharing the same physical memory, managed by the same kernel process entry. One is ART — the Android Runtime — which manages the Java/Kotlin side: the FlutterActivity, any Kotlin plugin code, the Android framework classes inherited from Zygote. The other is the Dart VM, embedded inside the Flutter engine, which manages your Dart code: the widget tree, the business logic, the isolates.
Two garbage collectors. Two type systems. Two sets of threads. Two compilation strategies. One process. They don't know about each other in any deep sense — they interact through a thin, explicit boundary of function calls and shared data. Understanding this boundary explains a lot about Flutter's architecture and its practical limitations.
ART: the host runtime
ART was already running before your Flutter code loaded. It was running before your app's process even knew it would be a Flutter app. It was running in Zygote, and when Zygote forked to create your process, the child inherited a fully initialised ART instance.
ART's job in a Flutter app is narrower than in a native Kotlin app, but it's not trivial:
Lifecycle management. Your FlutterActivity is a real Android Activity subclass, managed by ART. ART calls onCreate(), onResume(), onPause(), onDestroy(). These lifecycle methods are where the Flutter engine gets started and stopped. Without ART running the Activity lifecycle, the Flutter engine would never start.
Plugin code. Every Flutter plugin that has an Android implementation runs Kotlin or Java code managed by ART. The path_provider plugin uses ART to call Context.getFilesDir(). The camera plugin uses ART to interact with the Camera2 API. The firebase_messaging plugin uses ART to register for FCM tokens. This code is compiled (either ahead-of-time into native code by ART, or from DEX bytecode), managed by ART's garbage collector, and runs on ART-managed threads.
System service interaction. All Binder IPC goes through ART-managed code. When the Flutter engine needs to interact with a system service — requesting permissions, checking connectivity, resolving intents — the call goes through ART.
The Android framework. The View system (the FlutterView that hosts the Flutter surface), the window manager interaction, the input dispatch — all managed by ART. The SurfaceView that gives Flutter its rendering surface is an Android framework object, created and managed by ART.
In a native Kotlin app, ART is the only runtime, and it does everything. In a Flutter app, ART handles the Android platform integration while the Flutter engine handles the actual application.
The Flutter engine: the guest runtime
The Flutter engine is a C++ library — libflutter.so — loaded into the process's address space by a System.loadLibrary("flutter") call in FlutterActivity.onCreate(). This is a JNI call: ART's native interface for loading and calling C/C++ code.
Inside libflutter.so lives:
The Dart VM. A complete virtual machine for the Dart language. It manages Dart isolates (each with its own heap), the Dart garbage collector, and the execution of AOT-compiled Dart code from libapp.so.
Impeller (or Skia on older versions). The rendering engine that takes a layer tree (produced by the Dart framework's layout and paint phases) and turns it into GPU commands. Impeller talks to the GPU through kernel ioctls (Post 3) — it calls into OpenGL ES or Vulkan, which ultimately reach the GPU kernel driver.
The platform embedding. C++ code that bridges between the engine and the host platform — receiving touch events from Android, managing the rendering surface, handling platform channel messages, coordinating threading.
The text layout engine. libtxt (wrapping HarfBuzz and ICU) for text shaping and layout. This runs in C++, not in Dart, because text shaping is performance-critical and benefits from native-speed execution.
When the engine loads, it creates four threads (as described in Post 1): the platform thread (which is the same as the Android main thread — ART's UI thread), the UI thread (where Dart code runs), the raster thread (where Impeller renders), and the I/O thread (for background tasks like image decoding).
The boundary between them
ART and the Dart VM communicate through a specific, narrow interface. The mechanism is JNI (Java Native Interface) on the ART side and C function calls on the engine side.
Here's the flow for a touch event — one of the most performance-critical paths:
[Android main thread — ART managed]
InputEvent arrives via Binder from InputManagerService
→ Android framework dispatches to FlutterView
→ FlutterView.onTouchEvent() (Kotlin, ART-managed)
→ JNI call: nativeDispatchPointerEvent(enginePtr, ...)
[Now in C++ land — engine code, same thread]
→ FlutterEngine::DispatchPointerEvent()
→ Packages the event into an engine-internal format
→ Posts it to the Dart UI thread's event queue
[Dart UI thread — Dart VM managed]
→ GestureBinding.handlePointerEvent()
→ Hit testing, gesture recognition
→ Your GestureDetector.onTap() callback fires
→ setState() → rebuild → layout → paint
→ Layer tree sent to raster thread
[Raster thread — engine C++ code]
→ Impeller renders the layer tree
→ GPU commands submitted via kernel ioctlThe ART-to-engine transition is a JNI call — a C function call with some overhead for marshalling Java/Kotlin types to C types. The engine-to-Dart transition is an internal engine operation (posting to the Dart UI thread's event loop). The entire path from hardware touch to your onTap callback crosses two runtime boundaries: ART → C++ → Dart.
For platform channel communication, the path is similar but in the programmer-visible direction:
[Dart UI thread]
methodChannel.invokeMethod('getBatteryLevel')
→ Dart VM calls into engine C++ code
→ Engine serialises the message (Standard Method Codec)
→ Engine posts message to platform thread
[Platform thread — now in ART-managed territory]
→ Engine's JNI callback invokes Java/Kotlin method channel handler
→ Kotlin: onMethodCall("getBatteryLevel", result)
→ Kotlin code runs (ART-managed), calls system services via Binder
→ result.success(73)
→ JNI call back into engine: reply with result
[Engine posts reply to Dart UI thread]
→ Dart Future completes with 73Each crossing — Dart to C++, C++ to ART, ART to C++, C++ to Dart — has a cost. Not enormous (microseconds each), but not zero. This is the structural reason platform channel calls have measurable latency even for trivial operations: the data crosses two runtime boundaries and involves thread synchronisation.
Two garbage collectors
This is where the two-runtime architecture gets concretely interesting.
ART has its own garbage collector — a concurrent, generational collector that manages Java/Kotlin objects on ART's heap. The Dart VM has a separate garbage collector — also generational (new space and old space, as covered in the FFI memory series), managing Dart objects on the Dart heap.
They know nothing about each other. ART's GC doesn't scan the Dart heap. The Dart GC doesn't scan ART's heap. If a Kotlin object holds a reference to data that the Dart side also references, neither GC knows about the other's reference.
This is fine for most Flutter apps, but it matters when you're working with plugins that hold native resources:
// Dart side
final camera = await CameraController.create();
// This creates:
// 1. A Dart object (CameraController) on the Dart heap
// 2. A Kotlin object (Camera2 session) on ART's heap
// 3. Kernel resources (file descriptors for the camera device)Three levels of resource, managed by three different systems. The Dart GC can collect the Dart object. ART's GC can collect the Kotlin object. The kernel closes file descriptors when the process dies. But none of these systems coordinates with the others automatically.
If the Dart side drops its reference to the CameraController without calling dispose(), the Dart GC eventually collects the Dart object. But the Kotlin camera session and the kernel file descriptors stay alive until someone explicitly releases them. The Dart GC has no way to trigger cleanup on the ART side — it doesn't know ART exists.
This is why dispose() matters in Flutter, and why finalizers (Dart's Finalizer and NativeFinalizer) exist but are unreliable for critical cleanup. They're a best-effort bridge between two GC systems that don't coordinate. The reliable pattern is explicit cleanup: call dispose(), which sends a platform channel message to the Kotlin side, which releases the ART-managed object and closes the kernel resources.
Two compilation models
ART and the Dart VM use different compilation strategies, and understanding both explains what you see in profiling tools.
ART's compilation (Android 7+):
- When your app first runs, ART interprets DEX bytecode and JIT-compiles hot methods on the fly. This gives acceptable performance immediately without a long install-time wait.
- ART profiles which methods are actually executed. During idle device maintenance (
bg-dexopt), it uses these profiles — plus cloud profiles aggregated from other devices — to AOT-compile the most-used methods into native ARM code, stored in.odex/.artfiles on the/datapartition. - Over time, more methods are profiled and compiled. This is why native Android apps can get slightly faster with use — ART continuously optimises based on real execution patterns (Profile-Guided Optimisation).
- The compiled code lives in the process's address space, managed by ART's code cache.
Dart's compilation (in release mode):
- The Dart AOT compiler runs at build time on your development machine. It compiles Dart source into native ARM code, stored as
libapp.soin the APK. - There is no runtime recompilation in release mode. The code is fixed at build time. This is why Dart AOT code is predictable in performance but can't adapt to runtime profiles.
- In debug mode, the Dart VM uses a JIT compiler — which is why debug builds are slower (JIT compilation overhead) but support hot reload (code can be replaced at runtime).
Both sets of compiled code live in the same process address space. When you look at /proc/<pid>/maps (Post 1), you see both:
5580004000-5580a8c000 r-xp .../libapp.so ← Dart AOT code
7b3c000000-7b3c800000 r-xp .../libflutter.so ← Engine C++ code
...
7c00000000-7c04000000 r-xp .../base.odex ← ART-compiled framework codeDifferent compilers, different runtimes, same address space. The CPU doesn't care which compiler produced the instructions — it just executes them.
Memory layout of a Flutter process
Putting it all together, the virtual address space of a running Flutter app looks roughly like this:
┌─────────────────────────────────────┐ High addresses
│ Kernel space (inaccessible to app) │
├─────────────────────────────────────┤
│ Stack (main thread) │ ~8MB per thread
│ Stack (UI thread) │
│ Stack (raster thread) │
│ Stack (I/O thread) │
│ Stack (other threads × ~25) │
├─────────────────────────────────────┤
│ Memory-mapped files │
│ - APK contents │
│ - Font files │
│ - Shader caches │
├─────────────────────────────────────┤
│ ART heap (Java/Kotlin objects) │ Typically 20-50MB
│ - Framework objects │
│ - Plugin objects │
│ - ART internal state │
├─────────────────────────────────────┤
│ Dart heap (new space) │ Typically 4-16MB
│ Dart heap (old space) │ Typically 30-200MB
│ - Widget trees │
│ - State objects │
│ - Cached data │
│ - Image pixel buffers │
├─────────────────────────────────────┤
│ Native heap (C++ allocations) │
│ - Impeller resources │
│ - Text layout caches │
│ - Engine internal state │
├─────────────────────────────────────┤
│ GPU memory mappings │
│ - Textures (Impeller) │
│ - Command buffers │
├─────────────────────────────────────┤
│ Code segments │
│ - libapp.so (Dart AOT code) │ ~5-15MB
│ - libflutter.so (engine) │ ~8-10MB
│ - base.odex (ART framework code) │ ~30-50MB (shared via Zygote)
│ - libc.so, libm.so, etc. │
├─────────────────────────────────────┤
│ Binder mmap region │ 1MB (Post 4)
├─────────────────────────────────────┤
│ ... │
└─────────────────────────────────────┘ Low addressesThe ART heap, the Dart heap, the native heap, the code segments, the GPU mappings — all coexist in one 48-bit address space. The kernel manages the page tables that map each region to physical memory. ART manages the ART heap region. The Dart VM manages the Dart heap region. Impeller manages the GPU memory. None of them interfere with each other because virtual address ranges don't overlap and each allocator operates in its own region.
When you run dumpsys meminfo for your Flutter app, the output is confusing because Android's memory categories weren't designed for two-runtime processes. The "Native Heap" line shows malloc-based allocations from the engine's C++ code and plugin native code. But the Dart VM allocates its heaps via mmap, not malloc — so the Dart heap typically shows up under "Other" or "Private Other", not "Native Heap". Impeller's GPU resources appear under "Graphics". The total RSS (Private Dirty + Private Clean) is the real number, but no single dumpsys line gives you a clean "this is how much memory my Dart code uses" answer. The accounting boundaries don't align with the runtime boundaries.
JNI: the bridge protocol
The ART-to-engine boundary uses JNI — the standard interface for Java/Kotlin code to call C/C++ functions and vice versa. In the Flutter engine's Android embedding, you'll find code like:
// In the engine's Android embedding (C++ side)
static void DispatchPointerDataPacket(JNIEnv* env, jobject jcaller,
jlong engine_ptr,
jobject buffer,
jint position) {
auto* engine = reinterpret_cast<AndroidShellHolder*>(engine_ptr);
// ... process the pointer data
}And on the Kotlin side:
// In FlutterJNI.kt
external fun nativeDispatchPointerDataPacket(
nativeShellHolderPtr: Long,
buffer: ByteBuffer,
position: Int
)The external keyword in Kotlin (like native in Java) marks a JNI method — the implementation is in C++, not in Kotlin. When ART encounters a call to this method, it uses JNI to call the C++ function directly, in the same thread, without a context switch.
JNI calls have overhead: ART must transition from managed code to native code, which involves saving ART's managed state, setting up the JNI environment, and preventing the GC from moving objects while native code might be accessing them (JNI references pin objects in memory). This overhead is small (hundreds of nanoseconds) but non-zero, which is why the engine batches operations where possible rather than making individual JNI calls for each event.
The Long parameter (nativeShellHolderPtr) is a pointer to the C++ engine object, cast to a 64-bit integer. This is a common JNI pattern: Kotlin holds a reference to a C++ object as a raw pointer, and passes it back on each call. The C++ side casts it back to the correct type. This works because both sides are in the same address space — the pointer is valid in both ART's world and the engine's world. It's also the reason a use-after-free in the engine can corrupt ART's state and crash the whole process — no isolation between the runtimes at the memory level.
Thread sharing
One thread is shared between the two runtimes: the platform thread, which is also Android's main thread.
This thread runs ART-managed code (Activity lifecycle, plugin handlers, View system operations) and engine C++ code (platform channel dispatch, surface management). It does not run Dart code — Dart runs on its own UI thread. But it's the thread that bridges the two worlds.
When a platform channel message arrives from Dart, the engine's C++ code receives it on the platform thread and calls into ART via JNI to deliver it to the Kotlin handler. When the Kotlin handler returns a result, ART calls back into the engine via JNI, and the engine posts the result to the Dart UI thread.
The consequence: if a Kotlin plugin handler does slow work on the platform thread — a heavy computation, a synchronous disk read, a blocking Binder call — it blocks both the ART side (no lifecycle events processed, no View system updates) and the engine's platform channel dispatch (no new messages from Dart can be delivered until the thread is free).
This is the structural reason behind the advice "don't block the platform thread in plugin code." It's not just about the ANR dialog (Post 4). It's about the fact that this one thread is the bottleneck for all communication between the two runtimes.
The deeper architecture
Most mobile frameworks are single-runtime. A native Kotlin app has ART and nothing else. A React Native app runs JavaScript in a JavaScript engine (Hermes or JSC), but the bridge talks to the same ART-managed framework objects. The runtime boundaries exist but the rendering still goes through Android's View system.
Flutter is different. It brings its own rendering pipeline, its own layout system, its own animation framework, its own text engine. The only thing it shares with Android's framework is the process container and the platform integration layer. This is simultaneously its greatest strength (pixel-perfect cross-platform rendering, no View system version fragmentation) and its structural cost (two runtimes, two GCs, two sets of overhead, engine loading time that Zygote can't preload).
The two-runtime architecture also explains why Flutter's "add-to-app" mode (embedding Flutter in an existing native Android app) is architecturally sound but practically heavy. You're adding the full Dart VM and Flutter engine to an app that already has ART. The memory cost is real — 30-50MB of additional resident memory for the engine and Dart heap, on top of whatever the native app already uses.
Understanding that your Flutter app is really two programs sharing an address space — the ART program handling Android integration and the Dart program handling your application logic — is the mental model that makes the platform layer's behaviour predictable rather than mysterious.
The next post looks at the Activity lifecycle: the state machine that ART drives but that Flutter has a complicated relationship with, and why lifecycle-aware code in Flutter requires understanding both systems.
This is Post 6 of the Android Under the Surface series. Previous: Memory from the Kernel's Perspective. Next: The Activity Lifecycle and Why Flutter Fights It.