You run flutter build apk --release. A few minutes later, you have a file: build/app/outputs/flutter-apk/app-release.apk. You install it on a device. The app runs.
Between your Dart source code and the running process, your code undergoes a series of transformations — compiled, optimised, packaged, signed, verified, extracted, loaded, and executed. Each transformation has a reason, a mechanism, and failure modes that manifest as build errors, runtime crashes, or subtle performance issues.
This post traces the entire build and install pipeline, from flutter build to the first frame. Every step maps to concepts from earlier posts in this series — the process model (Post 1), Zygote (Post 2), the kernel (Post 3), ART (Post 6), the sandbox (Post 9).
For a general, more beginner-friendy explanation of the Dart copmilation proces, visit our Dart compilation guide.
Phase 1: Dart compilation
The first thing that happens when you run flutter build apk --release is the Dart AOT (Ahead-Of-Time) compiler.
The AOT compiler takes your Dart source code — every .dart file in your lib/ directory, plus every dependency from pub get — and compiles it into native ARM machine code. Not bytecode. Not an intermediate representation. Actual ARM64 instructions that the CPU executes directly.
The compiler runs on your development machine (x86_64 or ARM64 macOS/Linux), but produces code for the target architecture (ARM64 for modern phones, ARM32 for older devices if you build both). This is cross-compilation — the same concept as a C cross-compiler, applied to Dart.
The output is libapp.so — a shared library in ELF format (the standard binary format for Linux executables and libraries). This file contains:
- Your compiled Dart code. Every function, every class method, every constructor — compiled to native instructions.
- The Dart heap snapshot. Compile-time constant objects (strings, const constructors, immutable data structures) are pre-allocated in the snapshot and embedded in the binary. At runtime, the Dart VM maps this snapshot into memory directly rather than allocating and initialising each object.
- Metadata. Stack trace information (so crash reports show Dart function names), type metadata for reflection and runtime type checks, and GC metadata (so the garbage collector knows which fields in each object are pointers).
If you build with --obfuscate, the compiler replaces meaningful names with short, opaque identifiers in the metadata. The compiled machine code is identical — obfuscation affects symbol names and stack traces, not the instructions themselves. (This is covered in depth in the security series.)
The --split-debug-info flag separates the symbol mapping into a separate file, reducing the APK size while still allowing symbolication of crash reports from the separate debug info.
libapp.so for a moderately sized Flutter app is typically 5-15MB. Large apps with many dependencies can reach 20-30MB.
Phase 2: Gradle and the Android build
After the Dart compiler produces libapp.so, the Flutter build tool hands control to Gradle — the Android build system. Gradle handles everything on the Android side.
Resource compilation (AAPT2)
Android resources — layouts, drawables, strings, styles — are compiled by AAPT2 (Android Asset Packaging Tool 2) into a binary format. For a Flutter app, the Android resources are minimal: the splash screen theme, the app icon, possibly some native UI for plugins. But they still go through the same compilation path as a native Android app.
Kotlin/Java compilation
The Kotlin compiler compiles your MainActivity.kt (usually a thin FlutterActivity subclass), any plugin Kotlin code, and the Flutter embedding code into JVM bytecode (.class files).
DEX compilation (D8/R8)
JVM bytecode doesn't run on Android. Android uses its own bytecode format: DEX (Dalvik Executable). The D8 compiler converts .class files to .dex files.
In release builds, R8 replaces D8. R8 is both a DEX compiler and an optimiser/shrinker. It:
- Tree-shakes: removes unused classes and methods. If your app doesn't use a particular plugin API, R8 removes its code.
- Optimises: inlines small methods, removes dead code branches, simplifies constant expressions.
- Obfuscates: renames classes and methods to short identifiers (separate from Dart obfuscation — this is for the Kotlin/Java code).
- Produces DEX bytecode: the final output that ART will execute.
R8's configuration is controlled by ProGuard-format rules files. Flutter's Gradle plugin adds default rules that prevent R8 from removing code that the Flutter engine accesses via reflection or JNI. Plugin authors can add their own rules (in proguard-rules.pro) to protect their JNI-accessed code.
R8 failures are among the most confusing build/runtime errors in Flutter. The build succeeds, but at runtime, a Kotlin class that a plugin needs has been removed by R8 because it looked unused from R8's perspective (but was accessed via JNI or reflection). The crash manifests as a ClassNotFoundException or NoSuchMethodError. The fix is a keep rule telling R8 not to remove the class.
Native library packaging
The Dart AOT output (libapp.so) and the Flutter engine (libflutter.so) are native shared libraries. Gradle packages them into the APK under lib/arm64-v8a/ (for ARM64) and optionally lib/armeabi-v7a/ (for ARM32).
Plugin native libraries are also included here. A plugin that uses C/C++ code (via Dart FFI or through Kotlin JNI) contributes its own .so files. The lib/ directory in the APK can contain native code from:
libapp.so— your Dart code (5-15MB)libflutter.so— the Flutter engine (8-10MB)- Plugin native libraries — varies (Firebase might add 2-3MB, ML libraries more)
Asset packaging
Flutter assets (images, fonts, JSON files, anything in your assets/ directory) are collected and written to assets/flutter_assets/ in the APK. They're not compiled or transformed — just copied. At runtime, the Flutter engine loads them from this path using the Android AssetManager.
The pubspec.yaml assets: section determines which files are included. Missing an asset declaration is a common build-time omission that manifests at runtime as a "Unable to load asset" error.
Phase 3: APK assembly and signing
Gradle assembles all the components into an APK:
app-release.apk
├── AndroidManifest.xml (binary XML, app configuration)
├── classes.dex (Kotlin/Java code, DEX format)
├── classes2.dex (more DEX if multidex)
├── res/ (compiled Android resources)
├── assets/
│ └── flutter_assets/ (Flutter assets: images, fonts, etc.)
├── lib/
│ ├── arm64-v8a/
│ │ ├── libapp.so (your compiled Dart code)
│ │ ├── libflutter.so (Flutter engine)
│ │ └── lib*.so (plugin native libraries)
│ └── armeabi-v7a/ (optional 32-bit libraries)
├── META-INF/
│ ├── MANIFEST.MF (file hashes)
│ ├── CERT.SF (signed hashes)
│ └── CERT.RSA (signing certificate)
└── resources.arsc (compiled resource table)Signing
Every APK must be signed before it can be installed. The signing process:
- Every file in the APK is hashed (SHA-256).
- The hashes are collected into
MANIFEST.MF. MANIFEST.MFis signed with your release key, producingCERT.SFandCERT.RSA.- For APK Signature Scheme v2/v3 (required for modern Android), the entire APK is additionally signed with a whole-file signature stored in the APK Signing Block — a special section placed just before the ZIP central directory.
The signing key is the identity of your app. It ties together every version of your app: updates must be signed with the same key, or the system rejects them. Google Play App Signing manages production keys centrally, so losing your local key doesn't prevent updates.
Alignment
zipalign optimises the APK by aligning uncompressed entries to 4-byte boundaries. This allows the system to mmap the APK contents directly without copying, which is faster for loading native libraries and assets.
Phase 4: Installation
You run adb install app-release.apk (or the user installs from the Play Store). Here's what the system does:
Package verification
PackageManagerService (PMS) in system_server examines the APK:
- Signature verification. Checks that the APK's signature is valid and that the signing certificate is consistent with any previously installed version of the same package.
- Manifest parsing. Reads
AndroidManifest.xmlto determine the package name, version, requested permissions, declared components (Activities, Services, BroadcastReceivers), and minimum/target SDK versions. - Permission review. Records the permissions the app requests. Dangerous permissions aren't granted yet — they'll be requested at runtime.
UID assignment
If this is a fresh install (no previous version), PMS assigns a new UID. The UID is stored in /data/system/packages.xml and persists across reboots. If this is an update, the existing UID is kept — your app's UID is stable for its lifetime on the device.
File extraction
PMS copies the APK to /data/app/~~<random>/your.package.name-<random>/. The native libraries (.so files) are extracted to the lib/ subdirectory if they're not stored uncompressed in the APK.
The APK itself stays as a single file. Android doesn't unzip the entire APK — resources and assets are read from it at runtime via memory mapping (mmap). This is why zipalign matters: properly aligned content can be mmap'd and accessed without copying.
DEX optimisation (dexopt)
ART optimises the DEX bytecode for the device's specific CPU. On modern Android (7+), this isn't a single-step compilation — ART initially interprets and JIT-compiles code at runtime, then uses the resulting profiles (plus cloud profiles from other devices) to AOT-compile the hottest methods during idle maintenance:
# You can see the compilation status:
adb shell cmd package compile -m speed-profile -f your.package.nameThe output is stored as an OAT file (.odex) or a VDEX file in /data/app/.../oat/. The first launch after installation may rely on interpretation and JIT while ART compiles hot methods in the background (using the bg-dexopt service).
On modern Android (10+), ART uses cloud profiles — aggregated usage profiles from other users running the same app — to guide compilation. The system compiles the methods most likely to be used, speeding up the first launch without compiling everything.
For Flutter apps, the DEX code is thin (the Flutter embedding and plugins), so dexopt is fast. The heavyweight code — libapp.so and libflutter.so — is already native ARM code and doesn't go through dexopt.
Data directory creation
PMS creates /data/data/your.package.name/ with the correct UID ownership and permissions. This is your app's sandbox. Subdirectories (files/, cache/, databases/, shared_prefs/) are created on demand when your app first writes to them.
SELinux labelling
The installed files receive appropriate SELinux labels. Your APK gets the apk_data_file label. Your data directory gets app_data_file. These labels determine which SELinux contexts (processes) can access the files.
Phase 5: First launch
The user taps the app icon. The launch sequence from Post 2 begins:
- Launcher → AMS → Zygote → fork() — new process created (Post 2)
- Process gets PID, inherits ART from Zygote — ART is ready immediately
- ART loads DEX code — your thin
FlutterActivityand plugin code - `Application.onCreate()` — app component initialisation
- `FlutterActivity.onCreate()` — this is the critical moment
In FlutterActivity.onCreate():
System.loadLibrary("flutter")
→ dlopen("/data/app/.../lib/arm64/libflutter.so")
→ kernel: sys_openat, sys_mmap (map the .so into the address space)
→ dynamic linker: resolve symbols, run init functions
→ Flutter engine global state initialised
FlutterEngine.create()
→ Engine creates threads (UI, raster, I/O)
→ Engine initialises Impeller (GPU context, shader pipeline)
FlutterEngine.runDartEntrypoint()
→ dlopen("/data/app/.../lib/arm64/libapp.so")
→ kernel: sys_openat, sys_mmap
→ Dart heap snapshot mapped into memory
→ Dart VM creates the root isolate
→ main() starts executing
runApp(MyApp())
→ Widget tree builds
→ Layout computes
→ Paint produces layer tree
→ Raster thread renders to Surface
→ eglSwapBuffers → BufferQueue → SurfaceFlinger → displayThe first frame is on screen. The user sees the app.
The APK vs App Bundle
flutter build apk produces a single APK containing native libraries for all target architectures (ARM64, ARM32, x86_64 for emulators). This means users on ARM64 devices download ARM32 and x86_64 libraries they'll never use.
flutter build appbundle produces an Android App Bundle (AAB) — a publishing format that the Play Store uses to generate optimised APKs per device. A user on an ARM64 device gets an APK containing only ARM64 libraries, ARM64-specific resources, and the density-appropriate assets. The download size is typically 30-40% smaller than the universal APK.
The App Bundle also enables deferred components — Dart code that isn't loaded at startup but downloaded on demand. This can reduce initial download size for apps with large features that not every user accesses immediately.
For Play Store distribution, AABs are required (since August 2021). For direct distribution (enterprise, testing, sideloading), APKs are still used.
Debug vs Release: what changes
The build pipeline differs significantly between debug and release:
| Aspect | Debug | Release |
|--------|-------|---------|
| Dart compilation | JIT (Just-In-Time) | AOT (Ahead-Of-Time) |
| Dart code format | Kernel binary (source-like) | libapp.so (native ARM) |
| Hot reload | Supported | Not available |
| Dart VM | Full VM with JIT compiler | Minimal runtime (no JIT) |
| R8/ProGuard | Disabled | Enabled |
| Flutter engine | libflutter.so (debug) | libflutter.so (release, stripped) |
| Assertions | Enabled | Removed by compiler |
| debugPrint | Works | Works (but shouldn't be in production) |
| Observatory/DevTools | Available | Not available |
| --obfuscate | Not applicable | Optional |
| APK size | ~60-80MB | ~20-40MB |
| Startup time | Slower (JIT warm-up) | Faster (pre-compiled) |
The debug build includes the full Dart JIT compiler, the Dart observatory (for DevTools), and unoptimised engine code. This is why debug builds are significantly larger and slower — they carry development tools that don't exist in release.
Profile builds (flutter build apk --profile) use AOT compilation (like release) but include the observatory and frame timing instrumentation. This gives you release-like performance with debugging tools — the right mode for profiling.
Build failures and what they mean
Common build failures map to specific pipeline stages:
"Dart compilation failed" — Your Dart code has a type error, a missing import, or a syntax error that the AOT compiler caught. The error message comes from the Dart compiler (gen_snapshot), not from dart analyze.
"Execution failed for task ':app:minifyReleaseWithR8'" — R8 (the Kotlin/Java shrinker) failed. Often because a keep rule is missing and R8 removed something that's accessed via reflection. Check proguard-rules.pro.
"NDK not found" or "CMake error" — A plugin with native C/C++ code needs the NDK (Native Development Kit) to compile. Install the required NDK version via Android Studio's SDK Manager.
"Signing failed" — The keystore is missing, the password is wrong, or the keystore format is incompatible. For release builds, the signing configuration in android/app/build.gradle must point to a valid keystore.
"MultiDex error" — The DEX format has a 65,536 method limit per file. If your app (including all plugins) exceeds this, you need multidex support. Flutter enables this by default for minSdkVersion 21+ (which is all modern Flutter apps).
Large APK size — Check flutter build apk --analyze-size. The report shows which components contribute to size: Dart code, native libraries, assets, and resources. Large libapp.so usually means heavy dependencies. Large assets means unoptimised images or fonts.
The complete chain
From your development machine to photons on a screen:
Your Dart source (.dart files)
→ Dart AOT compiler (gen_snapshot) → libapp.so (native ARM code)
Your Kotlin plugin code (.kt files)
→ Kotlin compiler → .class files → R8 → classes.dex (DEX bytecode)
Flutter engine (prebuilt)
→ libflutter.so (native ARM code, built by Google)
Your assets (images, fonts)
→ Copied to assets/flutter_assets/
All of the above → APK (signed ZIP archive)
→ Installed by PackageManagerService
→ APK copied to /data/app/
→ UID assigned, data directory created
→ DEX optimised by ART
→ SELinux labels applied
User taps icon
→ Zygote fork → new process
→ ART loads DEX (thin embedding)
→ dlopen(libflutter.so) → engine init
→ dlopen(libapp.so) → Dart VM starts
→ main() → runApp() → build → layout → paint
→ Impeller → GPU → Surface → SurfaceFlinger → display
→ Photons leave the panelEvery step in this chain maps to a concept from this series. The process model (Post 1) is where the app lives. Zygote (Post 2) is how it starts fast. The kernel (Post 3) mediates every I/O operation. Binder (Post 4) connects it to system services. Memory (Post 5) is how the kernel manages the physical resources. ART and the Dart VM (Post 6) are the two runtimes inside the process. The lifecycle (Post 7) is how the system controls the Activity. SurfaceFlinger (Post 8) composites the frame for display. The sandbox (Post 9) keeps everything isolated.
Ten layers of infrastructure, built over decades by different teams solving different problems, all cooperating to turn your Widget build() method into something a human can see and touch.
This is Post 10 of the [Android Under the Surface](/blog/android-under-the-surface) series. Previous: [The Sandbox: Permissions, SELinux, and Isolation](/blog/android-sandbox-permissions-selinux-flutter). First post: [Your App Is a Linux Process](/blog/flutter-app-linux-process-android-internals).