34 How to Understand Flutter's Compilation Mode

34 How to Understand Flutter’s Compilation Mode #

Hello, I’m Chen Hang. Today, let’s talk about the compilation modes of Flutter.

When developing a mobile application, the complete lifecycle of an app includes three stages: development, testing, and release. In each stage, developers have different focuses.

For example, in the development stage, we want debugging to be as convenient and fast as possible, and we want to provide as much error context information as possible. In the testing stage, we want to cover a wide range of scenarios, have the ability to switch between different configurations, and be able to test and validate new features that have not yet been released. In the release stage, we want to remove all testing code, streamline debugging information, make the app run as fast as possible, and ensure the code is secure.

This requires developers to not only prepare multiple configuration environments within the project, but also utilize the compilation options provided by the compiler to package an app that meets the optimization requirements of different stages.

For Flutter, it supports common compilation modes at the project physical level, such as Debug and Release, and also supports providing multiple configuration environment entry points within the project. Today, we will learn about the compilation modes provided by Flutter and how to reference the development and production environments in our app, so that we can test new features during the development phase without breaking any production code.

Compilation Modes in Flutter #

Flutter supports 3 running modes: Debug, Release, and Profile. These modes are completely independent during compilation. First, let’s take a look at what these modes mean.

  • Debug mode corresponds to Dart’s JIT mode and can be run on both real devices and emulators. This mode enables all assertions, as well as all debug information, service extensions, and debugging aids such as the Observatory. Additionally, this mode is optimized for fast development and running, supporting sub-second hot reload for stateful changes, but it does not optimize code execution speed, binary package size, or deployment. The flutter run --debug command runs the application in this mode.
  • Release mode corresponds to Dart’s AOT mode and can only be run on real devices. It cannot be run on emulators. The compilation target of this mode is the final online release for end users. This mode disables all assertions, as well as as much debug information, service extensions, and debugging aids as possible. Additionally, this mode optimizes fast application startup, code execution speed, and binary package size, resulting in longer compilation times. The flutter run --release command runs the application in this mode.
  • Profile mode is essentially the same as Release mode, except that it supports additional service extensions for profiling, as well as some dependencies required for minimal support (such as the ability to connect the Observatory to the process). This mode is used to analyze the actual performance of the application on real devices. The flutter run --profile command runs the application in this mode.

Since there is little difference in the compilation process between Profile and Release modes, today we will only discuss Debug and Release modes.

When developing an application, in order to quickly identify problems, we often identify the current compilation mode at runtime to change the behavior of certain code execution. In Debug mode, we may print detailed logs and call development environment interfaces, while in Release mode, we may only log minimal information and call production environment interfaces.

There are two ways to identify the compilation mode of the application at runtime:

  • By using assertions
  • By using compile-time constants provided by the Dart virtual machine (VM)

Let’s first look at how to identify the compilation mode through assertions.

Based on the introduction of Debug and Release modes, an important difference between Release and Debug modes is that Release mode disables all assertions. Therefore, we can use assertions to write code that only takes effect in Debug mode.

As shown below, we pass an anonymous function that always returns true into the assertion. The code in the body of this anonymous function will only run in Debug mode:

assert(() {
  //Do something for debug
  return true;
}());

Note that we append parentheses () to the end of the anonymous function declaration and call. This is because assertions can only check boolean values, so we must use parentheses to enforce the execution of this anonymous function that always returns true, ensuring that the code in the body of the anonymous function can be executed.

Next, let’s look at how to identify the compilation mode of the application using compile-time constants.

If we can only write code that runs in Debug mode through assertions, using Dart’s compile-time constants, we can also write code that only takes effect in Release mode. Dart provides a boolean constant kReleaseMode that indicates the current compilation mode of the app.

As shown below, we can accurately identify the current compilation mode by checking this constant:

if(kReleaseMode) {
  //Do something for release
} else {
  //Do something for debug
}

Separate Configuration Environments #

By using assertions and the kReleaseMode constant, we can identify the current compilation environment of the app, allowing us to make minor adjustments to specific code functionality at runtime. However, if we want to provide a more uniform configuration for different runtime environments at the application level (for example, using dev.example.com domain for API calls in the development environment and api.example.com domain in the production environment), we need to provide a configurable initialization method at the application’s entry point to inject the configuration environment based on specific requirements.

When building a Flutter app, providing different configuration environments for the application can be divided into four steps: abstract configuration, multiple entry points, configuration reading, and compilation and packaging options:

  1. Abstract the configurable parts of the application and encapsulate them using an InheritedWidget.
  2. Break down the different configuration environments into multiple application entry points (e.g., main-dev.dart for the development environment and main.dart for the production environment) and solidify the configurable parts at each entry point.
  3. At runtime, apply the configuration parts to the corresponding functionality of the child Widget using the data sharing mechanism provided by InheritedWidget.
  4. Build installation packages for different configuration environments using the compilation and packaging options provided by Flutter.

Next, I will explain the specific implementation steps to you one by one.

In the following example, I will differentiate the API calls and titles of the application. In the development environment, we will use the dev.example.com domain and set the home page title as “dev”. In the production environment, we will use the api.example.com domain and set the home page title as “example”.

First, let’s look at configurable abstraction. According to the requirements, the application has two configurable parts: apiBaseUrl for the API endpoint and appName for the page title. Therefore, I defined a class AppConfig that inherits from InheritedWidget to encapsulate these two configurations:

class AppConfig extends InheritedWidget {
  AppConfig({
    @required this.appName,
    @required this.apiBaseUrl,
    @required Widget child,
  }) : super(child: child);

  final String appName; // home page title
  final String apiBaseUrl; // API domain

  // Allows its child widgets to find it in the widget tree.
  static AppConfig of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<AppConfig>();
  }

  // Determines whether the child widgets should update. Since this is the entry point, there is no need for updates.
  @override
  bool updateShouldNotify(InheritedWidget oldWidget) => false;
}

Next, we need to create different application entry points for different environments.

In this example, since there are only two environments, the development and production environments, we will name the files as main-dev.dart and main.dart respectively. In these two files, we will use different configuration data to initialize AppConfig and set the application instance MyApp as its child widget, so that the whole application can access the configuration data:

// main-dev.dart
void main() {
  var configuredApp = AppConfig(
    appName: 'dev', // home page title
    apiBaseUrl: 'http://dev.example.com/', // API domain
    child: MyApp(),
  );
  runApp(configuredApp); // start the application
}

// main.dart
void main() {
  var configuredApp = AppConfig(
    appName: 'example', // home page title
    apiBaseUrl: 'http://api.example.com/', // API domain
    child: MyApp(),
  );
  runApp(configuredApp); // start the application
}

After injecting the configuration environment, we can now retrieve the configuration data within the application to customize the functionalities. Since AppConfig is the root node of the entire application, we can use the AppConfig.of method to retrieve the relevant configuration data.

In the following code, I retrieved the home page title and API domain from the application configuration and displayed them:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var config = AppConfig.of(context); // retrieve the application configuration
    return MaterialApp(
      title: config.appName, // home page title
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var config = AppConfig.of(context); // retrieve the application configuration
    return Scaffold(
      appBar: AppBar(
        title: Text(config.appName), // home page title
      ),
      body: Center(
        child: Text('API host: ${config.apiBaseUrl}'), // API domain
      ),
    );
  }
}

Now, we have completed the code part for separating the configuration environment. Finally, we can use the compilation options provided by Flutter to build installation packages for different configurations.

If you want to run this code on a simulator or real device, you can append the –target or -t parameter after the flutter run command to specify the initialization entry of the application:

  • Run the development environment application:
flutter run -t lib/main_dev.dart
  • Run the production environment application:
flutter run -t lib/main.dart

If you want to create APKs for Android or IPA installation packages for iOS, you can also append the –target or -t parameter after the flutter build command to specify the initialization entry of the application:

  • Build the development environment application:
flutter build apk -t lib/main_dev.dart
flutter build ios -t lib/main_dev.dart
  • Build the production environment application:
flutter build apk -t lib/main.dart
flutter build ios -t lib/main.dart

If you want to create different launch configurations for the application in Android Studio, you can add an entry point for main_dev.dart through the Flutter plugin.

First, click on the Config Selector in the toolbar and select Edit Configurations to edit the application’s launch options:

Image 1: Adding entries in Config Selector

Then, click the “+” button at the top left of the toolbar panel and choose the Flutter option from the popup menu to add a new launch entry for the application:

Image 2: Selecting the new configuration type

Finally, in the entry’s edit panel, choose the Dart entry for main_dev, click OK, and you will have added the new entry:

Image 3: Editing the launch entry

Now, you can switch between different launch entries in Config Selector and directly inject different configuration environments in Android Studio:

Image 4: Switching launch entries in Config Selector

Try switching and running different entries. You will see that the app can recognize the different configuration environments:

Image 5: Running in the development environment

Image 6: Running in the production environment

If you want to build APKs for Android or IPA installation packages for iOS, you can also append the –target or -t parameter after the flutter build command to specify the initialization entry of the application:

  • Build the development environment application:
flutter build apk -t lib/main_dev.dart
flutter build ios -t lib/main_dev.dart
  • Build the production environment application:
flutter build apk -t lib/main.dart
flutter build ios -t lib/main.dart

Summary #

Alright, let’s summarize today’s main content.

Flutter supports both Debug and Release compilation modes, and these two modes are completely independent during the build process. In Debug mode, all assertions and debugging information are enabled, while in Release mode, these information is disabled. Therefore, we can write code that only takes effect in Debug mode using assertions. If we want to accurately identify the current compilation mode, we can use the compile constant kReleaseMode provided by Dart to write code that only takes effect in Release mode.

In addition, Flutter also supports common configuration capability for different environments. We can use InheritedWidget to encapsulate and abstract the configurable parts of the application, and inject configuration environments through multiple entry points for application startup.

It is worth noting that although assertions and kReleaseMode can both identify the Debug compilation mode, they have different impacts on the binary package build.

When using assertions, the related code will be completely removed from the Release build. However, if we use the kReleaseMode constant to identify the Debug environment, although this code will never be executed in the Release environment, it will be included in the binary package, increasing its size. Therefore, unless there are special requirements, we must use assertions to implement Debug-specific logic, or completely remove Debug logic that is judged using kReleaseMode before the release.

I have packaged the knowledge points covered in today’s sharing on GitHub. You can download it, run it repeatedly, and deepen your understanding and memory.

Thought-Provoking Question #

Finally, I’ll leave you with a thought-provoking question.

Assuming we want to support switching between different configurations in the development environment while keeping the production environment code unchanged, how should we implement it?

Feel free to leave a comment in the comment section to share your thoughts. I’ll be waiting for you in the next article! Thank you for listening, and feel free to share this article with more friends to read together.