skip to content

Android App Bundles

A signed Android release: an app bundle rather than an APK, an upload keystore read through key.properties, and Play App Signing holding the real key. Interviewers probe lost keys and R8 crashes.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

6

For a Flutter app's first Google Play release, why build with flutter build appbundle rather than flutter build apk, and when does --split-per-abi matter?

level: juniorimportance: must knowfreq 60%

answer

  1. Play builds per-device APKs
  2. new Play apps must upload a bundle
  3. fat APK carries every ABI
  4. three ABIs in a release build
  5. split APKs bump versionCode per ABI

basics

~20 s

flutter build appbundle produces an .aab from which Google Play generates per-device APKs, and new Play apps must upload a bundle; --split-per-abi matters only when shipping APKs elsewhere, replacing one fat APK with one APK per ABI.

solid answer

~40 s

`flutter build appbundle` (release by default) writes `build/app/outputs/bundle/release/app.aab`, holding Dart AOT code and the engine for `armeabi-v7a`, `arm64-v8a` and `x86_64`. Play splits it and serves each device only its ABI and resources, and new Play apps can no longer be submitted as APKs. `flutter build apk` alone yields a fat APK with all three ABIs, which is larger for everyone; `flutter build apk --split-per-abi` writes one APK per ABI and adds `ABI_VERSION * 1000` to each `versionCode` so they do not collide. So for a plant-care app's first Play release you upload the bundle, and keep split APKs for other stores, direct downloads or testers installing by hand.

code

bash · 9 lines
bash
# Upload to Google Play
flutter build appbundle
# build/app/outputs/bundle/release/app.aab

# Distribute outside Play, one APK per CPU family
flutter build apk --split-per-abi
# build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk
# build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
# build/app/outputs/flutter-apk/app-x86_64-release.apk

go deeper

for a junior

Remember: flutter build appbundle for Google Play, flutter build apk for installing directly, and --split-per-abi to avoid one fat APK outside Play.

for a middle

Explain what Play does with a bundle (per-device APKs), which three ABIs Flutter builds, and why split APKs get per-ABI version codes.

for a senior

Choose artifacts per channel, test bundles with bundletool or internal tracks before release, and keep version codes consistent across bundles and APKs.

for a principal

Decide which distribution channels the app supports and what each costs in build variants, signing and support for users outside Play.

## Two artifacts, two audiences A Flutter Android release can be packaged two ways: - An **Android App Bundle** (`.aab`) — a publishing format. You upload it; the store builds the installable APKs from it. - An **APK** (`.apk`) — the installable package itself. You can hand it to a device directly. The commands: ```bash flutter build appbundle # -> build/app/outputs/bundle/release/app.aab flutter build apk # -> one "fat" APK with every ABI flutter build apk --split-per-abi # -> one APK per ABI ``` `flutter build` defaults to **release** mode, so none of these needs `--release`. ## What goes inside A Flutter release build contains native code: the Flutter engine and your Dart code compiled ahead of time. Native code is per **ABI** (application binary interface, the CPU architecture family). Flutter's Android release builds target three ABIs: - `armeabi-v7a` (32-bit ARM), - `arm64-v8a` (64-bit ARM), - `x86_64` (64-bit x86, mostly emulators and some devices). By default the app bundle contains all three. ## Why the bundle for Google Play When you upload an `.aab`, Google Play generates **optimised APKs per device configuration**: a phone with an `arm64-v8a` CPU downloads only the `arm64-v8a` native libraries and the resources for its screen density and language. Users download less than they would with a fat APK. There is also a hard rule: Google Play no longer accepts **new apps** submitted as APKs, so the first release of a plant-care app has to be a bundle. Uploading a bundle also means enrolling in **Play App Signing**, where Google holds the key that signs the APKs it generates and you sign uploads with your own upload key. Before uploading, you can test a bundle offline with Google's `bundletool`, which generates the same device-specific APKs locally, or online through Play's internal testing tracks. ## When APKs still matter 1. **Stores or channels without bundle support** — other app stores, an enterprise MDM, a direct download from your website. 2. **Hand-installed test builds** — sending a build to a tester, or `flutter install` onto a connected device. 3. **Size-sensitive distribution outside Play** — here `--split-per-abi` matters. A plain `flutter build apk` produces a **fat APK**: one file with native code for all three ABIs. It installs anywhere but every user downloads code for CPUs they do not have. `flutter build apk --split-per-abi` instead writes: - `app-armeabi-v7a-release.apk` - `app-arm64-v8a-release.apk` - `app-x86_64-release.apk` ## The version-code detail Stores that accept several APKs for one app require each APK to have a **distinct version code**. When you split per ABI, Flutter therefore adds `ABI_VERSION * 1000` to the `versionCode` derived from `pubspec.yaml`. If you need the plain version code for every split, the Flutter docs describe forcing it with the `force-version-code-ignoring-abi=true` Gradle property. With an app bundle this does not arise: one bundle, one version code. | | `appbundle` | `apk` | `apk --split-per-abi` | |---|---|---|---| | Upload to Google Play (new app) | yes | no | no | | Install directly on a device | no (needs `bundletool`) | yes | yes, matching ABI | | Native code per file | all ABIs, split by Play | all ABIs | one ABI | | `versionCode` | from pubspec | from pubspec | pubspec + `ABI_VERSION * 1000` | ## Where the build lands Flutter writes Android release artifacts under `build/app/outputs/`: the bundle in `bundle/release/app.aab` and APKs in `flutter-apk/`. With product flavors the flavor name appears in the file name, for example `app-staging-release.apk`. CI jobs usually collect these paths as build artifacts; a wrong glob is a common reason a pipeline "succeeds" but uploads nothing. ## Things interviewers probe - **"Why is my APK so big?"** — it is a fat APK; users of a bundle-delivered app download far less. - **"Can I sideload the `.aab`?"** — not directly; an `.aab` is not installable without `bundletool` generating APKs from it. - **"Does the bundle change what my Dart code does?"** — no; it changes packaging and delivery, not the compiled program. - **Signing still applies** to both formats: an unsigned or debug-signed bundle is rejected on upload, which is the next thing to configure.

  • Why does Flutter add ABI_VERSION * 1000 to the versionCode of split APKs?
    Stores that accept multiple APKs for one app require each APK to carry a distinct version code. Adding a per-ABI offset to the pubspec's build number makes the three split APKs unique; the Flutter docs describe a Gradle property to force the plain version code instead.
  • How can you check what a device will actually receive from an app bundle before uploading it?
    Use Google's `bundletool` to generate the device-specific APK set from the `.aab` locally and install it on a device, or upload to an internal testing track and install from Play. Both exercise the same split that real users get.

saying these in an interview costs you the question

  • Uploads a fat APK for a new Google Play app
  • Thinks an .aab file can be installed directly on a phone
  • Believes --split-per-abi is needed when uploading an app bundle
  • Adds --release to flutter build, not knowing release is the default
  • Thinks the bundle format changes how the Dart code runs
open as a page

How do you configure release signing for a Flutter Android app using an upload keystore, key.properties and signingConfigs in build.gradle.kts?

level: middleimportance: must knowfreq 55%

basics

~20 s

Create an upload keystore with keytool, describe it in android/key.properties (storePassword, keyPassword, keyAlias, storeFile), load that file in android/app/build.gradle.kts, create a release signingConfig from it, and point the release build type at it instead of the debug key.

open as a page

What does minSdk = flutter.minSdkVersion mean in a Flutter app's build.gradle.kts, and when should you replace it with a fixed number?

level: middleimportance: should knowfreq 32%

basics

~20 s

flutter.minSdkVersion is the minimum Android API level the Flutter SDK supplies through its Gradle plugin, 24 in Flutter 3.47. Replace it with a higher fixed number when a plugin requires one, or pin it to stop upgrades moving it.

open as a page

Under Play App Signing, what is the difference between a Flutter app's upload key and app signing key, and what happens if the team loses the upload keystore?

level: seniorimportance: should knowfreq 40%

basics

~20 s

You sign uploads with an upload key; Google Play signs the APKs users install with an app signing key it holds. A lost upload keystore is replaced by registering a new upload key, since the key devices trust never left Google.

open as a page

When a Flutter release build crashes at startup in a plugin's Android code while debug works, how can R8 cause it, and how do keep rules fix it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Flutter's Gradle plugin enables R8 for release builds, and R8 removes or renames JVM classes it cannot see used, such as ones reached by reflection or JNI. Add -keep rules to android/app/proguard-rules.pro, which the plugin applies automatically.

open as a page

After moving a Flutter Android app to Android Gradle Plugin 9, what do android.builtInKotlin and android.newDsl in gradle.properties do, and what migration does the app need?

level: seniorimportance: nice to knowfreq 22%

basics

~10 s

AGP 9 compiles Kotlin itself and knows only its new DSL; Flutter's android.builtInKotlin=false and android.newDsl=false are temporary escape hatches. Migrating removes kotlin-android and kotlinOptions and adds kotlin { compilerOptions { ... } }.

open as a page