Shipping one Flutter codebase to iOS & Android
Build flavors, platform channels, and the release checklist I run before every store push.
One codebase, two app stores, and two very different sets of platform quirks. Flutter handles most of it, but the last 10% — platform-specific permissions, deep links, signing — requires a clear strategy. Here’s how to keep that manageable.
Key Takeaways
- Use build flavors for environment config, not git branches; keeps the codebase single and deployable
- Minimize platform channel code: keep native layer thin, push logic back to Dart
- Automate releases with Fastlane to eliminate manual Xcode/Android Studio steps
- Test on real devices before submission; simulator behavior often masks real issues
Build flavors, not branches
Dev, staging, and production environments live as Flutter build flavors, not separate git branches. Same code, different config — no risk of a staging API key shipping to production.
Define flavors in pubspec.yaml and build.gradle:
# pubspec.yaml
flutter:
flavors:
dev:
dimensions: "environment"
applicationId: "com.example.app.dev"
applicationIdSuffix: ".dev"
versionName: "1.0.0-dev"
staging:
dimensions: "environment"
applicationId: "com.example.app.staging"
applicationIdSuffix: ".staging"
versionName: "1.0.0-staging"
prod:
dimensions: "environment"
applicationId: "com.example.app"
versionName: "1.0.0"
In Dart, detect the active flavor at startup:
void main() {
String environment;
if (kDebugMode) {
// Check which flavor is running
environment = const String.fromEnvironment('FLAVOR', defaultValue: 'dev');
} else {
environment = const String.fromEnvironment('FLAVOR', defaultValue: 'prod');
}
if (environment == 'dev') {
setupDev();
} else if (environment == 'staging') {
setupStaging();
} else {
setupProd();
}
runApp(const MyApp());
}
Build with the flavor flag:
flutter build apk --flavor dev -t lib/main.dart
flutter build apk --flavor staging -t lib/main.dart
flutter build apk --flavor prod -t lib/main.dart
No branch switching. No forgotten env keys. Each flavor is a complete, deployable app.
Platform channels, sparingly
Most of the app never needs a platform channel. When it does — push notification permissions, deep link handling, platform-specific file access — keep the native code as thin as possible and push logic back into Dart.
A typical use case: requesting notification permissions on iOS.
// lib/services/notification_service.dart
import 'package:flutter/services.dart';
class NotificationService {
static const platform = MethodChannel('com.example.app/notifications');
static Future<bool> requestNotificationPermission() async {
try {
final bool result = await platform.invokeMethod<bool>(
'requestNotificationPermission',
) ?? false;
return result;
} catch (e) {
print('Error requesting permission: $e');
return false;
}
}
}
On the native side (iOS), keep it minimal:
// ios/Runner/GeneratedPluginRegistrant.swift or custom handler
import UserNotifications
func requestNotificationPermission() -> Bool {
var granted = false
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { success, error in
granted = success
}
return granted
}
The Dart layer handles the rest: checking permissions state, showing fallback UI, scheduling notifications. Native code only handles the OS-specific call.
Use the release checklist
Before every store push, run through this checklist:
Code:
- All tests pass locally and in CI
- No debug logging or print() statements
- No hardcoded API keys or sensitive data
- Version bump in
pubspec.yamlmatches the release version - Changelog updated with user-facing notes
Build:
- Run
flutter clean && flutter pub get - Build for the target flavor:
flutter build apk --flavor prod --release - Verify app size hasn’t ballooned (flag if >150 MB)
- Run
flutter analyzeand fix warnings
Signing:
- Android keystore is backed up and password stored securely
- iOS provisioning profiles are current (renew if within 30 days of expiry)
- Code signing identity matches the bundle ID
Testing:
- Install the production build on a real device
- Walk through the happy path: login, create content, submit
- Test deep links from a production notification
- Verify analytics events fire and reach the backend
- Check that feature flags show the intended experience for this release
Submission:
- Run Fastlane to build, sign, and submit:
fastlane ios releaseandfastlane android release - Upload screenshots and release notes to each store
- Check build status in App Store Connect and Google Play Console
- Monitor crash rates in Firebase Crashlytics for 24 hours post-release
Fastlane setup (one-time):
# Install Fastlane
gem install fastlane
# Initialize for your project
cd ios && fastlane init
cd ../android && fastlane init
# Create a lane for releases
cat > ios/fastlane/Fastfile << 'EOF'
default_platform(:ios)
platform :ios do
desc "Build and submit to App Store"
lane :release do
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
configuration: "Release",
export_method: "app-store"
)
upload_to_app_store
end
end
EOF
Running fastlane ios release builds, signs, and uploads in one command. No manual Xcode stepping through the GUI.
What changes between stores
The codebase is shared, but these differ:
| Aspect | iOS | Android |
|---|---|---|
| Build system | Xcode + CocoaPods | Gradle |
| Signing | Provisioning profiles + certificates | Keystore file |
| Notification IDs | APNs token | FCM token |
| Deep links | Universal Links (.well-known/apple-app-site-association) | Intent filters + Deep Link Verification |
| Storage | NSUserDefaults / Core Data | SharedPreferences / Room |
| File paths | Documents folder (app-scoped) | External storage (with permissions) |
Platform channels abstract these differences. The rest of the app doesn’t know which OS it’s running on.
When to graduate
This setup works until you need to scale horizontally (multiple test devices, CI/CD pipelines) or your deploy frequency outpaces what one person can babysit. Until then, build flavors, platform channels, and Fastlane are the setup to reach for first.
Takeaways
One Flutter codebase ships to two stores when you enforce a single source of truth for configuration (build flavors), keep platform integration minimal and testable (thin platform channels, logic in Dart), and automate the repetitive parts (Fastlane for signing and submission). Run the checklist before every push, test on real devices, and monitor post-release. That discipline keeps the process predictable and painless.