Alibaba Tech

@alitech_2017

Making the Most of Flutter: From Basics to Customization

Open-source SDK Flutter offers developers a way to readily deploy custom user interfaces for Android and iOS apps — once you know how to optimize its features.

This article is part of Alibaba’s Utilizing Flutter series.

For developers, software development kits like Google’s Flutter offer a means of building native Android and iOS mobile interfaces while avoiding many of the associated burdens, including sheer time demand. Still, some may wonder how Flutter projects and purely Android or iOS projects differ, for instance in their rendering or event delivery mechanisms, or in how they enable developers to modify errors and implement a project when construction stalls.

To help address these concerns and shed light on its workings, Alibaba looks today at Flutter from a global perspective, using a “hello_flutter” example project. The following sections will first introduce Flutter’s principles before then discussing customization and optimization capabilities, with specific steps that developers interested in exploring Flutter can follow.

Flutter’s Fundamentals

Architecture

Flutter’s architecture as a program is founded on three distinct layers: its framework, its engine, and its embedder.

Overview of Flutter’s architecture

Flutter’s framework is implemented in Dart, and encompasses material design–style widgets, Cupertino-style widgets (for iOS), text/image/button-based widgets, rendering, animation, gestures, and so on. The core code for this layer encompasses flutter packages under the flutter repository and packages under the sky_engine repository (the dart:ui library provides the interface between the flutter framework and engine) such as io, async, and ui packages.

Flutter’s engine is implemented in C++, and includes Skia, Dart, and Text. Skia is an open-source 2D graphics library that provides a common API for a variety of hardware and software platforms. It has been used as a graphics engine for Google Chrome, Chrome OS, Android, Mozilla Firefox, Firefox OS, and many other products. Supported platforms include Windows7+, macOS 10.10.5+, iOS8+, Android4.1+, Ubuntu14.04+, and so on.

The engine’s Dart section mainly encompasses Dart Runtime and Garbage Collection (GC). If Flutter is running is in debug mode, JIT (Just in Time) support is also included, while in release and profile modes, dart code is compiled into the native “arm” code by AOT (Ahead of Time), such that no JIT exists. Text refers to text rendering with the following rendering levels: the libtxt library (for font selection and separating lines), derived from minikin and HartBuzz (used for glyph selection and shaping). Skia acts as a render backend, using FreeType rendering on Android and Fuchsia and CoreGraphics rendering on iOS.
 
 The embedder is an embedded layer that embeds Flutter in various platforms. The main tasks here are rendering Surface settings, thread settings, and plug-ins. We can see that the platform-related layer of Flutter is minimal, where the platform (such as iOS) simply provides a canvas and the rest of the rendering-related logic occurs inside Flutter, making for good cross-platform consistency.

Engineering structure

The development environment used in this article is Flutter beta v0.3.1, corresponding to the engine commit : 09d05a389.

For our simple “hello_flutter” project example, Flutter’s engineering structure is as follows:

In the above example, “ios” is the code for iOS, using CocoaPods to manage dependencies, “android” is the code for Android, using Gradle to manage dependencies, and “lib” is the dart code, using pub to manage dependencies. Similar to the Podfile and Podfile.lock for Cocoapods in iOS, the corresponding ones in pub are pubspec.yaml and pubspec.lock.

Modes

Flutter supports common modes including debug, release, and profile, but does so with some notable distinctions.

Flutter’s debug mode corresponds to Dart’s JIT mode, also known as the check mode or slow mode, and supports devices and emulators for iOS and Android. Under this mode, the assertion functions, including all debugging information, service extensions, and debugging aids such as “observatory,” are enabled. This mode is optimized for rapid development and operation, but not for execution speed, package size, or deployment. Under this mode, compilation is based on JIT technology, supporting the popular sub-second stateful hot reload.

Flutter’s release mode corresponds to Dart’s AOT mode, which is targeted for deployment to end users and supports real machines but not emulators. Under this mode, all assertions are disabled, and in order to remove as much debugging information as possible, all debugging tools are also disabled. This mode is optimized for quick launch, speedy execution, and packet size, while all debugging aids and service extensions are prohibited in it.

Flutter’s profile mode is similar to its release mode in that this mode only adds supports for service extension and tracking, and minimizes the dependencies needed to use tracking information. For example, “observatory” can be connected to processes. The reason that the profile mode does not support the emulator is that diagnoses on the emulator do not represent real performance.

Since Flutter’s profile mode and release mode have no differences in terms of compilation principles, this article only discusses its debug mode and release mode.

In fact, an iOS or an Android project carried out in Flutter is still a standard iOS or Android project. Flutter only generates and embeds an App.framework and Flutter.framework (iOS) by adding a shell in BuildPhase and compiles related code and embeds it in the native app by adding flutter.jar and vm/isolate_snapshot_data/instr (Android) through Gradle. Therefore, this article mainly discusses principles of construction and execution introduced by Flutter. Although compiling targets include arm, x64, x86, and arm64, the principles are similar enough to discuss only the principles related to arm. (If there is no special description, android defaults to armv7.)

Going Deeper: Code Compilation and Execution for iOS

Compilation in release mode

Under the release mode, the dart code’s build process for the iOS project under Flutter is as follows:

In the diagram, gen_snapshot is the Dart compiler, which uses tree shaking technology (similar to dependency tree logic that can generate minimal packages, so that reflection supported by Dart is disabled in Flutter) to generate machine code in assembly form and then generate the final App.framework through compilation toolchains such as xcrun. In other words, for all dart code, including business logic code and third-party package code, the flutter framework (dart) code they depend on eventually turns into App.framework.

The tree shaking function is located in gen_snapshot. To see the corresponding logic you can visit:

engine/src/third_party/dart/runtime/vm/compiler/aot/precompiler.cc

The symbols in which the dart code eventually corresponds to the App.framework are as follows:

In fact, similar to products of Android release (see below), App.framework also includes four parts: kDartVmSnapshotData, kDartVmSnapshotInstructions, kDartIsolateSnapshotData, and kDartIsolateSnapshotInstructions. Why does iOS use App.framework instead of Android’s four files? The reason is that under iOS, the Flutter engine cannot mark a memory page as executable at run time because of system restrictions, while under Android it can.

Flutter.framework corresponds to the engine and the embedder of the Flutter architecture. In practice, 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 a custom change is required, you can use the Ninja construction system to generate it by downloading the engine’s source code.

The final products of Flutter-related code are App.framework (generated by Dart code) and Flutter.framework (the engine). From the perspective of Xcode project, Generated.xcconfig describes the configuration information of the Flutter-related environment, and then the newly added xcode_backend.sh of the Build Phases in the Runner project settings implements a copy (from the flutter framework repository to the Flutter directory under the Runner project’s root directory) and the embedding of Flutter.framework and the compilation and embedding of App.framework.

The Flutter-related product in the final resulting Runner.app is as follows:

In the result, flutter_assets is the Flutter resource and the codes are App.framework and Flutter.framework, located in the Frameworks directory.

Execution in release mode

The logic of Flutter-related rendering, events, and communication processing is as follows:

The call stack for the main function in Dart is as follows:

Compilation in debug mode

In debug mode, Flutter’s compilation structure is similar to that in release mode. The differences are mainly manifested in two points:

1.Flutter.framework

Under the debug mode, the framework contains JIT support, while there is no JIT support in release mode.

2.App.framework

Unlike the App.framework, which is the native machine code corresponding to dart code in AOT mode, in JIT mode App.framework has only a few simple APIs, and its Dart code exists in the snapshot_blob.bin file. The snapshot in this part is a script snapshot with simple labeled source code. All comments and white-space characters are removed, constants are normalized, and no machine code, tree shaking, or confusion exists.

The symbol table in App.framework is as follows:

Run the strings command on Runner.app/flutter_assets/snapshot_blob.bin to see the following:

The call stack for the main entry in debug mode is as follows:

Code Compilation and Execution for Android

In addition to some platform-related features of Android and iOS, other logic such as Release corresponding to AOT and Debug corresponding to JIT will be quite similar, with only two key differences to note.

Compilation in release mode

In release mode, the Dart code’s construction link in Android Flutter projects is as follows:

In the diagram, the vm/isolate_snapshot_data/instr items are arm instructions which are loaded by the engine at run time and marked as executable. VM_snapshot_data/instr are used to initialize DartVM. For its call entrance, see Dart_Initialize (dart_api.h). Isolate_snapshot_data/instr corresponds to our App code for creating a new isolate; for its call entrance, see Dart_CreateIsolate(dart_api.h).

Flutter.jar is similar to iOS’s Flutter.framework, including the code for the engine (libflutter.so in Flutter.jar) and a set of classes and interfaces that embed Flutter into Android (FlutterMain, FlutterView, FlutterNativeView, and so on). In fact, flutter.jar is located in the /bin/cache/artifacts/engine/android* of the Flutter repository and is pulled from the Google repository by default. When a custom change is required, you can use the Ninja construction system to generate flutter.jar by downloading the source code of the engine.

Taking isolate_snapshot_data/instr as an example, the result of running the disarm command is as follows:

Its APK structure is as follows:

After the APK is newly installed, a timestamp judgment (the versionCode combined with lastUpdateTime in packageinfo) is used to decide whether to copy the flutter products in assets to the local app data directory. The copied content is as follows:

Isolate/vm_snapshot_data/instr is finally located in the app’s local data directory, where it is writable. Thus, the entire replacement and update of the app can be done by download and replacement of those snapshots.

Execution in release mode

The following chart shows the logistics of execution in release mode:

Compilation in debug mode

As in the case of iOS, the difference between debug mode and release mode in Android mainly lies with the following two components:

1. flutter.jar

The difference here is exactly as previously described for iOS.

2. App code

The app code is located in snapshot_blob.bin under flutter_assets, as is the case with iOS.

Having introduced Flutter’s compilation principles with respect to iOS and Android, we will now focus on how to configure Flutter and its engine for complete customization and optimization. Since Flutter is under agile development, the current nature of problems arising therein may not prove relevant in the future. The following section therefore focuses not on solving problems but on different types of scenarios which can illustrate the principles involved in doing so.

Customizing and Optimizing Development in Flutter

Flutter is a complex system. In addition to the three-layer architecture mentioned above, it includes Flutter Android Studio (Intellij) plug-ins, pub repository management, and numerous other components. However, customization and optimization are often related to Flutter’s toolchains; the codes are located in the flutter_tools package of the flutter repository. We will now look at how to customize this section for Android and iOS, respectively.

Customizing Android

Content relevant to Flutter customization for Android includes flutter.jar, libflutter.so (under flutter.jar), gen_snapshot, flutter.gradle, and flutter_tools. The following considerations apply when customizing Flutter:

1. Defining the target in Android as armeabi

This section is part of the construction, and its logic is under flutter.gradle. When the app supports armv7/arm64 through armeabi, you must modify the default logic of Flutter as follows:

Because of the characteristics of Gradle itself, this section can be constructed and take effect after modification.

2. Setting Android to use the first launchable-activity by default at start-up.
 
 This section is related to flutter_tools and modified as follows:

The point here is not about how to modify it, but how to make the changes take effect. In principle, for commands such as “flutter run/build/analyze/test/upgrade”, the Flutter script (flutter_repo_dir/bin/flutter) is actually run; then, through this script, flutter_tools.snapshot (generated by packages/flutter_tools) is run through Dart. The logic is as follows:

if [[ ! -f "SNAPSHOT_PATH" ]] || [[ ! -s "STAMP_PATH" ]] || [[ "(cat "STAMP_PATH")" != "revision" ]] || [[ "FLUTTER_TOOLS_DIR/pubspec.yaml" -nt "$FLUTTER_TOOLS_DIR/pubspec.lock" ]]; then
rm -f "$FLUTTER_ROOT/version"
touch "$FLUTTER_ROOT/bin/cache/.dartignore"
"$FLUTTER_ROOT/bin/internal/update_dart_sdk.sh"
echo Building flutter tool...
if [[ "$TRAVIS" == "true" ]] || [[ "$BOT" == "true" ]] || [[ "$CONTINUOUS_INTEGRATION" == "true" ]] || [[ "$CHROME_HEADLESS" == "1" ]] || [[ "$APPVEYOR" == "true" ]] || [[ "$CI" == "true" ]]; then
PUB_ENVIRONMENT="$PUB_ENVIRONMENT:flutter_bot"
fi
export PUB_ENVIRONMENT="$PUB_ENVIRONMENT:flutter_install"

if [[ -d "$FLUTTER_ROOT/.pub-cache" ]]; then
export PUB_CACHE="${PUB_CACHE:-"$FLUTTER_ROOT/.pub-cache"}"
fi

while : ; do
cd "$FLUTTER_TOOLS_DIR"
"$PUB" upgrade --verbosity=error --no-packages-dir && break
echo Error: Unable to 'pub upgrade' flutter tool. Retrying in five seconds...
sleep 5
done
"$DART" --snapshot="$SNAPSHOT_PATH" --packages="$FLUTTER_TOOLS_DIR/.packages" "$SCRIPT_PATH"
echo "$revision" > "$STAMP_PATH"
fi

It is thus clear that if you want to rebuild flutter_tools, you can delete flutter_repo_dir/bin/cache/flutter_tools.stamp (so as to re-generate it), or block out if/fi judgments (which are regenerated every time).

3. Using release mode Flutter in debug mode for an Android project

If you find that Flutter seems lagging in development, and guess that this may be caused by a factor of the logic or debug mode, you can construct the APK in release mode, or force Flutter to the release mode as follows:

Customizing iOS

Content relevant to customizing Flutter for iOS development includes Flutter.framework, gen_snapshot, xcode_backend.sh, and flutter_tools. The following considerations apply when customizing Flutter:

1. Recompilation caused by repeated replacement of Flutter.framework during optimized construction

This logic of this section is related to construction, and is located in xcode_backend.sh. To ensure that it can obtain the correct Flutter.framework each time, Flutter looks for and replaces Flutter.framework every time based on its configuration (see the Generated.xcconfig configuration). However, this leads to recompilation of the code for the project that is dependent on this framework. The necessary modification is as follows:

2. Using release mode Flutter in debug mode for an iOS project

To make this customization, change the FLUTTER_BUILD_MODE in Generated.xcconfig to “Release” and FLUTTER_FRAMEWORK_DIR to the path corresponding to “Release”.

3. Setting support for armv7

For original documentation of this scenario, see https://github.com/flutter/engine/wiki/iOS-Builds-Supporting-ARMv7.

In fact, Flutter itself supports armv7 in iOS, but currently no official support is provided, so you must modify the relevant logic as follows:

a. Generate the default logic:

Flutter.framework(arm64)

b. Modify Flutter so that flutter_tools can be rebuilt every time. Modify build_aot.dart and mac.dart, change the relevant arm64 for iOS to armv7, and change gen_snapshot to the i386 architecture.

The gen_snapshot under the i386 architecture can be generated by the following command:

./flutter/tools/gn --runtime-mode=debug --ios --ios-cpu=arm
ninja -C out/ios_debug_arm

Here there is an implied kind of logic:

Construct CPU-related predefined macros (x86_64/__i386, and so on) of gen_snapshot. The arch of the target gen_snapshot and the final App.framework architecture must remain consistent. That is, use x86_64->x86_64->arm64 or i386->i386->armv7.

c. On iPhone4S, an EXC_BAD_INSTRUCTION(EXC_ARM_UNDEFINED) error occurs when gen_snapshot generates an unsupported SDIV command, which can be implemented by adding the parameter “ — no-use-integer-division” to gen_snapshot (located in build_aot.dart). The logic behind it is as follows:

d. “Lipo create” the Flutter.framework generated based on a and b to generate a Flutter.framework that supports both armv7 and arm64.

e. Modify the Info.plist under Flutter.framework, and remove:

<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>

Similarly, you must perform the same operations on App.framework to avoid being affected by app thinning from the AppStore.

Debugging Flutter Tools

If you want to know the specific logic executed in Flutter when constructing APK in debug mode, you can take the following approach:

a. Learn about the parameters of flutter_tools commands.

b. Open packages/flutter_tools as a Dart project and add a new “Dart Command Line App” configuration. Set the Dart file to “flutter_tools.dart”, set the Working Directory to the path for your Flutter project, and set the Program arguments to the parameters obtained earlier.

Engine Customization and Debugging

Consider the following scenario. Let’s assume that we customize and develop a service based on Flutter beta v0.3.1. To ensure stability, the SDK is not upgraded during a certain period. Meanwhile, a bug on flutter v0.3.1 is modified on the master, which is marked as fix_bug_commit. How can you track and manage this situation?

1. Flutter beta v0.3.1 specifies its corresponding engine commit as 09d05a389. See: flutter/bin/internal/engine.version.

2. Obtain the engine code.

3. Since the master code is obtained in the second step and what we need is the code base corresponding to the specific commit (09d05a389), you would then pull out a new branch from this commit: custom_beta_v0.3.1.

4. Run “gclient sync” based on custom_beta_v0.3.1 (commit:09d05a389) to get all of the engine code 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 change has many dependencies on the latest update, a compilation failure may occur.

6. Run the following code for changes related to iOS:

./flutter/tools/gn --runtime-mode=debug --ios --ios-cpu=arm
ninja -C out/ios_debug_arm

./flutter/tools/gn --runtime-mode=release --ios --ios-cpu=arm
ninja -C out/ios_release_arm

./flutter/tools/gn --runtime-mode=profile --ios --ios-cpu=arm
ninja -C out/ios_profile_arm

./flutter/tools/gn --runtime-mode=debug --ios --ios-cpu=arm64
ninja -C out/ios_debug

./flutter/tools/gn --runtime-mode=release --ios --ios-cpu=arm64
ninja -C out/ios_release

./flutter/tools/gn --runtime-mode=profile --ios --ios-cpu=arm64
ninja -C out/ios_profile

To debug the Flutter.framework source code, use the following command in construction:

./flutter/tools/gn --runtime-mode=debug --unoptimized --ios --ios-cpu=arm64
ninja -C out/ios_debug_unopt

Replace Flutter.framework and gen_snapshot in Flutter with the generated products to debug the engine source code.

7. Finally, run the following code for changes related to Android:

./flutter/tools/gn --runtime-mode=debug --android --android-cpu=arm
ninja -C out/android_debug

./flutter/tools/gn --runtime-mode=release --android --android-cpu=arm
ninja -C out/android_release

./flutter/tools/gn --runtime-mode=profile --android --android-cpu=arm
ninja -C out/android_profile

You can replace gen_snapshot and flutter.jar under flutter/bin/cache/artifacts/engine/android* with constructed products to generate arm&debug/release/profile products for Android.

(Original article by Wang Kang王康)

Alibaba Tech

First hand and in-depth information about Alibaba’s latest technology → Facebook: “Alibaba Tech”. Twitter: “AlibabaTech”.

More by Alibaba Tech

Topics of interest

More Related Stories