Best Practice #
A mature and professional release cycle is one that runs routinely, predictably, and doesn’t feel like a big panic for the development team. In large-scale production environments, releasing an app isn’t just pressing a compilation button and uploading it to the app store. This process involves ensuring binary code security, availability of background error monitoring (crash monitoring), gradual release strategies to minimize new bug impacts, and readiness of fast recovery plans (rollback plans) if system failures occur after launch.
In this closing article of the Flutter tutorial series, we’ll summarize all best practices, build problem handling steps (troubleshooting), and the final checklist you must run to ensure your app release goes smoothly without obstacles.
Versioning Automation #
Writing version numbers and build numbers manually directly in the pubspec.yaml file every time you want to do a release compilation is highly not recommended. This manual method is vulnerable to human negligence (like forgetting to increment the build number) resulting in binaries being rejected by app store consoles after you’ve waited through long compilation times.
As a solution, you can automate version number updates in your CI/CD pipeline by reading values from Git Tags (e.g., the v2.4.1 tag) and using the CI/CD server execution sequence number (CI run number) as a dynamic build number.
Here’s an example GitHub Actions script step snippet to automatically rewrite the pubspec.yaml file:
# Versioning automation step on the CI/CD server
- name: Extract Version from Git Tag
if: startsWith(github.ref, 'refs/tags/v')
id: get_version
run: |
# Convert the v2.4.1 tag format to 2.4.1
TAG_NAME=${GITHUB_REF#refs/tags/v}
echo "VERSION_NAME=$TAG_NAME" >> $GITHUB_OUTPUT
# Use the GitHub run_number as a build number that always increments uniquely
echo "VERSION_CODE=${{ github.run_number }}" >> $GITHUB_OUTPUT
- name: Update pubspec.yaml Version Dynamic
run: |
# Replace the version line in pubspec.yaml using the sed regex expression
sed -i "s/^version:.*/version: ${{ steps.get_version.outputs.VERSION_NAME }}+${{ steps.get_version.outputs.VERSION_CODE }}/" pubspec.yaml
Staged Rollout Strategy #
When releasing a new major version, it’s very important not to publish it directly to $100%$ of your user base. You must leverage the Staged Rollout feature in the Google Play Console or Phased Release in App Store Connect. This tactic limits update distribution to only a small portion of users in the early days to detect if there are critical bugs that slipped through during internal testing.
1. Rollout Monitoring Threshold Metrics #
During the gradual release phase, strictly monitor your app performance metrics. Safe healthy release standards are:
- Crash Rate: Must be below $0.1%$ (the strict Google Play Console threshold for “good standing” is a maximum of $0.47%$).
- ANR Rate (App Not Responding): Must be below $0.2%$ (the Google Play Console threshold is $0.47%$).
- Average Release Rating: Doesn’t experience sharp downward trends on new versions.
2. Halt Release Policy #
You must immediately pause/halt the gradual release distribution process if you detect one of the following conditions:
- A crash rate spike exceeding $1.5%$ occurs within the first 24 hours.
- Critical app workflow paths (critical paths) don’t function (e.g., users fail to log in, payment gateway failures, or the app exits by itself/crashes right after being opened).
- There are indications of local database corruption when migrating from old versions.
Release App Security #
Publicly distributed release compilation binaries can be decompiled back by irresponsible parties to study the source code. You must protect your app’s integrity using the following security tactics:
1. Enabling Dart Code Obfuscation #
Obfuscation disguises class names, methods, and variables in your Dart code into meaningless short random characters.
# Run compilation including the obfuscation flag
flutter build appbundle --release \
--obfuscate \
--split-debug-info=./build/symbols
2. Securing API Keys and Tokens at the Native Level #
Storing production secret API keys openly in Dart string variables is a dangerous action because those variables can be easily extracted using ordinary binary text reading tools (strings extraction tools).
- Tactic 1: Use your own backend proxy so the Flutter app doesn’t need to call third-party key APIs directly.
- Tactic 2: If forced to use native SDKs requiring API keys, store those keys at the native platform level (Android Keystore or iOS Keychain/Secure Enclave), then call their values through Platform Channels asynchronously to the Dart side when needed.
3. Disable Debugging and Backup in AndroidManifest #
Make sure your android/app/src/main/AndroidManifest.xml file rejects automatic data backup features (because they can be extracted via ADB) and turns off the debug flag:
<application
android:allowBackup="false"
android:debuggable="false"
...>
Background Error Monitoring (Crash Monitoring) #
You must not release an app into users’ hands without installing a background error tracking system. You need a tool that automatically captures error stack traces and reports them to a centralized dashboard when the app crashes on user devices. The most popular industry choices are Firebase Crashlytics (great because it’s free and integrated in the Firebase ecosystem) and Sentry (provides detailed native stack trace tracking, transaction performance analysis, and precise user interaction flow recording).
1. Firebase Crashlytics Setup #
Here’s an example of Firebase Crashlytics initialization inside your main main.dart entry point file. This configuration handles synchronous errors from within the Flutter framework as well as asynchronous errors (uncaught asynchronous errors) occurring outside the Dart event loop.
// lib/main.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// A. Capture all fatal errors from within the Flutter framework
FlutterError.onError = (FlutterErrorDetails details) {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
};
// B. Capture all uncaught external async errors in the Dart event loop
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
To make error reports easier to read and analyze in the Firebase Console dashboard, you must include user context identification metadata (e.g., user roles or subscription levels) without violating user data privacy policies:
class AuthController {
Future<void> loginUser(String userId, String email) async {
try {
// Set metadata in the crash report if the user successfully logs in
await FirebaseCrashlytics.instance.setUserIdentifier(userId);
await FirebaseCrashlytics.instance.setCustomKey('user_role', 'premium');
await FirebaseCrashlytics.instance.setCustomKey('os_version', 'Android 13');
} catch (e, stack) {
// Record as a non-fatal error to the dashboard
await FirebaseCrashlytics.instance.recordError(e, stack, fatal: false);
}
}
}
2. Sentry Setup for Detailed Tracking (Advanced Tracking) #
Sentry is a premium option very favored by enterprise-scale teams because it can track breadcrumbs (screen interaction steps users performed moments before errors occur). This makes bug reproduction easier in your testing labs.
Here’s how to integrate Sentry into your Flutter app:
// lib/main_sentry.dart
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'core/config/app_config.dart';
void main() async {
// Initialize Sentry asynchronously wrapping the runApp function
await SentryFlutter.init(
(options) {
options.dsn = 'https://***@o0.ingest.sentry.io/project_id';
options.tracesSampleRate = AppConfig.isProduction ? 0.1 : 1.0; // 10% sampling in prod to save quota
options.environment = AppConfig.isProduction ? 'production' : 'development';
options.release = 'toko-kita@${AppConfig.apiUrl}'; // Connect to release version information
},
appRunner: () => runApp(const MyApp()),
);
}
To manually record errors including category tags and custom data scopes in Sentry, use the following method:
void processProductPayment(String invoiceId) async {
try {
await sendTransactionHTTPToServer();
} catch (exception, stackTrace) {
// Capture the error manually with custom identification tags
await Sentry.captureException(
exception,
stackTrace: stackTrace,
withScope: (scope) {
scope.setTag('transaction', 'checkout_payment');
scope.setExtra('invoice_id', invoiceId);
scope.level = SentryLevel.fatal;
},
);
}
}
Error Recovery Strategies (Rollback & Force Update) #
If critical problems occur in production, you need a fast recovery plan to minimize the number of affected users.
1. Binary Rollback Procedures #
- Android (Play Store): You can’t just press a “return to the previous version” button. You must rebuild code from the previous stable compilation, increment the build number one level above the broken production version, then upload it as a new AAB file.
- iOS (App Store): You can stop the release version’s sale in App Store Connect to prevent new users from downloading it. However, for users who already downloaded the broken version, you must release a new fix build as soon as possible.
2. Force Update Middleware Implementation #
To force users to update their apps when the versions on their phones are deemed to have security vulnerabilities or critical transaction bugs, you can design a minimum version checking system when the app is first launched.
// lib/core/utils/version_checker.dart
class VersionChecker {
/// Checks whether the current version is below the server-required minimum version.
static bool checkIfForceUpdateNeeded({
required String currentVersion,
required String requiredMinimumVersion,
}) {
final List<int> currentParts = currentVersion.split('.').map(int.parse).toList();
final List<int> minParts = requiredMinimumVersion.split('.').map(int.parse).toList();
for (var i = 0; i < 3; i++) {
if (currentParts[i] < minParts[i]) {
return true; // Force update needed because the current version is too outdated
}
if (currentParts[i] > minParts[i]) {
return false; // Safe, the current version is above the minimum version
}
}
return false;
}
}
If the method above returns true, show a permanent modal dialog blocking access to the app’s main pages and provide a direct button directing users to the Google Play Store or Apple App Store link.
Common Build Error Troubleshooting #
The release compilation process often faces build failure problems that don’t occur in debug testing modes. Here’s a systematic handling flow chart if your app experiences release build failures:
flowchart TD
Start["Failed to Do a Release Build"] --> CheckPlatform{"Which Platform Failed?"}
CheckPlatform -- Android --> IdentifyAndroid{"What Type of Android Error?"}
IdentifyAndroid -- "Gradle Out of Memory (OOM)" --> FixOOM["Increase org.gradle.jvmargs in gradle.properties"]
IdentifyAndroid -- "Library Dependency Conflict" --> FixDep["Run ./gradlew app:dependencies & resolve versions"]
IdentifyAndroid -- "Keystore/Signing Mismatch" --> FixSign["Check key.properties & make sure the keystore file exists"]
CheckPlatform -- iOS --> IdentifyiOS{"What Type of iOS Error?"}
IdentifyiOS -- "Certificate / Profile Not Found" --> FixCert["Open Xcode, verify the Keychain & Apple Developer Team"]
IdentifyiOS -- "CocoaPods Cache / Podfile Lock" --> FixPods["Run pod cache clean --all & rm -rf Pods"]
IdentifyiOS -- "Corrupted Xcode Cache" --> FixXcode["Clean the DerivedData folder & run flutter clean"]
FixOOM --> Verify["Run flutter clean & Try Building Again"]
FixDep --> Verify
FixSign --> Verify
FixCert --> Verify
FixPods --> Verify
FixXcode --> Verify
Verify --> End(["The Build Compiled Successfully"])The Ultimate Clean Command #
If the build error cause is unclear (often due to corrupted CocoaPods or Gradle caches), run this total cleanup command in the terminal:
# 1. Clean the Flutter build cache
flutter clean
# 2. Remove and clean the CocoaPods cache on iOS
cd ios
rm -rf Pods Podfile.lock
pod cache clean --all
pod install --repo-update
cd ..
# 3. Stop all Android Gradle daemons running in the background
cd android
./gradlew --stop
cd ..
Final Release Readiness Checklist Worksheet #
Run this audit worksheet $24-48$ hours before launching your app to the production release console:
1. Pre-Release & Testing #
- The app has been test-run on release Android and iOS physical devices in offline mode to observe storage cache behavior.
- All authentication processes (login, register, token refresh) have been verified running smoothly.
- Push notification and deep link integration behaviors have been tested working in release mode.
- All draft files, testing dummy data, and mock database flags have been turned off or switched to real production server endpoints.
2. Security Compliance #
- The
key.properties(Android) and.dart_define/*.json(Dart) files have been confirmed not uploaded to Git repositories. - The binary code has been compiled using the
--obfuscatecompiler encryption. - The
android:debuggableflag in the Android manifest is set tofalse.
3. Monitoring & Operations #
- The Firebase Crashlytics or Sentry dashboard is active and ready to receive crash data from the latest release version.
- Release notes have been translated into local user languages and don’t contain internal developer technical terms.
- The force update schema has been prepared in the release backend server database.
Summary #
- Version Automation: Integrate automatic
pubspec.yamlversion writing using Git Tags on CI/CD servers to avoid binary rejections due to build number duplication.- Gradual Releases: Always launch new releases gradually (starting from $5%$) and monitor crash ratios ($<0.1%$) before releasing $100%$ to the public.
- Release Security: Secure your binary code from decompilation using the
--obfuscatecommand and avoid storing API Keys nakedly in Dart source code.- Real-time Monitoring: Must install fatal runtime error catchers (
PlatformDispatcher.instance.onError) connected to Firebase Crashlytics or Sentry before publication.- Release Scheduling: Avoid releasing app updates on Friday afternoons or approaching national holidays to anticipate emergency bug handling needs.