Separating Build Environment Configurations in Flutter with Firebase — Doing it the right way.

Matt Goodson
6 min readOct 29, 2019

At Komodo we recently migrated our mobile app from React Native to Flutter. One of the challenges we faced early on was configuring the application for our different environments. Like most production applications, we have multiple environments for production, staging etc. For each environment, we have separate backend services as well as a separate Firebase project. We needed a way to build the mobile app for each of the different environments so that it would connect to the correct backend services and Firebase project.

Firebase configuration is done through a google-services.json file on Android and a GoogleService-Info.plist file on iOS. The Firebase client libraries will automatically be configured based on this file. Configuration for our own backend services is done in the dart code allowing us to query the correct HTTPS endpoints.

There are a few articles and Github issues that discuss building for different Firebase projects and building with different dart configurations but many did not seem to work and none fully covered all our requirements:

  • The app can be built using a single command
  • No code changes are required when configuring (i.e. changing a dart config file)
  • Firebase and our other backend services don’t require separate configuration commands (No way to mismatch environments).
  • The solution works the same way for iOS and Android

In this tutorial, I will explain how to separate build environments in Flutter using flavours so that we can run a single command like:

flutter build ios --flavor production

or

flutter build appbundle --flavor staging

to build or run our application with the correct Firebase environment as well as a configurable environment in the dart code.

For Firebase, this will be done on Android using Android Flavors and on iOS using Xcode Schemes and a custom build step.

For dart configuration, other articles suggest using Flutter targets with multiple main.dart files to configure dart code. This would require adding a target to the build command though. This does not meet our criteria as it would, for example, allow developers to point to the staging Firebase project but the production backend services if they weren’t careful. e.g:

flutter run -t main-production.dart --flavor staging

Instead, we are going to use Flutter platform channels to allow us to access the build flavor in the dart code at runtime.

Part 1: Firebase Configuration

Android

The first step is to download the google-services.json and GoogleService-Info.plist for each of your Firebase projects.

For Android, you want to open android/app/src/ and make a new folder for each environment ie. production/ and staging/. Put the google-services.json in the correct folder for each environment. Your tree should look like this:

android
-- app
-- src
-- production
-- google-services.json
-- staging
-- google-services.json
...

Now open android/app/build.gradle. Here you are going to add your Android flavors. You want one for each environment. You can specify extra options such as applicationId for each flavor if you want but I’ll keep it simple here. Add the flavors to the file under the “android” section:

flavorDimensions "app"
productFlavors {

staging {
dimension "app"
}

production {
dimension "app"
}
}

And thats it! You can now run your app using:

flutter run --flavor MY_FLAVOR

and it should connect the the correct Firebase instance.

iOS

The is a little tricker. In Native iOS we would use Targets but this is not supported by Flutter. Instead we will using Xcode Schemes. For this part, it is best to work in Xcode as modifying directories and files does not always propagate to the xcworkspace. Open Runner.xcworkspace.

Similar to android, you will add each of the GoogleService-Info.plist files into separate folders. Under Runner/ add a folder (xcode group) called Firebase/ and inside here add a folder for each environment. Add the correct plist file in each making sure to check ‘copy items if needed’:

Folder tree should look like this

Now you need to create a configuration for each combination of environment and build configuration (There are three by default Debug, Release, Profile). For this tutorial we will ignore Profile. You can duplicate these configurations by pressing the + icon under Info/Configurations:

Duplicated configurations for each environment

Now it’s time to make the schemes. Go to Product/Scheme/Manage Schemes. Duplicate the Runner scheme for each environment:

The duplicate option is under the options menu.

Double click the new scheme to open the settings. For each scheme, set the correct Build Configuration for Run and Archive:

Setting the build configuration

The final step is to add the custom build step to copy the correct Firebase plist file into Runner/ when building the app. Go to Targets/Runner/BuildPhases and add a new build step called ‘Firebase config’ or similar. Move this step below ‘Run Script’:

Custom build phase to copy plist file.

Add the following bash script into the step:

if [ "${CONFIGURATION}" == "Debug-production" ] || [ "${CONFIGURATION}" == "Release-production" ] || [ "${CONFIGURATION}" == "Release" ]; thencp -r "${PROJECT_DIR}/Runner/Firebase/production/GoogleService-Info.plist" "${PROJECT_DIR}/Runner/GoogleService-Info.plist"elif [ "${CONFIGURATION}" == "Debug-staging" ] || [ "${CONFIGURATION}" == "Release-staging" ] || [ "${CONFIGURATION}" == "Debug" ]; thencp -r "${PROJECT_DIR}/Runner/Firebase/staging/GoogleService-Info.plist" "${PROJECT_DIR}/Runner/GoogleService-Info.plist"fi

You need to add a conditional case for each additional environment.

And you’re done! This should now work for iOS as well:

flutter run --flavor MY_FLAVOR

Part 2: Custom Dart Configuration

For custom configuration we need to make changes in the dart code. We will use platform channels to check which flavor the app was built with during runtime.

Open main.dart and add the following code in main() to get the build flavor using a MethodChannel:

const MethodChannel('flavor')
.invokeMethod<String>('getFlavor')
.then((String flavor) {
print('STARTED WITH FLAVOR $flavor');
if (flavor == 'production') {
startProduction();
} else if (flavor == 'staging') {
startStaging();
}
// add other environments here
}).catchError((error) {
print(error);
print('FAILED TO LOAD FLAVOR');
});

startProduction(), startStaging() etc. can do whatever you need. For instance, you could create a singleton which stores application configuration such as:

class AppConfig {
final String appName;
final String flavorName;
final String apiBaseUrl;

AppConfig({
this.appName,
this.flavorName,
this.apiBaseUrl,
});

static AppConfig _instance;

static AppConfig getInstance({appName, flavorName, apiBaseUrl}) {
if (_instance == null) {
_instance = AppConfig(
appName: appName, flavorName: flavorName, apiBaseUrl: apiBaseUrl);
print('APP CONFIGURED FOR: $flavorName');
return _instance;
}
return _instance;
}
}

and then:

void startProduction() {
SomeService.init(production: true);
AppConfig.getInstance(
appName: 'PRODUCTION',
flavorName: 'production',
apiBaseUrl: 'https://myservice.production/api/v1',
);
}

Then when you want to use it you can just access it like:

MakeHttpRequest(url: AppConfig.getInstance().apiBaseUrl + '/myResource');

Now we need to setup platform channels for iOS and Android.

Android

Open android/app/src/main/java/…yourapppath/MainActivity.java. Add a MethodChannel like so to return the build flavor:

public class MainActivity extends FlutterActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);

// custom method to get flavor
new
MethodChannel(getFlutterView(), "flavor").setMethodCallHandler(new MethodChannel.MethodCallHandler() {
@Override
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
result.success(BuildConfig.FLAVOR);
}
});
}
}

iOS

Open Runner/AppDelegate.m and add a FlutterMethodChannel in didFinishLaunchingWithOptions() like so:

- (BOOL)application:(UIApplication *)applicationdidFinishLaunchingWithOptions:(NSDictionary *)launchOptions {[GeneratedPluginRegistrant registerWithRegistry:self];// custom code to allow get flavorFlutterViewController* controller = (FlutterViewController*)self.window.rootViewController;FlutterMethodChannel* flavorChannel = [FlutterMethodChannel methodChannelWithName:@"flavor" binaryMessenger:controller];[flavorChannel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) {NSString* flavor = (NSString*)[[NSBundle mainBundle].infoDictionary valueForKey:@"Flavor"];result(flavor);}];return [super application:application didFinishLaunchingWithOptions:launchOptions];}

We now need to make the Flavor available in the infoDictionary. In Xcode, open Runner/Info.plist and add a key Flavor mapped to ${PRODUCT_FLAVOR}.

Info.plist

Now, under Targets/Runner/Build Settings/User-Defined and add a setting PRODUCT_FLAVOR. Add the flavor name for each configuration:

User-Defined settings

And you’re done!

You should now be able to build or run the app using a Flutter flavor and the correct dart and Firebase configuration should be used.

--

--