An In-Depth Understanding of Flutter Compilation Principles and Optimizations

By Zhengwu, from Xianyu Technology Team

Background

There are always many questions for developers. What is Flutter? What language does it use for programming? What parts are included? How is it compiled and run on the device? How does Flutter achieve quick Hot Reload in Debug mode and native experience in Release mode? What is the difference between the Flutter project and the Android/iOS project? How is their relationship? How is Flutter embedded into Android/iOS? How does Flutter’s rendering and event delivery mechanism work? Does Flutter support hot updates? Is it true that Flutter does not officially provide ARMv7 support for iOS? How can you fix the bug in the engine when using Flutter? How can you locate, modify, and make it take effect if the construction fails?

The answers to all these questions lie in the global observation on the whole process of Flutter, including design, development, construction, and final operation. This article introduces the relevant principles, customization, and optimization of Flutter using the ‘hello_flutter” project as an example.

Introduction to Flutter

The Flutter architecture is mainly divided into three layers: Framework, Engine, and Embedder.

Based on Dart, the Framework includes Material Design-style and Cupertino-style (for iOS) Widgets, text/image/button and other basic Widgets, rendering, animation, gestures, and other components. The core code of this layer lies in the Flutter package in the Flutter repository, and io, async, ui, and other packages in the sky_engine repository. The dart:ui repository provides interfaces between the Flutter framework and the engine.

Based on C++, the Engine mainly includes Skia, Dart, and Text. Skia is an open-source two-dimensional graphics library that provides general APIs for a variety of software and hardware platforms. It has served as a graphics engine for Google Chrome, Chrome OS, Android, Mozilla Firefox, Firefox OS, and many other products. Platforms, including Windows 7+, macOS 10.10.5+, iOS 8+, Android 4.1+, and Ubuntu 14.04+, are also supported. Dart mainly includes Dart Runtime, Garbage Collection (GC), as well as Just In Time (JIT) in Debug mode. In Release and Profile modes, the Ahead Of Time (AOT) compilation compiles code into native ARM code, not JIT compilation. Text refers to text rendering, which acts on libtxt library (for font selection and lines separation) derived from Minikin. HartBuzz is used for glyph selection and molding. Skia, as the rendering/GPU backend, uses FreeType on Android and Fuchsia and CoreGraphics on iOS to render fonts.

The Embedder is an embedding layer that embeds Flutter into various platforms. Its tasks mainly include rendering surface settings, thread settings, and plug-ins. Platforms, such as iOS, only provide a “canvas” with a low platform-related layer of Flutter. All the remaining rendering-related operations occur inside Flutter. Thus, Flutter has excellent cross-terminal consistency.

Flutter Project Structure

This article uses Flutter beta v0.3.1 for introduction and description, with the corresponding engine commit 09d05a389. The Flutter project structure of the hello_flutter project is listed below:

In the image above, ios contains part of iOS code and uses CocoaPods to manage dependencies. The android contains part of Android code and uses Gradle to manage dependencies. The lib contains part of Dart code and uses pub to manage dependencies. Cocoapods in iOS corresponds to Podfile and Podfile.lock while pub corresponds to pubspec.yaml and pubspec.lock.

Flutter Mode

Flutter supports common modes like Debug, Release, and Profile with its own features.

  • Debug Mode: It corresponds to JIT mode of Dart, which is also called check mode or slow mode. This mode supports devices and simulators (iOS/Android) and permits assertion, including all debugging information, service extension, observatory, and other debugging aids. It is optimized for rapid development and operation, but not for execution speed, packet size, and deployment. In Debug mode, the compilation uses JIT technology to support the popular sub-second hot reload.
  • Release Mode: It corresponds to AOT mode of Dart, with the aim of deployment to end users. This mode only supports physical devices, not simulators. All assertions and debugging tools are restricted, and debugging information is removed as much as possible. It is optimized for quick start, quick execution, and packet size. All debugging aids and service extensions are prohibited.
  • Profile Mode: It is similar to Release mode while providing more support for service extensions. It also supports tracking and minimizing the dependencies required to use the tracking information. For example, the Observatory can connect to processes. Profile mode does not support simulators because judgments on simulators do not represent true performance.

Since there is no difference in compilation principles between Profile and Release modes, this article only discusses Debug mode and Release mode.

The iOS/Android project under Flutter is still essentially a standard iOS/Android project. Flutter only generates and embeds App.framework and Flutter.framework (iOS) by adding a shell to the BuildPhase. Flutter also adds flutter.jar and vm/isolate_snapshot_data/instr (Android) through gradle to compile Flutter-related code and embed them into a native App. Therefore, this article mainly discusses the construction and operation principles introduced by Flutter. Although the compilation targets include ARM, x64, x86, and ARM64, they are similar in principles. Thus, only ARM will be discussed here. Unless explicitly stated, Android defaults to ARMv7.

Compilation and Operation of Flutter Code (iOS)

Compilation in Release Mode

The construction procedure of Dart code in iOS projects under Flutter in Release mode is listed below:

In the image above, gen_snapshot is the Dart compiler, applying technologies like Tree Shaking. Tree Shaking is similar to the dependency tree logic and can generate a minimum packet to disable the reflection feature supported by Dart in Flutter. The gen_snapshot is applied to generate machine code in the assembly form and then the final App.framework through compilation toolchains, such as xcrun. In other words, all Dart codes, including business code and third-party packet code, will eventually become App.framework. It is the same for the Flutter framework code that Dart codes require.

Tree Shaking is located in the gen_snapshot. For the corresponding logic, please see engine/src/third_party/dart/runtime/vm/compiler/aot/precompiler.cc. The final corresponding symbols of Dart code in App.framework are listed below:

App.framework contains four parts, kDartVmSnapshotdData, kDartVmSnapshotInstructions, kDartIsolateSnapshotData, and kDartIsolateSnapshotInstructions, just like products in the Android release. Why does iOS use the four parts of App.framework instead of Android's four files? Due to iOS system limitations, the Flutter engine cannot mark a memory page as executable at runtime, but Android can.

Flutter.framework corresponds to the engine part and embedder part of the Flutter architecture. Flutter.framework is located in the /bin/cache/artifacts/engine/ios of the Flutter repository and is pulled from the Google repository by default. When customized modifications are required, users can use the Ninja building system to generate Flutter.framework by downloading the engine source code.

The final products of the Flutter-related code are App.framework (generated by Dart code) and Flutter.framework (engine). From the perspective of the Xcode project, Generated.xcconfig describes the configuration information of the Flutter-related environment. The xcode_backend.sh added in the Build Phases of the Runner project settings implement the copying and embedding of Flutter.framework and the compilation and embedding of App.framework. The Flutter.framework is copied from the engine of the Flutter repository to the Flutter directory under the root directory of the Runner project. The Flutter-related information generated in Runner.app is listed below:

In the image above, flutter_assets is the relevant resource, and the code is App.framework and Flutter.framework under Frameworks.

Operation in Release Mode

The Flutter-related rendering, event, and communication processing logics are shown below:

The call stack of the main function in Dart is shown below:

Compilation in Debug Mode

The compilation and structure of Flutter in Debug mode are similar to what is in Release mode. There are two main differences between these two modes:

1. Flutter.framework

Framework supports JIT in Debug mode but does not support JIT in Release mode.

2. App.framework

In AOT, App.framework is the local machine code corresponding to Dart code. In JIT, App.framework has several simple APIs, and its Dart code is located in the snapshot_blob.bin file. The snapshots in this section are script snapshots and contain simple tokenized source code. All comments and blank characters are removed. In addition, constants are normalized, and there is no machine code, Tree Shaking operation, or code obfuscation. The symbol table in App.framework is listed below:

The following information appears when running the strings command on Runner.app/flutter_assets/snapshot_blob.bin:

The call stack of the main entry in Debug mode is listed below:

Compilation and Operation of Flutter Code (Android)

Apart from some platform-related features, Android and iOS also share something in common. Release mode corresponds to AOT, and Debug mode corresponds to JIT. Only their differences are discussed in this article.

Compilation in Release Mode

In Release mode, the construction procedure of Dart code in an Android project under Flutter is shown below:

In the image above, vm/isolate_snapshot_data/instr is an ARM command, which is loaded during runtime by the engine. The engine marks the vm/isolate_snapshot_instr as executable. Runtime and other services, such as garbage collection, are involved in vm_ and used to initialize Dart VM. For the call entry, see Dart_Initialize(Dart_api.h). The isolate_ corresponds to our app code and is used to create a new isolate. For the call entry, see Dart_CreateIsolate(dart_api.h). Similar to Flutter.framework in iOS, flutter.jar includes the code in the engine, such as libflutter.so in flutter.jar. A set of classes and interfaces that embed Flutter into Android, such as FlutterMain, FlutterView, and FlutterNativeView, are also included. flutter.jar is located in /bin/cache/artifacts/engine/android of the Flutter repository and is pulled from the Google repository by default. When customized modifications are required, users can use the Ninja building system to generate flutter.jar by downloading the engine source code.

Let’s use isolate_snapshot_data/instr as an example. The information after running the disarm command is listed below:

Its APK structure is listed below:

Once it is installed, APK determines whether to copy the assets in APK based on the judgment of a ts. The judgment is made based on a combination of the versionCode in packageinfo and the lastUpdateTime. After the copy is completed, the information is listed below:

The isolate/vm_snapshot_data/instr is located in the local data directory of the app. These writable parts can be downloaded and replaced to complete the entire replacement and update of the app.

Operation in Release Mode

Compilation in Debug Mode

Similar to the differences between Debug mode and Release mode in iOS, their differences in Android mainly include the following two parts:

1. flutter.jar

The difference of flutter.jar in Android is the same as that in iOS.

2. App Code

App code is located in snapshot_blob.bin of flutter_assets. The difference is also the same as that in iOS.

After introducing the Flutter compilation principles in iOS and Android, this article will focus on how to customize Flutter and engines to complete the customization and optimization. Considering the rapid iteration of Flutter, the current problems may be solved in the future. Therefore, this section doesn’t emphasize problems to be solved but illustrates solutions based on different types of problems.

Customization and Optimization Related to Flutter Construction

Except for the components of the three layers mentioned above, Flutter is a very complex system that includes many other components. For example, there are components, such as the Flutter Android Studio (Intellij) plug-in, pub repository management, and others. However, customization and optimization are often related to the toolchain of Flutter. The specific codes are located in the flutter_tools packet in the Flutter repository. So, the following section describes the customization process with examples.

Android

The flutter.jar, libflutter.so (located under flutter.jar), gen_snapshot, flutter.gradle, and flutter (flutter_tools) are involved.

1. Define the target in Android as armeabi

This part of the code is related to the construction, and the logic is located under flutter.gradle. If the app supports ARMv7 or ARM64 through armeabi, the default logic of Flutter needs to be modified:

Due to the features of gradle, this part of the code takes effect immediately after being directly modified.

2. Set Android to use the first launchable-activity by default at startup

This part of the code is related to flutter_tools. The modifications are listed below:

The key here lies in how to make the modification take effect instead of how to modify it. In principle, commands, such as flutter run/build/analyze/test/upgrade, execute the script called flutter (flutter_repo_dir/bin/flutter). Then, commands run flutter_tools.snapshot through the script and Dart. The flutter_tools.snapshot is generated through packages/flutter_tools. The logic is listed below:

The figure above shows that flutter_tools need to be reconstructed. You can generate flutter_repo_dir/bin/cache/flutter_tools.stamp to do so. By doing so, flutter_tools can be generated again. Shielding the if/fi judgment can also reconstruct flutter_tools, and it will be generated each time.

3. Using Flutter in Release Mode in Debug mode with an Android project

If Flutter lag appears during R&D, the reason may lie in the logic or the mode. This problem may occur when Flutter is in Debug mode. In this case, developers can build an APK in Release mode or forcibly change Flutter to the Release mode. The code is listed below:

iOS

Related objects include Flutter.framework, gen_snapshot, xcode_backend.sh, and flutter (flutter_tools).

1. Optimization on recompilation caused by the repeated replacement of Flutter.framework during construction

This part of the code is related to the construction, and the logic is located in xcode_backend.sh. The configuration (Generated.xcconfig configuration) will be referred to every time to find and replace Flutter.framework to ensure Flutter always gets the correct Flutter.framework. This leads to the recompilation of some codes that depend on the Framework. The code modifications are listed below:

2. Using Flutter in Release mode in Debug mode with an iOS project

It can be achieved by modifying FLUTTER_BUILD_MODE in Generated.xcconfig to Release mode and FLUTTER_FRAMEWORK_DIR to the corresponding path.

3. Support for ARMv7

For the original article, please visit this link.

Flutter unofficially supports ARMv7 in iOS. However, since there is no official support, users must modify the relevant logic. The modifications are listed below:

1. The default logic can generate Flutter.framework (ARM64).

2. Modify Flutter to enable flutter_tools to be reconstructed each time and then modify build_aot.dart and mac.dart. Change the iOS-related ARM64 to ARMv7 and then change gen_snapshot to the i386 architecture.

The following command can be executed to generate gen_snapshot under the i386 architecture:

There lies an implicit logic: Architectures of arch of the target gen_snapshot and the final App.framework should be consistent to construct CPU-related predefined macros like _x86_64_/_i386 of gen_snapshot.

x86_64 :arrow_right: x86_64 :arrow_right: ARM64 or i386 :arrow_right: i386 :arrow_right: ARMv7

3. On iPhone 4S, the EXC_BAD_INSTRUCTION(EXC_ARM_UNDEFINED) error can be caused by unsupported SDIV instruction, which is generated by gen_snapshot. The error can be corrected by adding the parameter, --no-use-integer-division (located in build_aot.dart) to gen_snapshot. The logic is shown in the figure below:

4. Based on the Flutter.framework generated by paragraphs #1 and #2, lipo create generates a Flutter.framework that supports ARMv7 and ARM64.

5. Modify Info.plist in Flutter.framework and remove it

Similarly, the App.framework must also be processed this way to prevent it from being affected by App Thining after official operations.

Debugging flutter_tools

You can refer to the following steps when learning about the detailed execution logic of Flutter during APK construction in Debug mode.

  1. Understand the command line parameters of flutter_tools
  1. Open packages/flutter_tools in a Dart project. Modify flutter_tools.dart based on the obtained parameters and set the command line dart app to start debugging.

Engine Customization and Debugging

Let’s imagine that customization and business development are conducted based on Flutter beta v0.3.1. SDK is not upgraded within a certain time to ensure stability. At this time, Flutter has modified a bug in v0.3.1 on master, which is marked as fix_bug_commit. How can you track and manage this situation?

1. Flutter beta v0.3.1 specifies that the corresponding engine commit is 09d05a389 (see flutter/bin/internal/engine.version).

2. Get the engine code

3. The code obtained in step 2 is master code, while the corresponding code repository of the specific commit (09d05a389) is expected. Therefore, it’s necessary to pull a new branch from this commit, custom_beta_v0.3.1.

4. Based on custom_beta_v0.3.1 (commit: 09d05a389), run gclient sync to obtain all the engine codes corresponding to Flutter beta v0.3.1.

5. Use git cherry-pick fix_bug_commit to synchronize the master's changes to custom_beta_v0.3.1. If the changes depend a lot on the latest changes, the compilation may fail.

6. For iOS-related modifications, execute the following code:

By doing so, products of ARM/ARM64 and debug/release/profile for iOS can be generated. The generated products can replace Flutter.framework and gen_snapshot under flutter/bin/cache/artifacts/engine/ios.

If you need to debug the Flutter.framework source code, the command during construction is listed below:

The engine source code can be debugged by replacing Flutter.framework and gen_snapshot in Flutter with generated products.

7. For Android-related modifications, execute the following code:

By doing so, products of ARM and debug/release/profile for Android can be generated. The generated products can replace gen_snapshot and flutter.jar under flutter/bin/cache/artifacts/engine/android.

References

  1. Flutter’s Modes
  2. iOS Builds Supporting ARMv7
  3. Contributing to the Flutter Engine
  4. Flutter System Architecture
  5. Symbolicating Production Crash Stacks
  6. flutter.io

Original Source:

Follow me to keep abreast with the latest technology news, industry insights, and developer trends.