If you've ever built an app in Flutter, you've almost certainly run into this question at some point: "I tested my app with flutter run — now how do I turn that into something I can send a client, or upload to the Play Store?" It's such a common sticking point that nearly every new Flutter developer trips over it at least once.
This article covers everything from the absolute basics to the finer details — every command, every concept, and every mistake people commonly make. This isn't a "just copy-paste this" guide; we'll also explain why each command works the way it does, so that the next time an error shows up, you'll actually understand where the problem is coming from.
What Exactly Is a Flutter APK?
APK stands for Android Package Kit. It's a compressed file (structurally similar to a ZIP) that packages your entire app — code, images, fonts, resources, and configuration — into a single unit. When you install any app on an Android phone, you're really installing an APK file.
In Flutter, the code you write (in Dart) can't run directly on a phone. It has to go through a process called a "build" — during this process, your Dart code gets converted into native machine code, then packaged together with Android's resources to produce a final APK file.
This is exactly why you need to rebuild the app every time you change your code — whether that's for development (flutter run) or for a final release (flutter build apk).
Debug Build vs. Release Build — A Distinction You Need to Understand
This is the first and most essential concept every Flutter developer needs to have clear in their head. Flutter produces two very different kinds of builds, and each serves a completely different purpose.
Debug Build
When you run your app with flutter run, or run flutter build apk without the --release flag, Flutter produces a debug build. Debug builds have a few defining characteristics:
- They support hot reload — changes show up on screen instantly, without restarting the whole app.
- They include debug symbols — if the app crashes, you'll get the exact line number where things went wrong.
- They're much heavier — sometimes 500MB to 700MB+, because they bundle libraries for every CPU architecture (armeabi-v7a, arm64-v8a, x86, x86_64) all at once, plus extra debugging data.
- They run comparatively slower, since the code isn't optimized — it's compiled just-in-time (JIT) for quick iteration, not for performance.
A debug build exists purely for your own testing. Don't send it to a client, and don't upload it to the Play Store — it's strictly a development-phase artifact.
Release Build
When you run flutter build apk --release or flutter build appbundle --release, Flutter produces a release build. In this build:
- Code is AOT (Ahead-Of-Time) compiled — meaning it's fully converted into native machine code (ARM or x86 instructions) beforehand. This makes the app run much faster.
- Debug symbols are stripped out, which significantly reduces file size.
- Code is minified/shrunk (via ProGuard/R8) — unused code gets removed.
- The final file size is typically 20MB to 80MB, depending on how many libraries and assets your app includes.
This is the build you send to a client or upload to the Play Store.
Command #1 — flutter run for Development Testing
The first command every Flutter developer uses constantly:
flutter run
This installs and launches the app directly on your connected phone (or emulator), running in debug mode by default. If your phone is connected via USB and "USB debugging" is enabled in Developer Options, this command will just work.
One especially useful feature: while the app is running, pressing r in the terminal triggers hot reload (changes appear instantly). Pressing R (capital) triggers a hot restart — needed for bigger changes, like modifying a state variable's initial value or adding a new asset/image.
A Quick Tip — Hot Reload vs. Hot Restart
Many new developers get confused about the difference:
- Hot Reload (`r`) — reflects only UI-code changes; the app's current state (like a variable's current value) is preserved. Fast.
- Hot Restart (`R`) — restarts the entire app fresh (state resets), but doesn't rebuild the whole project the way
flutter rundoes. So it's faster than re-runningflutter runfrom scratch.
If a change doesn't seem to be showing up on screen (especially for assets or native-configuration changes), always try a hot restart first — and if that doesn't work either, stop the app entirely and re-run flutter run.
Command #2 — Clearing the Old Build Cache
flutter clean
This command completely deletes your project's build/ folder. This folder is Flutter's auto-generated cache — every time you build, the output accumulates here. Over time, this folder can grow quite large (sometimes 2-3 GB).
Running flutter clean is completely safe — it never deletes your source code, only build artifacts, which get regenerated automatically the next time you build.
When to run this command:
- When you hit a strange build error that doesn't make sense (stale cache issues can cause odd errors)
- After changing configuration files like
android/app/build.gradle - Before building the final release APK — always start with a fresh, clean build so no old/corrupted cache gets used
- When disk space is running low and the project is taking up too much room
Command #3 — Fetching Dependencies
flutter pub get
Every Flutter project has a pubspec.yaml file that lists your app's dependencies (external packages/libraries) — things like firebase_core, http, provider, etc. This command downloads all those packages from the internet and installs them into your project.
You need to run this whenever:
- You add a new package to
pubspec.yaml - You've just run
flutter clean(dependencies sometimes need to be re-fetched) - You clone a fresh project from GitHub — dependencies aren't installed by default
Command #4 — Building a Testing APK to Send to a Client
Now for the important part — once your code is ready and you need a testable APK file to send to a client:
flutter build apk --release
This produces a universal APK — meaning it includes code for every Android CPU architecture (armeabi-v7a for older devices, arm64-v8a for modern devices, x86_64 for some emulators/tablets). Because of this, the file is a bit larger (60-100MB range), but it works on any Android device.
You'll find the file here:
build/app/outputs/flutter-apk/app-release.apk
Need a Smaller File? Split Per ABI
If you want the smallest possible file (say, for testing on one specific device), use:
flutter build apk --release --split-per-abi
This produces 4 separate APK files, each targeting a specific architecture:
app-armeabi-v7a-release.apk— for older 32-bit devicesapp-arm64-v8a-release.apk— for modern phones (roughly 99% of Android phones today)app-x86_64-release.apk— for certain tablets/emulatorsapp-x86-release.apk— for older x86 devices
Each individual file is roughly 30-40MB (much smaller than the universal APK), since it only contains libraries for one architecture instead of bundling them all.
Practical tip: If you need to send something to a client for testing, just send app-arm64-v8a-release.apk — it works on roughly 99% of Android phones made since 2019.
Command #5 — The Real Command for the Play Store
This is a critical point that a lot of people miss — the Google Play Store doesn't accept APKs for new or updated app releases. It requires a different format: the App Bundle (AAB).
flutter build appbundle --release
This produces:
build/app/outputs/bundle/release/app-release.aab
What's the difference between APK and AAB? An APK is a "fully ready" install file — it has all resources/architectures pre-packed. An AAB is more like "raw material" — Google Play processes this file itself and delivers only the parts relevant to each user's specific device. This is called "Dynamic Delivery."
The benefits:
- Users get a smaller download (even if your AAB file itself is 70-80MB, users might only download 20-30MB)
- Each device gets only the libraries for its own architecture — everything else is automatically excluded
Simple rule to remember: APK for testing/direct installs, AAB always for Play Store uploads.
App Signing — The Most Confusing but Most Important Topic
This is where most new developers get stuck. Let's break it down from the fundamentals.
Why Signing Is Necessary
Every Android app must be signed with a digital signature — without one, the app can't even be installed (Android enforces this). This signature acts like a "digital identity" — it proves you built the app, and that no one has tampered with it along the way.
When you run flutter run, Flutter automatically uses a debug signing key (auto-generated by Flutter/Android SDK — you don't need to do anything). But this debug key isn't valid for the Play Store — the Play Store requires a proper "release" signing key, which you have to create yourself.
Creating a Release Keystore
Keystores are created using a tool called keytool, which comes bundled with Java (automatically installed when you install Android Studio).
On Windows, first locate the keytool path — it isn't registered in PATH by default. Run:
flutter doctor -v
Somewhere in the output you'll see a line like Java binary at: C:\...\Android Studio\jbr\bin\java. The keytool.exe file lives in that same folder.
Now, the command to create a keystore:
keytool -genkey -v -keystore upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
If keytool isn't in your PATH, you'll need to use the full path, wrapped in quotes (since the path may contain spaces):
"C:\Android\Android Studio\jbr\bin\keytool.exe" -genkey -v -keystore upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
Running this will prompt you for a few things — a password (any strong password you'd like), your name, organization, city, state, and country code (IN for India). At the end it'll ask "Is this correct?" — type yes.
Avoiding Password Typos
A common issue: when you type your password, the terminal shows nothing (hidden input for security), which increases the chance of a typo. To avoid this, you can pass the password directly in the command:
keytool -genkey -v -keystore upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload -storepass YOUR_PASSWORD -keypass YOUR_PASSWORD -dname "CN=App Name, OU=Unknown, O=Company, L=City, ST=State, C=IN"
This skips all the prompts and creates the keystore directly — and the password will be exactly what you typed, with zero risk of a typing mismatch.
Where to Keep Your Keystore — Extremely Important
This keystore file (`upload-keystore.jks`) is your app's "master identity." Keep these points in mind:
- Never send it to anyone (not even your client) — it should only ever be in your possession.
- Back it up safely — a private folder in Google Drive, or a password manager.
- If it's lost, future updates will never be uploadable again — Google Play will reject them with a "signature mismatch." Once an app is published, changing the keystore is extremely difficult (in many cases, effectively impossible).
- Keep the password written down somewhere safe too.
Creating the `key.properties` File
Now you need to connect the keystore to your project. Create a small text file at android/key.properties:
storePassword=YOUR_PASSWORD
keyPassword=YOUR_PASSWORD
keyAlias=upload
storeFile=../upload-keystore.jks
Important note: the ../ in the storeFile path is necessary. This is because Gradle looks for files starting from inside android/app/, and the keystore is usually kept in android/ (one level up). Without the ../, you'll get an error: "Keystore file not found".
Wiring Up Signing in `build.gradle`
Now, inside android/app/build.gradle, you need to set things up so the release build automatically signs with your new keystore instead of the debug keystore.
Right at the top (right after the plugins { } block), add:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
Then, inside the android { } block, add signingConfigs:
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
And point your buildTypes { release { } } block at this signing config:
buildTypes {
release {
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
This block might currently say signingConfig signingConfigs.debug — that's wrong for production, since it would sign your release APK with the debug keystore, which the Play Store will reject. Always point it to signingConfigs.release.
The Certificate (.pem File) — What It Is
Occasionally you'll need to extract a public certificate from your keystore — especially when Play Console asks for one during "Play App Signing" setup. The command:
keytool -export -rfc -alias upload -keystore upload-keystore.jks -file upload_certificate.pem
This .pem file is a public certificate — it's safe to send to a client (if they'll be uploading it to Play Console themselves). Unlike the keystore, it isn't "secret" — it's only used for verification, and can't be used to sign anything.
A simple analogy: the keystore (.jks) is your key — it's what you use to open/sign the door (your app). The certificate (.pem) is a photocopy of that key — it lets people verify it matches your key, but it can't actually open the door.
Real-World Case Study — Shrinking an APK from 505MB to 33MB
Here's a real example that helps illustrate why an APK's size can suddenly balloon, and how to debug it.
On one project, a release APK came out at 505.8 MB — while a normal Flutter release APK is typically 60-100MB. The first step was running a size analysis:
flutter build apk --release --analyze-size --target-platform android-arm64
The report showed that the lib/arm64-v8a folder alone was 168 MB — meaning the problem was in the native libraries.
To dig in manually, the .apk file was renamed to .zip and extracted, then the lib/arm64-v8a/ folder was opened. Inside, libflutter.so was 156 MB — compared to a normal size of 8-10MB.
The root cause turned out to be a line someone had added to android/app/build.gradle:
packagingOptions {
doNotStrip "**/*.so"
}
This line was telling Gradle not to strip debug symbols from native libraries. Normally, release builds strip these symbols automatically, significantly reducing file size. This line was explicitly blocking that.
The fix was simple — remove the entire packagingOptions block, then run:
flutter clean
flutter build apk --release --split-per-abi
And the size dropped straight from 168MB to 33.7MB.
Lesson worth remembering: if your APK/AAB is suddenly huge (200MB+), the first place to check is build.gradle for any custom packagingOptions block, especially a doNotStrip line.
Common Errors and Their Solutions
Here are errors every developer eventually runs into, and what they actually mean:
Error: "'keytool' is not recognized as an internal or external command"
Meaning: keytool isn't registered in your system's PATH.
Fix: Run flutter doctor -v, find the "Java binary at:" line, and locate keytool.exe in that same folder. Use the full path in your command (wrap it in quotes if it contains spaces).
Error: "Keystore file '...\app\upload-keystore.jks' not found for signing config 'release'"
Meaning: The storeFile path in key.properties is wrong — Gradle is looking inside android/app/, but the keystore is actually one level up, in android/.
Fix: In key.properties, write storeFile=../upload-keystore.jks — add the ../.
Error: "keystore password was incorrect"
Meaning: The password in key.properties doesn't match the password set when the keystore was created (usually because of a typo during hidden-input entry).
Fix: Recreate the keystore, this time passing -storepass/-keypass directly in the command (as shown above), so there's no risk of a typing mismatch.
Error: "App not installed" (when installing a new APK on a phone)
Meaning: A version of the app already installed on the phone was signed with a different keystore (e.g., the debug keystore), and the new APK is signed with a different (release) keystore — Android treats these as conflicting apps.
Fix: Uninstall the old app from the phone first, then install the new APK.
"Cannot perform code size analysis when building for multiple ABIs"
Meaning: You used the --analyze-size flag without specifying a target platform.
Fix: Add --target-platform android-arm64 to the command:
flutter build apk --release --analyze-size --target-platform android-arm64
Bracket-Mismatch Errors — The Most Common Mistake While Editing Code
When editing a piece of a widget tree in a Flutter file (say, changing a button's onTap), a very common mistake is accidentally deleting a necessary bracket or piece of a widget. This produces errors like:
Error: Can't find ']' to match '['
Error: Too many positional arguments
Error: Expected an identifier, but got ']'
These errors are all essentially saying the same thing — somewhere, a {, }, (, or ) is mismatched. The best way to fix it:
- Find the original, complete block of the widget/method you edited (or just undo with
Ctrl+Z) - Carefully compare the new code against the old — check for a missing
child:,children:, or closing bracket - Use VS Code's bracket-matching feature — place your cursor on a bracket and press
Ctrl+Shift+\to jump to its matching bracket
The Complete Play Store Upload Process
Once your .aab file is ready, uploading to the Play Store generally looks like this:
- Go to Google Play Console and create a developer account (if you don't have one already — there's a one-time $25 registration fee)
- Click "Create app" to set up a new app entry
- Fill in the app name, category, description, screenshots, etc. (Store Listing section)
- Go to the "Release" section — for your first release, it's best practice to start with "Internal testing" or "Closed testing", not go straight to production
- Click "Create new release" and upload your
.aabfile - If this is your first time (a new app), Play Console will ask you to set up "Play App Signing" — this is where you'll upload your
upload_certificate.pem - Write release notes and submit for review
- Google Play's review process usually takes anywhere from a few hours to 1-2 days
Final Pre-Launch Checklist
Before your final upload to the Play Store, make sure to go through this checklist:
- ✅ Ran
flutter cleanand took a fresh build - ✅ Signed with the release keystore (not the debug keystore)
- ✅ Updated the app's
versionCodeandversionName(inandroid/app/build.gradle, insidedefaultConfig) - ✅ App icon and splash screen are correct (no leftover placeholder branding)
- ✅ App size is within a normal range (if it's 150MB+, check for something like a
doNotStripsetting) - ✅ Internet permission and other required permissions are set in
AndroidManifest.xml - ✅ Installed and manually tested the release APK yourself
- ✅ Safely backed up
upload-keystore.jksand its password
Frequently Asked Questions
Q1. Can I use the same keystore across multiple apps?
Technically possible, but not recommended. Best practice is a separate keystore per app, so that if one app's keystore is ever compromised, the others aren't affected.
Q2. What happens if I lose my keystore?
If the app hasn't been published yet, just create a new keystore and publish with that. But if the app is already published and the keystore is lost, uploading future updates becomes extremely difficult. Google Play has a "Key Upload Reset" process that can help, but it's a complex process requiring contact with Google Play support — which is exactly why you should always back up your keystore in 2-3 places.
Q3. What's the difference between `flutter build apk` and `flutter build apk --release`?
Without the --release flag, it builds a debug build (heavier, slower). Always include --release for a smaller, faster APK.
Q4. Can you install a `.aab` file directly on a phone?
No, .aab files can't be installed directly — they're only meant for uploading to Play Console. For testing, always build and use a .apk file.
Q5. Why does a release build take so long?
A release build AOT-compiles your Dart code (fully converts it to native machine code), minifies/shrinks the code, and optimizes resources — a much heavier process than a debug build. 15-25 minutes is completely normal, especially for apps with a fair number of dependencies (Firebase, ads, etc.).
Conclusion
Building a Flutter APK can feel confusing the first time around — there are a lot of commands, keystores, signing, gradle configuration — but once the concepts click, the process becomes pretty mechanical. The most important things to remember:
flutter runfor development testing- Sending a build to a client:
flutter build apk --release --split-per-abi - For the Play Store:
flutter build appbundle --release - Create a dedicated release keystore for signing, and always keep it safe
- If your APK size suddenly seems too large, check
build.gradlefor a custompackagingOptionsblock
If you need help with your Flutter app's development, build process, or Play Store deployment, or if you're stuck on a specific error, reach out to us — email us at admin@rupeshtechnologies.com and we'll help you out.