Build Flavors and Environment-specific Configuration

9 min read, 04 August, 2018

When building mobile apps, it's common to work with multiple environments. For example, dev environment is used for local development, test is used for testers to test and production is for production deployment. This is typically done by extracting environment-specific variables from the source code and providing the actual values for different environments at runtime. If you have experience with other programming languages, you may find the practice of using system environment variables is very straightforward. When running Java programs, we can use -D to pass environment variables to a program. These environment variables can be retrieved using System.getProperty in the Java code. No surprises. It just works. We can use the same binary for different environments by passing different variables.

But it's different for Flutter apps. The approach used by Java works because we can control how Java programs are started, either on a local dev machine or a production server. However, we cannot control how a Flutter app is launched on a user's Android phone or iPhone. So we cannot pass any environment variables to the app. We have to include environment variables as part of the app bundles, which means different binaries for different environments.

In this article, I'll show you how to create and use environment-specific configuration in Flutter.

Describe configuration

Before we use configuration, we need to describe it first. In other programming languages, environment-specific configuration is usually described as an opaque Map<String, String> object. You need to know the keys before accessing their values. The problem with using a Map<String, String> is that it is not type-safe. A typo in the key name can cause hard-to-find bugs. We still need to convert String values into different types. In Flutter, we can have a type-safe way to describe configuration. Since the configuration is part of the Flutter app, we can use a class to describe it.

The class Config below is what used in this article as an example. It has three fields env, production and apiKey. An app may have a complicated configuration class with other nested classes.

class Config {
  final String env;
  final bool production;
  final String apiKey;

  Config({this.env, this.production, this.apiKey});
}

Using a configuration class has three benefits.

  • We can use the actual types like bool, int and double.
  • IDEs can provide auto-complete support when using the configuration object.
  • We have a central place to add configuration-related logic. For example, if a configuration key has specific logic to determine the default value. We can put the code into the configuration class.

Use JSON files

It's not a good idea to put configuration values into the code directly. To make updating easier, it's better to put those values in a plain text file, like a JSON file. For example, we can have two JSON files dev.json and prod.json for dev and prod environments, respectively.

Below is the content of file lib/env/dev.json.

{
  "env": "DEV",
  "production": false,
  "apiKey": "<DEV_KEY>"
}

Below is the content of file lib/env/prod.json.

{
  "env": "PROD",
  "production": true,
  "apiKey": "<PROD_KEY>"
}

Here I put all environment-related files into the directory lib/env.

To use these JSON files, we can use the annotation JsonLiteral from the library json_serializable to convert them into Map<String, dynamic> objects. Please check out the article Work with JSON in Flutter - Part 2: json_serializable to see more details about json_serializable.

Below is the file lib/env/dev.dart. The variable config references to the JSON object from dev.json.

import 'package:json_annotation/json_annotation.dart';

part 'dev.g.dart';

('dev.json', asConst: true)
Map<String, dynamic> get config => _$configJsonLiteral;

We have the similar file lib/env/prod.dart to read the content of file prod.json.

import 'package:json_annotation/json_annotation.dart';

part 'prod.g.dart';

('prod.json', asConst: true)
Map<String, dynamic> get config => _$configJsonLiteral;

Now we need to update the class Config to add the annotation JsonSerializable. Setting createToJson to false means we don't need to generate the method toJson, because we never convert Config objects to JSON.

import 'package:json_annotation/json_annotation.dart';

part 'config.g.dart';

(createToJson: false)
class Config {
  final String env;
  final bool production;
  final String apiKey;

  Config({this.env, this.production, this.apiKey});

  factory Config.fromJson(Map<String, dynamic> json) => _$ConfigFromJson(json);
}

After running the command flutter packages pub run build_runner build --delete-conflicting-outputs, we should see the generated .g.part files.

Now we can easily get different Config objects for dev and prod environments by importing either dev.dart or prod.dart and using Config.fromJson to do the conversion.

Read configuration in widgets

After we can get the Config objects to use, we need a way for widgets to access the Config object. You may have used the method Theme.of to get the ThemeData object of the current theme. This works because of the class InheritedWidget which can propagate information down the widgets tree. We can use the same way for Config objects.

The private class _InheritedConfig below inherits from InheritedWidget. It has a Config object and a child widget. The method updateShouldNotify determines whether framework should notify widgets that inherit from this widget to re-render. Here we notify when the Config object is changed. Since we currently don't have a way to change the Config in the runtime, the method updateShouldNotify actually always returns false.

class _InheritedConfig extends InheritedWidget {
  const _InheritedConfig(
      {Key key,  this.config,  Widget child})
      : assert(config != null),
        assert(child != null),
        super(key: key, child: child);
  final Config config;

  
  bool updateShouldNotify(_InheritedConfig oldWidget) =>
      config != oldWidget.config;
}

The class ConfigWrapper below is the public class to use. ConfigWrapper is a stateless widget that takes a Config object and a child widget. In its method build, it creates a new _InheritedConfig widget. The static method of uses BuildContext.inheritFromWidgetOfExactType to get the nearest widget of the given type _InheritedConfig and return the Config object associated with the _InheritedConfig widget.

class ConfigWrapper extends StatelessWidget {
  ConfigWrapper({Key key, this.config, this.child});

  
  Widget build(BuildContext context) {
    return new _InheritedConfig(config: this.config, child: this.child);
  }

  static Config of(BuildContext context) {
    final _InheritedConfig inheritedConfig =
        context.inheritFromWidgetOfExactType(_InheritedConfig);
    return inheritedConfig.config;
  }

  final Config config;
  final Widget child;
}

You may wonder why we need a private class _InheritedConfig. It's true that the current _InheritedConfig doesn't have any special logic inside of it, and it can be merged into ConfigWrapper. However, having a private class as the implementation details of ConfigWrapper makes it easy for us to encapsulate the details of how a Config object is obtained. We can put logic like creating a default Config object when it's null, or customising Config object, in this private class.

Now we can use ConfigWrapper.of(context) to get the Config object in other widgets. In the code below, we use ConfigWrapper.of(context) to get the Config object in widget MyApp and MyHomePage.

import 'package:flutter/material.dart';
import 'package:flutter_build_env/config.dart';

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    var config = ConfigWrapper.of(context);    return new MaterialApp(
      title: 'Flutter Build Env',
      theme: new ThemeData(
        primarySwatch: config.production ? Colors.green : Colors.yellow,
      ),
      home: new MyHomePage(title: 'Flutter Build Env - ${config.env}'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  
  Widget build(BuildContext context) {
    var apiKey = ConfigWrapper.of(context).apiKey;    return new Scaffold(
        appBar: new AppBar(
          title: new Text(this.title),
        ),
        body: new Center(
            child: new Text('API Key : $apiKey')));
  }
}

Build flavors

To create different bundles for different environments, we need to have different Dart entrypoint files.

The code below is the main_dev.dart file for dev environment. It imports the file package:flutter_build_env/env/dev.dart to use the variable config.

import 'package:flutter/material.dart';
import 'package:flutter_build_env/config.dart';
import 'package:flutter_build_env/env/dev.dart';
import 'package:flutter_build_env/main.dart';

void main() => runApp(
    new ConfigWrapper(config: Config.fromJson(config), child: new MyApp()));

The file main_prod.dart is almost the same as main_dev.dart with a different import file package:flutter_build_env/env/prod.dart.

import 'package:flutter/material.dart';
import 'package:flutter_build_env/config.dart';
import 'package:flutter_build_env/env/prod.dart';
import 'package:flutter_build_env/main.dart';

void main() => runApp(
    new ConfigWrapper(config: Config.fromJson(config), child: new MyApp()));

Run on Android Studio

In Android Studio, we can add Run/Debug Configurations for different environments. We only need to use different Dart entrypoint files, see the screenshot below.

Android Studio Run/Debug Configurations

When run these two configurations, we can see different UIs. prod environment has a green color, while dev environment has a yellow color.

Build APKs

When building APKs, we can use -t to specific different entrypoint files.

$ flutter build apk -t lib/main_dev.dart

A simplified approach

The approach described in this article may be a little bit complicated for simple projects. To simplify the implementation, you can skip the JSON files and create Dart files like dev.dart and prod.dart directly. You can also merge ConfigWrapper and _InheritedConfig into one class by keeping only ConfigWrapper.

Summary

This article shows how to use a type-safe way to describe your app's configuration and read the configuration from JSON files. The class ConfigWrapper provides a generic way to pass configuration objects down to other widgets. By providing different entrypoint files for different environments, we can run the app in Android Studio for development and tests. We can also create bundles for different environments.

Source code

Source code of this article is available on GitHub.