← ALL POSTS
FLUTTER JUN 21, 2026·9 MIN

Shipping one Flutter codebase to iOS & Android

Build flavors, platform channels, and the release checklist I run before every store push.

rj
Rollie John Jaictin
Senior Software Developer
Two smartphones side by side displaying different home screen and settings UIs

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.yaml matches 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 analyze and 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 release and fastlane 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:

AspectiOSAndroid
Build systemXcode + CocoaPodsGradle
SigningProvisioning profiles + certificatesKeystore file
Notification IDsAPNs tokenFCM token
Deep linksUniversal Links (.well-known/apple-app-site-association)Intent filters + Deep Link Verification
StorageNSUserDefaults / Core DataSharedPreferences / Room
File pathsDocuments 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.

#Flutter
Discuss this ↗