1. 为什么要了解编译过程
- 因为只有了解了flutter的编译过程,才能更好的做flutter CI CD工作。尤其是解决"如何让flutter开发适配目前已有的完备的iOS/Android的开发工作流"问题, 尤为重要。(如果是个人开发者可以忽略)。
- 其次你会发现一个现象:"debug包 在离开xcode环境,脱机运行的时候,在初始化Flutter engine会失败。但是在xcode环境下运行debug包一切正常"。这就带来一个问题:有些业务需要再debug模式下提测,但提交给测试工程师的debug包flutter初始化会失败。为了解决这个问题,你需要了解flutter的编译过程来解决这个问题。
2. 编译过程
编译过程可以分为:
- 编译前:为编译做准备,工程参数设定,脚本注入等
- 编译:将flutter module编译为framework,并打包资源文件
- 编译后:将打包后的framework和资源复制到相应的文件夹
我们会举例说明三个过程
2.1. Demo环境:目录结构和Podfile
2.1.1. 目录结构
bash
/path/to/MyApp
├── my_flutter/
│ └── .ios/
│ └── Flutter/
│ └── podhelper.rb
└── MyApp/
└── Podfile
2.1.2. Podfile
arduino
flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
MyApp/Podfile
target 'MyApp' do
install_all_flutter_pods(flutter_application_path)
end
post_install do |installer|
flutter_post_install(installer) if defined?(flutter_post_install)
end
重点看podhelper.rb文件内的install_all_flutter_pods和flutter_post_install两个函数做了什么
2.2. 编译器前
编译前可以理解为pod install所做的事情,主要由install_all_flutter_pods和flutter_post_install两个函数完成
2.2.1. install_all_flutter_pods
ruby
def install_all_flutter_pods(flutter_application_path = nil)
# defined_in_file is a Pathname to the Podfile set by CocoaPods.
pod_contents = File.read(defined_in_file)
unless pod_contents.include? 'flutter_post_install'
puts <<~POSTINSTALL
Add `flutter_post_install(installer)` to your Podfile `post_install` block to build Flutter plugins:
post_install do |installer|
flutter_post_install(installer)
end
POSTINSTALL
raise 'Missing `flutter_post_install(installer)` in Podfile `post_install` block'
end
flutter_application_path ||= File.join('..', '..')
install_flutter_engine_pod(flutter_application_path)
install_flutter_plugin_pods(flutter_application_path)
install_flutter_application_pod(flutter_application_path)
end
这里主要调用了三个函数:
- install_flutter_engine_pod
- install_flutter_plugin_pods
- install_flutter_application_pod
这里重点看:install_flutter_application_pod。它为MyApp工程文件导入两个脚本:
- Run Flutter Build my_flutter Script:根据环境编译flutter 和 App framework
- Embed Flutter Build my_flutter Script:把编译后的产出复制到相应的目录
ruby
def install_flutter_application_pod(flutter_application_path)
flutter_application_path ||= File.join('..', '..')
export_script_directory = File.join(flutter_application_path, '.ios', 'Flutter')
# Keep script phase paths relative so they can be checked into source control.
relative = flutter_relative_path_from_podfile(export_script_directory)
flutter_export_environment_path = File.join('${SRCROOT}', relative, 'flutter_export_environment.sh')
# Compile App.framework and move it and Flutter.framework to "BUILT_PRODUCTS_DIR"
script_phase name: 'Run Flutter Build my_flutter Script',
script: "set -e\nset -u\nsource "#{flutter_export_environment_path}"\nexport VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh build",
execution_position: :before_compile
# Embed App.framework AND Flutter.framework.
script_phase name: 'Embed Flutter Build my_flutter Script',
script: "set -e\nset -u\nsource "#{flutter_export_environment_path}"\nexport VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh embed_and_thin",
execution_position: :after_compile
end
2.2.2. flutter_post_install
vbnet
def flutter_post_install(installer, skip: false)
return if skip
installer.pods_project.targets.each do |target|
target.build_configurations.each do |_build_configuration|
# flutter_additional_ios_build_settings is in Flutter root podhelper.rb
flutter_additional_ios_build_settings(target)
end
end
end
进一步调用了flutter_additional_ios_build_settings,它的主要做用是根据当前环境设置MyApp的编译配置
为每个编译配置。比如framework搜索路径等
- flutter_additional_ios_build_settings所在的文件:flutter_root/package/flutter_tools/bin/podhelper.rb
ruby
def flutter_additional_ios_build_settings(target)
return unless target.platform_name == :ios
# [target.deployment_target] is a [String] formatted as "8.0".
inherit_deployment_target = target.deployment_target[/\d+/].to_i < 12
# ARC code targeting iOS 8 does not build on Xcode 14.3.
force_to_arc_supported_min = target.deployment_target[/\d+/].to_i < 9
# This podhelper script is at $FLUTTER_ROOT/packages/flutter_tools/bin.
# Add search paths from $FLUTTER_ROOT/bin/cache/artifacts/engine.
artifacts_dir = File.join('..', '..', '..', '..', 'bin', 'cache', 'artifacts', 'engine')
debug_framework_dir = File.expand_path(File.join(artifacts_dir, 'ios', 'Flutter.xcframework'), __FILE__)
unless Dir.exist?(debug_framework_dir)
# iOS artifacts have not been downloaded.
raise "#{debug_framework_dir} must exist. If you're running pod install manually, make sure "flutter precache --ios" is executed first"
end
release_framework_dir = File.expand_path(File.join(artifacts_dir, 'ios-release', 'Flutter.xcframework'), __FILE__)
# Bundles are com.apple.product-type.bundle, frameworks are com.apple.product-type.framework.
target_is_resource_bundle = target.respond_to?(:product_type) && target.product_type == 'com.apple.product-type.bundle'
target.build_configurations.each do |build_configuration|
# Build both x86_64 and arm64 simulator archs for all dependencies. If a single plugin does not support arm64 simulators,
# the app and all frameworks will fall back to x86_64. Unfortunately that case is not detectable in this script.
# Therefore all pods must have a x86_64 slice available, or linking a x86_64 app will fail.
build_configuration.build_settings['ONLY_ACTIVE_ARCH'] = 'NO' if build_configuration.type == :debug
# Workaround https://github.com/CocoaPods/CocoaPods/issues/11402, do not sign resource bundles.
if target_is_resource_bundle
build_configuration.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
build_configuration.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
build_configuration.build_settings['CODE_SIGNING_IDENTITY'] = '-'
build_configuration.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = '-'
end
# ARC code targeting iOS 8 does not build on Xcode 14.3. Force to at least iOS 9.
build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' if force_to_arc_supported_min
# Skip other updates if it does not depend on Flutter (including transitive dependency)
next unless depends_on_flutter(target, 'Flutter')
# Bitcode is deprecated, Flutter.framework bitcode blob will have been stripped.
build_configuration.build_settings['ENABLE_BITCODE'] = 'NO'
# Profile can't be derived from the CocoaPods build configuration. Use release framework (for linking only).
# TODO(stuartmorgan): Handle local engines here; see https://github.com/flutter/flutter/issues/132228
configuration_engine_dir = build_configuration.type == :debug ? debug_framework_dir : release_framework_dir
Dir.new(configuration_engine_dir).each_child do |xcframework_file|
next if xcframework_file.start_with?('.') # Hidden file, possibly on external disk.
if xcframework_file.end_with?('-simulator') # ios-arm64_x86_64-simulator
build_configuration.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]'] = ""#{configuration_engine_dir}/#{xcframework_file}" $(inherited)"
elsif xcframework_file.start_with?('ios-') # ios-arm64
build_configuration.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]'] = ""#{configuration_engine_dir}/#{xcframework_file}" $(inherited)"
# else Info.plist or another platform.
end
end
build_configuration.build_settings['OTHER_LDFLAGS'] = '$(inherited) -framework Flutter'
build_configuration.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO'
# Suppress warning when pod supports a version lower than the minimum supported by Xcode (Xcode 12 - iOS 9).
# This warning is harmless but confusing--it's not a bad thing for dependencies to support a lower version.
# When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target.
# If the pod only supports a higher version, do not delete to correctly produce an error.
build_configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if inherit_deployment_target
# Override legacy Xcode 11 style VALID_ARCHS[sdk=iphonesimulator*]=x86_64 and prefer Xcode 12 EXCLUDED_ARCHS.
build_configuration.build_settings['VALID_ARCHS[sdk=iphonesimulator*]'] = '$(ARCHS_STANDARD)'
build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = '$(inherited) i386'
build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphoneos*]'] = '$(inherited) armv7'
end
end
2.3. 编译
当MyApp工程开始编译,就胡已调用'Run Flutter Build my_flutter Script'脚本编译flutter module。
bash
set -e
set -u
source "${SRCROOT}/../my_flutter/.ios/Flutter/flutter_export_environment.sh"
export VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh build
这里还加载了一个文件flutter_export_environment.sh。添加了一些环境变量,这些环境变量可以控制编译flutter module的参数。
bash
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/yidao/Documents/env/1/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/yidao/Documents/code/flutter/my_flutter"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"
# debug profile release
#export "FLUTTER_BUILD_MODE=release"
xcode_backend.sh其实是一个转发脚本到xcode_backend.sh build
bash
#!/usr/bin/env bash
# Copyright 2014 The Flutter Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# exit on error, or usage of unset var
set -euo pipefail
# Needed because if it is set, cd may print the path it changed to.
unset CDPATH
function follow_links() (
cd -P "$(dirname -- "$1")"
file="$PWD/$(basename -- "$1")"
while [[ -h "$file" ]]; do
cd -P "$(dirname -- "$file")"
file="$(readlink -- "$file")"
cd -P "$(dirname -- "$file")"
file="$PWD/$(basename -- "$file")"
done
echo "$file"
)
PROG_NAME="$(follow_links "${BASH_SOURCE[0]}")"
BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)"
FLUTTER_ROOT="$BIN_DIR/../../.."
DART="$FLUTTER_ROOT/bin/dart"
"$DART" "$BIN_DIR/xcode_backend.dart" "$@"
xcode_backend.sh进一步调用了同文件夹下的xcode_backend.dart build。看xcode_backend关键代码:
javascript
void main(List<String> arguments) {
File? scriptOutputStreamFile;
final String? scriptOutputStreamFileEnv = Platform.environment['SCRIPT_OUTPUT_STREAM_FILE'];
if (scriptOutputStreamFileEnv != null && scriptOutputStreamFileEnv.isNotEmpty) {
scriptOutputStreamFile = File(scriptOutputStreamFileEnv);
}
Context(
arguments: arguments,
environment: Platform.environment,
scriptOutputStreamFile: scriptOutputStreamFile,
).run();
}
class Context {
Context({required this.arguments, required this.environment, File? scriptOutputStreamFile}) {
if (scriptOutputStreamFile != null) {
scriptOutputStream = scriptOutputStreamFile.openSync(mode: FileMode.write);
}
}
final Map<String, String> environment;
final List<String> arguments;
RandomAccessFile? scriptOutputStream;
void run() {
if (arguments.isEmpty) {
// Named entry points were introduced in Flutter v0.0.7.
stderr.write(
'error: Your Xcode project is incompatible with this version of Flutter. '
'Run "rm -rf ios/Runner.xcodeproj" and "flutter create ." to regenerate.\n',
);
exit(-1);
}
final String subCommand = arguments.first;
switch (subCommand) {
case 'build':
buildApp(); //>>>>>>>>>>>>>>>看这里<<<<<<<<<<<
case 'prepare':
prepare();
case 'thin':
// No-op, thinning is handled during the bundle asset assemble build target.
break;
case 'embed':
embedFlutterFrameworks();
case 'embed_and_thin':
// Thinning is handled during the bundle asset assemble build target, so just embed.
embedFlutterFrameworks();
case 'test_vm_service_bonjour_service':
// Exposed for integration testing only.
addVmServiceBonjourService();
}
}
}
- main函数调用Context.run
- run通过判断传参为build,然后调用buildApp()方法
dart
void buildApp() {
final bool verbose = (environment['VERBOSE_SCRIPT_LOGGING'] ?? '').isNotEmpty;
final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
final String projectPath = environment['FLUTTER_APPLICATION_PATH'] ?? '$sourceRoot/..';
final String buildMode = parseFlutterBuildMode();
final List<String> flutterArgs = _generateFlutterArgsForAssemble('build', buildMode, verbose);
flutterArgs.add('${buildMode}_ios_bundle_flutter_assets');
final ProcessResult result = runSync(
'${environmentEnsure('FLUTTER_ROOT')}/bin/flutter',
flutterArgs,
verbose: verbose,
allowFail: true,
workingDirectory: projectPath, // equivalent of RunCommand pushd "${project_path}"
);
if (result.exitCode != 0) {
echoError('Failed to package $projectPath.');
exitApp(-1);
}
streamOutput('done');
streamOutput(' └─Compiling, linking and signing...');
echo('Project $projectPath built and packaged successfully.');
}
buildApp完成:
- 基于环境准备编译参数
- 通过runSync函数编译flutter module
重点看一下对parseFlutterBuildMode();的调用。
- 它会返回当前的编译模式:debug、profile、release
编译模式从两个环境变量获取:
- environment['FLUTTER_BUILD_MODE']:优先使用此变量。可以在上面提到的flutter_export_environment.sh中设置,也可以通过其他途径设置。
- environment['CONFIGURATION']:如果上面变量没有设置,则使用此变量。此变量是Xcode从工程文件设置中读取并设置此变量。
- 总结:如果没有设置FLUTTER_BUILD_MODE,则按照xcode当前编译设置选择编译模式。这样就说xcode编译debug包,则flutter module也编译debug包,如果是.......
javascript
String parseFlutterBuildMode() {
// Use FLUTTER_BUILD_MODE if it's set, otherwise use the Xcode build configuration name
// This means that if someone wants to use an Xcode build config other than Debug/Profile/Release,
// they _must_ set FLUTTER_BUILD_MODE so we know what type of artifact to build.
final String? buildMode =
(environment['FLUTTER_BUILD_MODE'] ?? environment['CONFIGURATION'])?.toLowerCase();
if (buildMode != null) {
if (buildMode.contains('release')) {
return 'release';
}
if (buildMode.contains('profile')) {
return 'profile';
}
if (buildMode.contains('debug')) {
return 'debug';
}
}
}
2.4. 编译后
通过编译前注入的"Embed Flutter Build my_flutter Script"脚本,完成Embed Flutter Build
bash
set -e
set -u
source "${SRCROOT}/../my_flutter/.ios/Flutter/flutter_export_environment.sh"
export VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh embed_and_thin
这个阶段的职责:
操作 | 说明 |
---|---|
嵌入 Flutter.framework 和 App.framework | 确保它们包含在最终 App 中 |
瘦身处理(thin) | 移除不必要的架构,提高打包效率 |
复制资源 | 包括 Dart AOT、Asset、VM Snapshot 等运行时资源 |
和编译脚本同样也会调用Context的run函数:
javascript
void run() {
if (arguments.isEmpty) {
// Named entry points were introduced in Flutter v0.0.7.
stderr.write(
'error: Your Xcode project is incompatible with this version of Flutter. '
'Run "rm -rf ios/Runner.xcodeproj" and "flutter create ." to regenerate.\n',
);
exit(-1);
}
final String subCommand = arguments.first;
switch (subCommand) {
case 'build':
buildApp();
case 'prepare':
prepare();
case 'thin':
// No-op, thinning is handled during the bundle asset assemble build target.
break;
case 'embed':
embedFlutterFrameworks();
case 'embed_and_thin':
// Thinning is handled during the bundle asset assemble build target, so just embed.
embedFlutterFrameworks();
case 'test_vm_service_bonjour_service':
// Exposed for integration testing only.
addVmServiceBonjourService();
}
}
这里调用了embedFlutterFrameworks函数:
dart
void embedFlutterFrameworks() {
// Embed App.framework from Flutter into the app (after creating the Frameworks directory
// if it doesn't already exist).
final String xcodeFrameworksDir =
'${environment['TARGET_BUILD_DIR']}/${environment['FRAMEWORKS_FOLDER_PATH']}';
runSync('mkdir', <String>['-p', '--', xcodeFrameworksDir]);
runRsync(
delete: true,
'${environment['BUILT_PRODUCTS_DIR']}/App.framework',
xcodeFrameworksDir,
);
// Embed the actual Flutter.framework that the Flutter app expects to run against,
// which could be a local build or an arch/type specific build.
runRsync(
delete: true,
'${environment['BUILT_PRODUCTS_DIR']}/Flutter.framework',
'$xcodeFrameworksDir/',
);
// Copy the native assets. These do not have to be codesigned here because,
// they are already codesigned in buildNativeAssetsMacOS.
final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
String projectPath = '$sourceRoot/..';
if (environment['FLUTTER_APPLICATION_PATH'] != null) {
projectPath = environment['FLUTTER_APPLICATION_PATH']!;
}
final String flutterBuildDir = environment['FLUTTER_BUILD_DIR']!;
final String nativeAssetsPath = '$projectPath/$flutterBuildDir/native_assets/ios/';
final bool verbose = (environment['VERBOSE_SCRIPT_LOGGING'] ?? '').isNotEmpty;
if (Directory(nativeAssetsPath).existsSync()) {
if (verbose) {
print('♦ Copying native assets from $nativeAssetsPath.');
}
runRsync(
extraArgs: <String>['--filter', '- native_assets.yaml', '--filter', '- native_assets.json'],
nativeAssetsPath,
xcodeFrameworksDir,
);
} else if (verbose) {
print("♦ No native assets to bundle. $nativeAssetsPath doesn't exist.");
}
addVmServiceBonjourService();
}
至此flutter module的编译完成
3. 解决debug包提测问题
3.1. 问题:
- 有些业务需要再debug模式下提测,但提交给测试工程师的debug包flutter初始化会失败。
3.2. 原因:
3.2.1. 分析
- Debug 模式的 Flutter Add-to-App 项目中,flutter module 会被编译为debug包。FlutterEngine 启动时会尝试 连接开发主机上的
flutter attach
服务,用于:
-
- 调试功能(热重载、日志打印、DevTools)
- 加载 Dart VM snapshot 等临时调试资源
- 而这些功能只有在 Xcode 启动时会设置好相关参数路径(通过环境变量或启动参数传入)
3.2.2. 所以:
- ✅ Xcode 启动时,会自动设置
--observatory-port
等参数,FlutterEngine 知道去哪找 Dart runtime - ❌ 图标点击启动时,这些参数没有传入 ,FlutterEngine 找不到调试环境、资源路径,导致
run
返回 NO(Dart isolate 无法启动)
3.3. 如何解决
- 思路:在debug App包中中使用flutter module的profile或release包。profile或release包不依赖xcode提供的环境。但也不能hot reload dart代码。
- 实现:可以通过设置上面提到的"environment['FLUTTER_BUILD_MODE']"环境变量,控制flutter module的编译模式
bash
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/yidao/Documents/env/1/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/yidao/Documents/code/flutter/my_flutter"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"
# debug profile release
export "FLUTTER_BUILD_MODE=release"