In the mobile development, it is a common requirement that your app code base need to be able switch between different backend environment, as

  • Development
  • Staging
  • Production

For Flutter, the common way to achieve this is by using Flavors. Using Flavors, we can create different binaries from the single code base and connect to different backend environments.

Idea of switching backend environment

The reason I am writing this article while there is already an official documentation, is that 1. the official documentation is a little bit short & hard to understand. 2. it is not describing the particular use case of switching backend environment. 3. I don’t agree some of the directions, I think there should be a better way.

Since Flavor is a concept that were first introduced in Android development, applying it to iOS development is a little bit tricky. I try my best to make it easy.

Let’s just dive in! Here is what we want to achieve in this article. I will skip Staging environment to avoid the complexity for explanation. (Also I understand that a lot of small scale project skips Staging for better ROI)

Example to demonstrate

Create different binary with different application ID for Android

Let’s start with the easier Android case.

Edit android/app/build.gradle like below

android {
...
defaultConfig {
// 1. Edit the applicationId base
applicationId "com.example.flutterFlavorBestPractice"
}

...

// 2. Create flavors
flavorDimensions "default"

productFlavors {
dev {
dimension "default"
resValue "string", "app_name", "Flavor Dev"
// dev flavor's applicationId will be
// "com.example.flutterFlavorBestPractice.dev"
applicationIdSuffix ".dev"
}
prod {
dimension "default"
resValue "string", "app_name", "Flavor"
// prod flavor's applicationId will stay as the same as base
// "com.example.flutterFlavorBestPractice"
applicationIdSuffix ""
}
}
}

The resValue part defines a resource you can access from Android app. We can access the defined app_name as a resource string and change application name accordingly. To do that, add android:label in android/app/src/main/AndroidManifest.xml like below:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.flutter_flavor_best_practice">
<application
android:label="@string/app_name"
...
>
...
</application>
</manifest>

Now, you can launch the Dev version of Android app via

flutter run --flavor dev -d YOUR_ANDROID_SIMULATOR_DEVICE_ID

and Prod version of Android app via

flutter run --flavor prod -d YOUR_ANDROID_SIMULATOR_DEVICE_ID

Your device’s launcher screen would look like this:

Voila! You have created two different app instances from a single Flutter code base.

Create different binary with different bundle ID for iOS

For iOS, it is a bit more tricky than Android, especially if you are not familiar with a concept called Scheme and Configuration. In simple words, the Scheme is an iOS equivalent of Flavor, but it works with Configurations in order to change build time settings.

Also, one thing you need to know is that the Scheme name and the Flutter Flavor name needs to match. So, in our case we need to create two Schemes named “dev”/”prod”. And the Configuration names should follow the exact naming conventions, being post fixed with those Flavor names like “Debug-dev”/”Release-prod” etc.

General relations between Flutter Flavor/Xcode Scheme/Xcode Configuration can be pictured as below.

Hopefully you got the gist of the concepts, so let’s dive in. When you create a Flutter app using flutter create, we already have a Scheme named “Runner”. Let’s change it to “dev” first.

Open iOS/Runner.xcworkspace with Xcode and select “Manage Schemes”

Rename “Runner” to “dev”

Now, let’s duplicate another scheme and name it to “prod”.

After all, you will see something like this (Make sure you have “Shared” checkbox selected. Otherwise, the scheme will not be part of source control):

Now, we need to deal with another concept called Configuration. By default, Flutter prepares “Debug”/”Release”/”Profile” Configurations, but we need to duplicate and create “Debug-dev”/”Release-dev”/”Profile-dev”/“Debug-prod”/”Release-prod”/”Profile-prod” Configurations. The postfix part needs to match the corresponding Flutter Flavor name.

You can duplicate/rename “Debug” Configuration to create “Debug-xxx” Configurations. (And similarly for “Release-xxx”/”Profile-xxx”)

After all, you should see something like this:

Now we need to associate the Schemes with the Configurations. Select “Edit Scheme”

For “prod” scheme, select “XXX-prod” configurations. For “dev” scheme, select “XXX-dev” configurations.

After creating new Configurations, we need to apply them to iOS/Podfile.

project 'Runner', {
'Debug-dev' => :debug,
'Profile-dev' => :release,
'Release-dev' => :release,
'Debug-prod' => :debug,
'Profile-prod' => :release,
'Release-prod' => :release,
}

Phewww, it was quite a long process for whom not familiar with Scheme/Configuration, but finally we have the foundation established for the “dev”/”prod” Flavors for iOS too. From here, utilizing the Scheme/Configuration we created, we can set bundle IDs and application display names.

First, we want to set different bundle identifiers for different Flavors. Select “Runner” target and select “Signing & Capabilities”. Specify “dev” Flavor’s bundle identifier for “XXX-dev” Configurations. If you need to change bundle identfier for “prod” Flavor as well, do it accordingly.

And finally, to achieve the same feat as with the Android version so far, we want to set different application name for different Flavors. To do that, first set PRODUCT_NAME environment variable for different Configurations. Go to “Build Settings” and find “Product Name”, change the values as you wish for the app names.

And in the “Info” tab, specify the $(PRODUCT_NAME) for Bundle display name.

Finally, we achieved the same feat as in the Android version. Let’s run the both Flavors on iOS.

flutter run --flavor dev -d YOUR_IOS_SIMULATOR_DEVICE_ID
flutter run --flavor prod -d YOUR_IOS_SIMULATOR_DEVICE_ID

Now your iOS Simulator’s home screen would look like this:

Set different launch icons for Flavors in Android

It is nice that we can distinguish the binaries on the home screen by app name, but it would be even better if we had different launch icons. That is fairly easy once we are at this stage. (Creating the icon is probably the harder part if you want to do it right. However, that part is outside the scope of this article.)

Assuming you have created the dev version of the ic_launcher.png, name it to ic_launcher_dev.png and place it under mipmap-xxx folders. (In the example, I am only placing it under mipmap-xxxhdpi for my laziness ;))

Now, edit the android/app/build.gradle file. We will define a variable we can use in the AndroidManifest.xml file later, basically.

    productFlavors {
dev {
...
manifestPlaceholders = [
appIcon: "@mipmap/ic_launcher_dev"
]
}
prod {
...
manifestPlaceholders = [
appIcon: "@mipmap/ic_launcher"
]
}

Then, we specify the variable for app icon in android/app/src/main/AndroidManifest.xml.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.flutter_flavor_best_practice">
<application
...
android:icon="${appIcon}">
...
</application>
</manifest>

That’s it. Launch both flavors via flutter run command and the launcher view you will see will be like this:

Set different launch icons for Flavors in iOS

For iOS, you need to create the icon set in Asset Catalog (Again, how to generate it is outside the scope of this article, but a website like MakeAppIcon will come out handy.) Name the dev version of the iconset to be something like “AppIcon-Dev”.

And specify that iconset from “Build Setting”, only from “XXX-dev” Configurations.

That is it. Launch both flavors via flutter run command and the launcher view you will see will be like this:

Switch backend environments for different Flavors

It’s been a bit of a long journey, but we reached at the juicy part of this article and also, finally we are back to the nice/familiar Flutter world 😅

Officially recommended way seems to switch main-xxx.dart file by specifying --target option when running/building Flutter app, but I personally don’t like it. Here are the reasoning:

  • You will end up quite a few boilerplate code overlapping in the main-xxx.dart files
  • You need to specify multiple options ( --flavor/--target ) with the correct combination. If you specify --flavor dev and miss specifying --target option for example, it would run default main.dart file and could wreak havoc. I want to control the environment properly by ONLY specifying Flavor.

In order to switch the backend URL from Flutter code base, we need to be able to detect the Flavor from the Flutter code base, but this is not possible as far as I know (Please correct me if somebody knows otherwise 🙏🏻).

Instead, we can utilize the application ID/bundle ID we set up per flavor. We can access the application ID/bundle ID using package_info_plus package.

First install package_info_plus.

flutter pub add package_info_plus

And add following environment.dart file in your source code.

import 'package:package_info_plus/package_info_plus.dart';

const apiBaseUrlProd = "https://example.com/api";
const apiBaseUrlDev = "https://dev.example.com/api";

enum EnvironmentType {
dev(apiBaseUrlDev),
prod(apiBaseUrlProd);

const EnvironmentType(this.apiBaseUrl);
final String apiBaseUrl;
}

class Environment {
static EnvironmentType? _current;

static Future<EnvironmentType> current() async {
if (_current != null) {
return _current!;
}

final packageInfo = await PackageInfo.fromPlatform();

switch (packageInfo.packageName) {
case "com.example.flutterFlavorBestPractice.dev":
_current = EnvironmentType.dev;
break;
default:
_current = EnvironmentType.prod;
}
return _current!;
}
}

Basically, it switches EnvironmentType based on the packageName, and associates with different base API URLs.

void main() async {
WidgetsFlutterBinding.ensureInitialized();

final currentEnv = await Environment.current();
print("Connecting to ${currentEnv.apiBaseUrl}");
...
}

Then, you launch both flavors and you will see different base URLs.

Debug console after launching dev Flavor
Debug console after launching prod Flavor

Following this pattern of the code, you can access to the proper API URL from any part of your Flutter code.

Switch Firebase environments for different Flavors

Okay, but what if your backend was Firebase? Flutter-Firebase combo is a very common environment and in that environment, you cannot simply switch by base URL.

There can be several ways, but I want to explain the simplest way using flutter_fire.

First, create four different apps in Firebase. Make sure you specify the same application ID/bundle ID as you specified in the app. (In this example, I created all apps in the same Firebase project being lazy, but it is better practice to separate Dev/Prod in separate projects.)

Install firebase cli and flutter_fire following the instruction.

firebase login

and make sure you are logged into the Google account that has access to the above Firebase project.

Now, run flutterfire config specifying the dev app. Specifying --out file that is different from the prod version is also an important part.

flutterfire config \
--project=flutter-flavor-best-practice \
--out=lib/firebase_options_dev.dart \
--ios-bundle-id=com.example.flutterFlavorBestPractice.dev \
--android-app-id=com.example.flutterFlavorBestPractice.dev

Do the same for prod app.

flutterfire config \
--project=flutter-flavor-best-practice \
--out=lib/firebase_options_prod.dart \
--ios-bundle-id=com.example.flutterFlavorBestPractice \
--android-app-id=com.example.flutterFlavorBestPractice

The final touch is to specify the proper class depending on the EnvironmentType like we did to obtain the API base URL.

import 'package:flutter_flavor_best_practice/firebase_options_dev.dart' as dev;
import 'package:flutter_flavor_best_practice/firebase_options_prod.dart'
as prod;

...

final currentEnv = await Environment.current();
switch (currentEnv) {
case EnvironmentType.dev:
await Firebase.initializeApp(
options: dev.DefaultFirebaseOptions.currentPlatform);
break;
default:
await Firebase.initializeApp(
options: prod.DefaultFirebaseOptions.currentPlatform);
}

That’s it!!

Switch Firebase environments for different Flavors without FlutterFire

I honestly see no reason to configure Firebase project for Flutter without using FlutterFire. (Please let me know if there is good use cases) But since there are projects configured without using it, let me cover that scenario as well.

For Android, as usual, it’s quite easy. You just need to create folders for flavors and put corresponding google-services.json (You download them from Firebase project settings) inside them.

But there is a caveat for Android without FlutterFire. You have to make sure that you add dependencies to Firebase library properly in build.gradle.

In android/build.gradle, you have to

buildscript {
...
dependencies {
...
classpath 'com.google.gms:google-services:4.3.15'
}
}

In android/app/build.gradle, you have to

...
apply plugin: 'com.google.gms.google-services'
...
dependencies {
...
implementation platform('com.google.firebase:firebase-bom:30.2.0')
// If you add analytics
implementation 'com.google.firebase:firebase-analytics'
}

manually even after including Firebase package(s) via

% flutter pub add firebase_core
% flutter pub add firebase_analytics // If you want to add analytics

FlutterFire is nice because it does these things automatically.

Next up, iOS. iOS flavor stuff is a bit more tricky as usual. You need to write your own script to copy proper GoogleService-Info.plist into the app bundle.

First, create flavor folders under ios folder and put corresponding GoogleService-Info.plist in them.

Next, open iOS/Runner.xcworkspace and add new Run Script phase.

Name it something like “Copy GoogleService-Info” and copy&paste following script (Got most of the idea from this article).

environment="default"

# Regex to extract the scheme name from the Build Configuration
# We have named our Build Configurations as Debug-dev, Debug-prod etc.
# Here, dev and prod are the scheme names. This kind of naming is required by Flutter for flavors to work.
# We are using the $CONFIGURATION variable available in the XCode build environment to extract
# the environment (or flavor)
# For eg.
# If CONFIGURATION="Debug-prod", then environment will get set to "prod".
echo "Configuration: ${CONFIGURATION}"
if [[ $CONFIGURATION =~ \-([^-]*)$ ]]; then
flavor=${BASH_REMATCH[1]}
fi

echo "Flavor: $flavor"

# Name and path of the resource we're copying
GOOGLESERVICE_INFO_PLIST=GoogleService-Info.plist
GOOGLESERVICE_INFO_FILE=${PROJECT_DIR}/flavors/${flavor}/${GOOGLESERVICE_INFO_PLIST}

# Make sure GoogleService-Info.plist exists
echo "Looking for ${GOOGLESERVICE_INFO_PLIST} in ${GOOGLESERVICE_INFO_FILE}"
if [ ! -f $GOOGLESERVICE_INFO_FILE ]
then
echo "No GoogleService-Info.plist found. Please ensure it's in the proper directory."
exit 1
fi

# Get a reference to the destination location for the GoogleService-Info.plist
# This is the default location where Firebase init code expects to find GoogleServices-Info.plist file
PLIST_DESTINATION=${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app
echo "Will copy ${GOOGLESERVICE_INFO_PLIST} to final destination: ${PLIST_DESTINATION}"

# Copy over the prod/dev GoogleService-Info.plist to destination
cp "${GOOGLESERVICE_INFO_FILE}" "${PLIST_DESTINATION}"

The script may look a bit cryptic if you are not very familiar with shell script, but actually that is it!!

Launch configuration in VS Code

This article is nearly coming to the end, but to put the icing on the cake, let’s create launch configuration to our beloved VS Code. Of course, we don’t want to just launch our Flutter app from only terminal. We want to be able to launch it from VS Code and debug.

You first need to follow “create a launch.json file”

Select “Dart & Flutter”

And add paste following snippet. It’s rather long, but you will notice these consist of a lot of repetitions. The important part is that each configuration specifies “ — flavor” and Flavor name. Each configuration will launch the app with the specified Flavor.

{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "dev",
"request": "launch",
"type": "dart",
"args": [
"--flavor",
"dev"
]
},
{
"name": "dev (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": [
"--flavor",
"dev"
]
},
{
"name": "dev (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": [
"--flavor",
"dev"
]
},
{
"name": "prod",
"request": "launch",
"type": "dart",
"args": [
"--flavor",
"prod"
]
},
{
"name": "prod (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"args": [
"--flavor",
"prod"
]
},
{
"name": "prod (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release",
"args": [
"--flavor",
"prod"
]
},
]
}

You can select the configuration to launch the app like this.

flutter_flavorizr

There is a third party library called flutter_flavorizr. It looks quite promissing. You can specify app/bundle identifiers, app name, firebase config files in pubspec.yaml file, and execute a command flutter pub run flutter_flavorizr and voila! it automatically generates configurations similar to what we went through manually.

However, at this point, I feel it is a little bit short of what I want to achieve.

  • It creates a different main.dart files for Flavor, which enforces us to specify both -t and -f options
  • Once you generate files out of pubspec.yml, there seems no way to clean it altogether. So, if you generate files based on a wrong pubspec.yaml, the wrongly generated files remain in the project and there seems no easy way to clean them up.
  • Not quite clear how Firebase initialization is done. Especially for Android version 🤔

Conclusion

I have gone through the steps on how to create Flavors to support different backend with the single code base. It is a bit tedious process, and you need to jump on to the Gradle/Xcode world, but at the same time, it is not something to be feared at all.

My sample project is published here.

Convenient tool that automates this process is desirable. Something like flutter_flavorizr looks promising, even though it seems to come with some limitations at this point.

Happy coding!! 😎

--

--

Yuichi Fujiki

Technical director, Freelance developer, a Dad, a Quadriplegic, Life of Rehab