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.
You can also read the tutorial without large gifs on my blog.
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:
- Creating flavors of a Flutter app (Flutter & Android setup) by Natalie Masse Hooper,
- Flavoring Flutter by Salvatore Giordano,
- Flutter Ready to Go (flavors, connectivity and more) by Julio Henrique Bitencourt.
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:
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.
{
"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
, andcom.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
becomes1.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.
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.
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 .tst
2):
#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:
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.
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:
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.
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
:
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 🖖.
-
Of course you can define as many flavors as you wish, 3 flavors are a good compromise ↩
-
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. ↩
-
This may be changed in future Flutter versions ↩
-
I use several plugins to fastlane like badge or appcenter. I really recommend you to check them out. ↩
Top comments (2)
Very nice article, good job! :)
Cool, this is what I need!