Flavors in Flutter with Fastlane - yet another guide


It’s a good practice to build separate apps for development, test and production environment. In case of mobile apps a good way to have separate configurations is usage of flavors.

In this tutorial you will learn how to prepare ordinary Flutter project to have 3 different flavors (dev, test and production) and how to handle build, signing and deployment with fastlane.

TL;DR Just go to the repository where all the flavors are already configured.

Contents

Basics

The concept of flavors is taken from Android apps and can be applied to iOS in various ways (more on this later). By incorporating flavors in your project you can build your app with different configuration options, styles or feature sets. In commercial projects it’s a standard way of distributing apps.

There are several great articles on build flavors just to mention:

In this article I’ll show a similar but a subtly different approach and focus mostly on iOS part. Presented way works really well for me and my colleagues. It’s been battle tested with several apps already and is getting better with each new project.

For instance our test builds have AppCenter distribution packages to automate updates and additional logging included, dev builds have very verbose logging, and production apps come without unnecessary diagnostics but with production logging configuration.

Flutter comes with built-in flavor support but default project is not prepared to handle them. All it takes to define flavors is to add and edit few files. There are multiple ways to achieve this and with each new project you’ll have a chance to improve your approach. Especially on iOS there are multiple ways to provide different bundle ids or configuration parameters.

Fastlane

In my daily job I use fastlane to automate apps deployment to QA and app stores. In this article I will show how to use flavors with fastlane but in general you can handle flavors manually or in typical CI environment like Codemagic or Bitrise.

Fastlane allows you to define specific lanes for each app in code like deploy_to_appcenter or deploy_to_store. A set of files can describe signing, build and deployment phases. Those can be reproduced both on developer’s computer, but also on CI/CD platform. Fastlane allows to automate provisioning and signing of iOS apps as well as screenshot capture or updating the description in store. This gives us a very convenient and reproducible way of distributing our app.

There is no native support of Flutter apps in fastlane but we can define fastlane configuration for Android and iOS projects and treat them as typical native apps.

Flavors in Dart

Preparation

In this article I use Flutter v1.7.8+hotfix.3 and demo app is created with Kotlin, AndroidX, and Swift support by:

flutter create -i swift -a kotlin --androidx --org com.flutter flutter_flavors

It’s a good practice to create new projects with Kotlin and Swift support. AndroidX is a future of Android development so while starting new project you should definitely have it enabled. You will benefit from Swift later in your project when you’ll have to write some platform specific code.

How to configure Flutter project

In order to take the advantage of flavors in Flutter app you should define 3 separate main files1 that will handle all the configuration details different for each scheme. The easiest way is to rename main.dart to main_common.dart and create:

  • main_dev.dart
  • main_tst.dart
  • main_prod.dart

In each of them you can define respective configuration and later just start execution of the app from a common function.

import 'dart:async';

import 'package:flutter_flavors/app_config.dart';
import 'package:flutter_flavors/main_common.dart';

Future<void> main() async { // async can be useful if you fetch from disk or network
  // do flavor specific configuration here e.g. API endpoints
  final config = AppConfig('tst');

  mainCommon(config);
}

The AppConfig class is a used to store some basic configuration options like name or API endpoints.

In main_common.dart you should replace the 3rd line with:

void mainCommon(AppConfig config) => runApp(MyApp(config));

This step of the configuration you can investigate in commit a4c7ef8e.

How to build or run Flutter project

Typically you would run following commands to build flavored app:

Ordinary apk: flutter build apk --release -t lib/main_tst.dart --build-name=1.0.0 --build-number=1 --flavor tst

App bundle: flutter build appbundle --target-platform android-arm,android-arm64 --release -t lib/main_tst.dart --build-name=1.0.0 --build-number=1 --flavor tst

iOS: flutter build ios --release --no-codesign -t lib/main_tst.dart --build-name=1.0.0 --build-number=1 --flavor tst

Some important things to notice here:

  • we define build numbers (1) and build names (1.0.0)
  • we use tst flavor
  • we skip codesign for iOS (we’ll sign our app with fastlane)
  • we’ll sign our android app later

In order to run the app with desired flavor in VS Code you can define your own launch.json configuration. Below you may find a sample I use in my apps. You may copy it to the configuration file that opens when you click the cog wheel on debug pad in VS Code.

click this cog wheel in VS Code

{
    "version": "0.2.0",
    "configurations": [
      {
        "name": "Flutter Dev",
        "request": "launch",
        "type": "dart",
        "flutterMode": "debug",
        "program": "lib/main_dev.dart",
        "args": [
          "--flavor",
          "dev"
        ]
      },
      {
        "name": "Flutter Dev Release",
        "request": "launch",
        "type": "dart",
        "flutterMode": "release",
        "program": "lib/main_dev.dart",
        "args": [
          "--flavor",
          "dev"
        ]
      },
      {
        "name": "Flutter Profile",
        "request": "launch",
        "type": "dart",
        "flutterMode": "profile",
        "program": "lib/main_dev.dart",
        "args": [
          "--flavor",
          "dev"
        ]
      },
      {
        "name": "Flutter Test",
        "request": "launch",
        "type": "dart",
        "flutterMode": "release",
        "program": "lib/main_tst.dart",
        "args": [
          "--flavor",
          "tst"
        ]
      },
      {
        "name": "Flutter Prod",
        "request": "launch",
        "type": "dart",
        "flutterMode": "release",
        "program": "lib/main_prod.dart",
        "args": [
          "--flavor",
          "prod"
        ]
      },
    ]
  }
  

At this point these commands would fail because we haven’t defined flavors in Android and iOS apps yet.

Flavors on Android

Defining flavors on Android is really straightforward. The only file to be changed is build.gradle in app directory.

Just add the following lines after buildTypes node and before closing bracket:

    flavorDimensions "flavor-type"

    productFlavors {
        dev {
            dimension "flavor-type"
            applicationIdSuffix ".dev"
            versionNameSuffix "-dev"
            manifestPlaceholders = [appName: "Flavor DEV"]
        }
        tst {
            dimension "flavor-type"
            applicationIdSuffix ".test"
            versionNameSuffix "-test"
            manifestPlaceholders = [appName: "Flavor TST"]
        }
        prod {
            dimension "flavor-type"
            manifestPlaceholders = [appName: "Flavor"]
        }
    }

You can take a look at the exact diff here in commit cef5fbff.

Some important notes here:

  • we have different app ids for each flavor: com.flutter.flutter_flavor.dev, com.flutter.flutter_flavor.test, and com.flutter.flutter_flavor - this way you can install all 3 apps on a single device, have separate google-services.json files and distinct the app in some logging service or Firebase
  • we set different app names
  • we set different version name suffixes e.g. 1.0.0 becomes 1.0.0-test

Flavors on Android allow us to define separate resources for each of them. E.g. you can have a special icon for QA builds or different strings resources. What you need to do to provide new icon is just create mipmap-... folders with icons in app/src/tst directory. The same works for any other resources and schemes.

app
| - src
   | - debug (default)
   | - main (default)
   | - profile (default)
   | - tst (add this with desired subdirectories)

At this point you should be able to build 3 separate flavors of the app for Android.

Flavors on iOS

Typically, in iOS apps you can base flavors on build schemes. In order to configure this you’ll need macOS and Xcode. To start you should open ios/Runner.xcworkspace in Xcode.

iOS project open in Xcode

Creating schemes

Default scheme for Flutter apps is Runner. We’ll define additional 3 schemes named exactly as the previously defined flavors i.e. dev, tst and prod. You should go to Product > Scheme > Manage schemes and add them via + button. Make sure the schemes are marked as Shared.

Then you should add 3 xconfig files to Flutter directory next to Debug, Release and Generated. Right click on Flutter directory on left pad in Xcode and select New File. Select Configuration Settings File and add dev.xconfig, tst.xconfig and prod.xconfig. Make sure they’re in Flutter directory as seen on the screenshot below.

iOS configuration files

These files allow you to define custom variables that can be used later during build or in Info.plist file. We’ll define our custom app bundle ids here.

My typical dev.xconfig files look like follows:

#include "Generated.xcconfig"
BUNDLE_ID_SUFFIX=.dev
PRODUCT_BUNDLE_IDENTIFIER=com.flutter.flutterflavors.dev
FLUTTER_TARGET=lib/main_dev.dart
APP_NAME=Flavor DEV

and tst.xconfig (note .test suffix, not .tst2):

#include "Generated.xcconfig"
BUNDLE_ID_SUFFIX=.test
PRODUCT_BUNDLE_IDENTIFIER=com.flutter.flutterflavors.test
FLUTTER_TARGET=lib/main_tst.dart
APP_NAME=Flavor TST

Extending configuration

At this point you should copy and paste some build configurations and assign them to the respective scheme. There will be a lot of clicking and typing now so be patient.

Go to project settings in Xcode, select Runner and then Debug in Configurations section. Press Enter to rename it to Debug-dev. Then duplicate it and call it Debug-tst, and another with Debug-prod. Repeat the procedure for Release and Profile configurations. Then assign previously created schemes to respective configurations. You should end up with following layout:

iOS Xcode configurations

This should allow you to build your app with different bundle id per flavor. To make sure you can go to Build Settings of Runner target and look for Product Bundle Identifier position.

iOS product bundle identifiers

There is still one problem to be solved. When building the app Flutter takes into account the product bundle identifier visible in General tab of the target properties. So even with tst flavor you’ll see following output in console:

flutter build ios --release --no-codesign -t lib/main_tst.dart --build-number=1 --build-name=1.0.0 --flavor tst

Take a look at the wrong bundle id for Release-tst scheme.

Fortunately, if we defined a PRODUCT_BUNDLE_IDENTIFIER variable in our tst.xconfig file this will be overwritten during the build so that generating and signing the .test bundle id will be possible.

Archiving

Finally, we should update each build scheme with correct build configuration.

Go to Product > Schemes > Manage Schemes, select dev and click Edit. Now for each of the processes (Run, Test, Profile, Analyze, Archive) change the build configuration to -dev one. Repeat the process for tst and prod schemes.

iOS build configs updated

Signing iOS app with fastlane

In order to sign and provision your app you’ll need Apple developer account and fastlane configured. I recommend creating a separate ‘service’ account for fastlane only with separate certificate. Create 3 application identifiers in Apple Developer portal e.g. com.flutter.flutterflavors, com.flutter.flutterflavors.test, and com.flutter.flutterflavors.dev.

Go to ios folder in your console and initialize fastlane with manual mode (option 4.). In fastlane folder create Matchfile file next to Fastfile and Appfile.

My typical Matchfile looks like this:

# you should store your provisioning profiles and certs in repository
# this repository is encrypted with MATCH_PASSWORD env variable
git_url(ENV["FASTLANE_GIT"])
storage_mode("git")
username(ENV["FASTLANE_USERNAME"])
team_id(ENV["FASTLANE_TEAM"])
# this is useful on CI/CD if you build test and production app 
# flavors with the same steps configuration
app_identifier(ENV["APP_NAME"])
type("development")

After creating application ids and adding the files you should be able to generate provisioning profiles. Execute following commands and type desired bundle id when prompted:

bundle exec fastlane match development
bundle exec fastlane match adhoc
bundle exec fastlane match release

This whole iOS step can be observed in commit 162d2015.

Rebuilding and signing with fastlane

Unfortunately, it is necessary to rebuild iOS app to archive it and sign before deploying to testers or AppStore3. With custom flavors it is necessary to provide provisioning profile match map manually. I couldn’t make the fastlane to detect all profiles automatically. If anyone knows better way to do this, then please share!

My typical Fastfile for QA/test builds looks as follows4:

# update_fastlane

default_platform(:ios)

platform :ios do
  desc "Submit a new build to AppCenter"
  lane :test do
    # add_badge(dark: true)
    register_devices(
        devices_file: "fastlane/devices.txt",
        team_id: ENV["FASTLANE_TEAM"],
        username: ENV["FASTLANE_USERNAME"]
    )
    match(
      type: "adhoc",
      force_for_new_devices: true,
    )
    automatic_code_signing(
      use_automatic_signing: false
    )
    update_project_provisioning(
      profile: ENV["sigh_com.flutter.flutterflavors.test_adhoc_profile-path"],
      build_configuration: "Release-tst",
      code_signing_identity: "iPhone Distribution"
    )
    build_app(
      scheme: "tst",
      configuration: "Release-tst",
      xcargs: "-allowProvisioningUpdates",
      export_options: {
        signingStyle: "manual",
        method: "ad-hoc",
        provisioningProfiles: {
          "com.flutter.flutterflavors.test": "match AdHoc com.flutter.flutterflavors.test",
        }
      },
      output_name: "Runner.ipa"
    )
    # upload to AppCenter or anywhere else
  end

  desc "Deploy a new version to the AppStore"
  lane :prod do

  end
end

To build the app with fastlane you should execute just:

bundle exec fastlane ios test

At this point you may encounter a very nasty error that fastlane tries to build com.flutter.flutterflavors.dev instead of com.flutter.flutterflavors.test:

❌ error: Provisioning profile “match AdHoc com.flutter.flutterflavors.test” has app ID “com.flutter.flutterflavors.test”, which does not match the bundle ID “com.flutter.flutterflavors.dev”. (in target ‘Runner’)

The simplest solution that took me hours to find was just to delete bundle id from General tab in Xcode.

Now you should be able to have you .ipa archive ready to submit to AppCenter, Beta or directly to your testers.

Take a look at the commit a3c5512a to look through all the changes related to fastlane.

Summary

After reading this article you should be able to configure Flutter flavors on your own. There are almost limitless possibilities related to flavors, schemes and configurations. For instance you can have separate Google Services files or Facebook ids for each flavor. You can enable or disable some features for test builds. You can even create multiple apps from single code base.

I hope you learned something with me. See you soon in the next blog post 🖖.


  1. Of course you can define as many flavors as you wish, 3 flavors are a good compromise 

  2. On Android you can’t define test flavor so we named it tst, but we wanted .test suffix to make it more obvious for QA. You can go with test names and files all the way if you prefer it. 

  3. This may be changed in future Flutter versions 

  4. I use several plugins to fastlane like badge or appcenter. I really recommend you to check them out.