Get the best out of Flutter flavors with flutter_flavorizr

Get the best out of Flutter flavors with flutter_flavorizr

Introduction

Flavors are a way of creating different environments for our application. It allows us to specify the app configuration for each of our cases. For example, we want to create a development flavor that is going to use different API endpoints than the production flavor which will be available to customers. Flutter makes it easy, we will see how, but there is currently no out-of-the-box solution inside Flutter itself. Thanks to the awesome Flutter community, there are some pretty good packages on pub.dev to make our life easier. In this post, we will see how to set flavors to a Flutter application. Then we are going to see how we can get the best out of these flavors. We will see how it simplifies the CI/CD and how we can configure VsCode to run each flavor from the interface.

Demo App

To demonstrate the power of flavors, we are going to create a small, useless app that is only going to display its boring information (app name, package name) using package_info_plus and a GIF, which will be different for each flavor.

You can find the repo on github

The code of the main.dart file looks like this:

import 'dart:async';

import 'package:flavors_demo/flavors.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flavors Demo',
      theme: ThemeData.dark(),
      home: const MyHomePage(title: 'Flavors Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  PackageInfo _packageInfo = PackageInfo(
    appName: 'Unknown',
    packageName: 'Unknown',
    version: 'Unknown',
    buildNumber: 'Unknown',
  );

  @override
  void initState() {
    super.initState();
    _initPackageInfo();
  }

  Future<void> _initPackageInfo() async {
    final info = await PackageInfo.fromPlatform();
    setState(() {
      _packageInfo = info;
    });
  }

  Widget _infoTile(String title, String subtitle) {
    return ListTile(
      title: Text(title),
      subtitle: Text(subtitle.isEmpty ? 'Not set' : subtitle),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: ListView(
        children: <Widget>[
          _infoTile('App name', _packageInfo.appName),
          _infoTile('Package name', _packageInfo.packageName),
          Image.network(
            "https://media.giphy.com/media/s9ijJ0AI4JKko/giphy.gif",
          ),
        ],
      ),
    );
  }
}

Nothing much is happening here, just displaying some app info and an Image from a classic french TV show. The result look like this:

Capture d’écran 2022-02-19 à 08.25.45.png

Now we are going to add 3 flavors to our app. One for development, another one for staging, and finally, one for production.

Setting up flavors

Configuring flavors is mostly done by setting up some config files in the Android and iOS folder. It's from my point of view, not really the most exciting part of creating an app, but thanks to the community, some have created tools to make this part easier. In this article, we are going to focus on flutter_flavorizr.

At the time I am writing this article, the latest version (2.1.2) isn't compatible with the integration_test package. So if you want to use this package and still write integration tests, you should add the following line to your pubspec.yaml file flutter_flavorizr: <2.1.2.

Once installed, we can add the following lines to our pubspec.yaml file.

flavorizr:
  ide: "vscode"
  app:
    android:
      flavorDimensions: "flavor-type"
    ios:

  flavors:
    development:
      app:
        name: "Development Flavor"

      android:
        applicationId: "com.dev.flavorsdemo"

      ios:
        bundleId: "com.dev.flavorsdemo"
        buildSettings:
          # Development Team is visible in the apple developer portal 
          DEVELOPMENT_TEAM: YOURDEVTEAMID 
          PROVISIONING_PROFILE_SPECIFIER: "Dev-ProvisioningProfile"

    staging:
      app:
        name: "Staging App"

      android:
        applicationId: "com.staging.flavorsdemo"
      ios:
        bundleId: "com.staging.flavorsdemo"
        buildSettings:
          DEVELOPMENT_TEAM: YOURSTAGINGTEAMID
          PROVISIONING_PROFILE_SPECIFIER: "Staging-ProvisioningProfile"

    production:
      app:
        name: "Production App"

      android:
        applicationId: "com.production.flavorsdemo"
      ios:
        bundleId: "com.production.flavorsdemo"
        buildSettings:
          DEVELOPMENT_TEAM: YOURPRODUCTIONTEAMID
          PROVISIONING_PROFILE_SPECIFIER: "Production-ProvisioningProfile"

What's happening ?

The first config we see is the flavorDimensions for Android. This is to create complex flavors that can combine with each other. Note that the value flavor-type is the default, and this is what we want in most cases. If you want to read about this topic, here is the documentation

Next we can see our array of flavors. In our case we have defined 3 flavors, development, staging and production. Each of these flavors has a different app name, a different applicationId for Android and different bundleId/buildSettings for iOS.

Other config

There are more configurations we can add. First, if you have already configured app icons with flutter_launcher_icons, you can add generateDummyAssets to false. This will prevent flutter_flavorizr to create dummy assets for each of your flavors.

If you want to set a different icon for each of your flavors, you can do that with flutter_launcher_icons. flutter_flavorizr also has this feature, just add the icon field and set it to the path of the desired icon. For example:

  flavors:
    development:
      app:
        name: "Development Flavor"

      android:
        applicationId: "com.dev.flavorsdemo"
        icon: "assets/path-to-your-icon"


      ios:
        bundleId: "com.dev.flavorsdemo"
        buildSettings:
          # Development Team is visible in the apple developer portal 
          DEVELOPMENT_TEAM: YOURDEVTEAMID 
          PROVISIONING_PROFILE_SPECIFIER: "Dev-ProvisioningProfile"
        icon: "assets/path-to-your-icon"

You can also add a configuration related to Firebase. Check the documentation for more info about that

There is an IDE field to configure your IDE. But we will see that later.

Create flavors

Now that we have our flavors set up, we can create them! For that run the command flutter pub run flutter_flavorizr. This will create all the configuration files for Android and iOS. It will also generate dart files. Let's see what they are used for

flavors.dart

You can see this file like a .env file, but with more power because it's a dart file. It's pretty simple, you have an enum named Flavor and this enum has 3 possible values (each for one of your defined flavors). Now, to fetch a specific value for one flavor, we create a static method. Here is the generated default file:

enum Flavor {
  DEVELOPMENT,
  STAGING,
  PRODUCTION,
}

class F {
  static Flavor? appFlavor;

  static String get name => appFlavor?.name ?? '';

  static String get title {
    switch (appFlavor) {
      case Flavor.DEVELOPMENT:
        return 'Development Flavor';
      case Flavor.STAGING:
        return 'Staging App';
      case Flavor.PRODUCTION:
        return 'Production App';
      default:
        return 'title';
    }
  }

}

To define an env variable, we add something like this in our flavors file:

  static String get memeUrl {
    switch (appFlavor) {
      case Flavor.DEVELOPMENT:
        return "https://media.giphy.com/media/s9ijJ0AI4JKko/giphy.gif";
      case Flavor.STAGING:
        return "https://media.giphy.com/media/XknChYwfPnp04/giphy.gif";
      case Flavor.PRODUCTION:
        return "https://media.giphy.com/media/zrCSvFfl2fP7W/giphy.gif";
      default:
        throw Exception("Unknown flavor for memeUri");
    }
  }

We just define an env variable. Now we can use it in our app like this:

Image.network(F.memeUrl)

You might be asking yourself "but how is my app going to know what flavor to use?". This is our next step.

main-flavor.dart

In order to know which flavor to use, we need a new entry point for each of our flavors. It might be weird because we are used to only one app entry point, main.dart. Now we have three of them. In each of these new entry points, we are setting the static field appFlavor of the F class to the desired flavor.

import 'package:flutter/material.dart';
import 'app.dart';
import 'flavors.dart';

void main() {
  F.appFlavor = Flavor.DEVELOPMENT;
  runApp(MyApp());
}

For example, here in main-development.dart we are settingFto Flavor.DEVELOPMENT. This is what tells the app, "hey, please use the integration flavor this time."

Finally, we are calling the runApp function with a MyApp object instance. Note that by default it creates an App widget. This isn't the entry point widget we want. Indeed, this is a dummy widget, created byflutter_flavorizr. We need to replace this.

app.dart

This is the place where we place the root widget. We are just going to replace everything inside that file with the content of the main.dart file.

There is also a home.dart file that is generated. You can delete it safely. You can also delete the main.dart file which is no longer useful.

result

Now we can run our 3 different flavors, the result look like this:

development flavor

Capture d’écran 2022-02-19 à 08.25.45.png

staging flavor

Capture d’écran 2022-02-19 à 08.23.57.png

production flavor

Capture d’écran 2022-02-19 à 08.24.50.png

Flavor update

The next time you want to update your flavors, you don't want to redo everything. For this, you can run a specific processor. A processor is a specific task done byflutter_flavorizr. For example, maybe you don't want to override your dart file or recreate your app icon. For this, use the following syntax flutter pub run flutter_flavorizr -p <processor>. For example

flutter pub run flutter_flavorizr -p android:buildGradle,android:androidManifest,ios:xcconfig,ios:buildTargets,ios:schema,ios:plist

If you want to update only Android and iOS without the icon generation.

YOU MUST AVOID ANY SPACE BETWEEN EACH PROCESSOR BECAUSE IT WILL NOT WORK.

You do -p <processor1>,<processor2> but don't -p <processor1>, <processor2>

The full list of processors can be found here

Configure VsCode

Have you noticed this line in our flavors' config ?

ide: "vscode"

This tells flutter_flavorizr to configure your IDE, in our case VsCode. You can also set the value to idea if you use Android Studio.

Now in the debug panel of VsCode, you can see this

Capture d’écran 2022-02-19 à 07.26.50.png

With this you can run each of your flavors in every flutter mode (debug, profile, release).

This is really neat, let's see how it works.

The classic way to run a Flutter app is flutter run. However this command assumes that you have a file named main.dart as the app entry point and no flavor configuration. To run one of our flavors, we need to do flutter run --flavor <flavor> -t lib/main-<flavor>.dart. So, to run or staging flavor flutter run --flavor -lib/main-staging.dart.

The --flavor argument specifies which flavor build config it should use. The -t argument specifies the entry point file.

Now, to run a flutter in a specific flutter mode use the flag --<flutter-mode>. So to run our production flavor in profile mode we do flutter run --profile --flavor production -t lib/main-production.dart

The debug mode is the default mode

All this stuff is already done inside the .vscode/launch.json (created by flutter_flavorizr).

Now, if we have an issue with the staging app, we can just run our app with the staging flavor in debug mode from VsCode!

CI/CD

With these well-configured flavors, the CI/CD is also simplified. To demonstrate how we can make our CI/CD job easier, let's create a Jenkinsfile.

The goal of this article isn't to explain how Jenkins works, so I will not speak in deep detail about how it works

We will accomplish the following things:

  • run a flutter doctor command
  • build a .ipa file for iOS
  • build an app bundle for Android
  • create artifacts from these build files

Here is the Jenkinsfile:

#!/usr/bin/groovy
def appName = "Flavor Demo"

node('someNode') {
    withEnv(["PATH=path-to-a-flutter-bin:$PATH"]) {
        def flavorName = env.BRANCH_NAME;

        if (flavorName == 'development' || flavorName == 'recette' || flavorName == 'production') {
            def appNameAndroid = appName + "-${flavorName}-" + env.BUILD_NUMBER + ".aab";
            def appNameiOS = appName + "-${flavorName}-" + env.BUILD_NUMBER + ".ipa";
            def exportPlistFile = "./ios/exportOptions.${flavorName}.plist"

            stage('flutter doctor') {
                sh "flutter doctor"
            }

            stage("Build AAB Flutter Android for flavor ${flavorName}") {
                sh "mkdir -p build_ci"
                sh """
                    flutter build appbundle --release --flavor ${flavorName} -t lib/main-${flavorName}.dart
                    cp -pr build/app/outputs/bundle/${flavorName}Release/app-${flavorName}-release.aab build_ci/flutter_app.aab
                """
            }

            stage("Build IPA Flutter iOS for flavor ${flavorName}") {
                sh "mkdir -p build_ci"
                sh """
                    export LANG=en_US.UTF-8
                    flutter build ipa --release --flavor ${flavorName} -t lib/main-${flavorName}.dart
                    xcodebuild -exportArchive -archivePath ./build/ios/archive/Runner.xcarchive -exportOptionsPlist ${exportPlistFile} -exportPath flutter_app_ios
                    mv flutter_app_ios/*.ipa ./build_ci/flutter_app.ipa
                """
            }

            stage('Creating Artifacts') {
                dir("build_ci") {
                    sh "mv flutter_app.aab ${appNameAndroid}"
                    sh "mv flutter_app.ipa ${appNameiOS}"

                    archiveArtifacts allowEmptyArchive: true, artifacts: appNameAndroid
                    archiveArtifacts allowEmptyArchive: true, artifacts: appNameiOS
                }
            }
        }
    }
}

You need to create one export.plist file for each flavor.

As you can see, we are doing the exact same thing, just with different parameters.

In this case, we are assuming that each flavors have a specific git branch. This is really useful because we know what flavor we want to process based on the branch name.

We just have to define 4 variables.

Just like with the flutter run command, we are using the -t and the --flavor to build a specific flavor.

Conclusion

What did we do?

We have created 3 different flavors for our app, configured our IDE to take full advantage of our flavors and finally, we demonstrated how we can make our CI/CD easier.

It was not that hard. In real projects, I usually get some issues with the iOS configuration. You might spend some time debugging the configuration in Xcode. It's still better than creating it from scratch.

flutter_flavorizr is currently only working on Android and iOS. Hopefully it will handle desktop as well in the future.

You can find the demo app on github