作者:闲鱼技术-卢克
引言
使用Flutter的过程中,如果遇到Flutter Engine的问题需要对其进行修改定制,那么我们需要对它的编译、打包以及发布流程非常清楚。这次在Flutter升级的过程中,发现之前Flutter Engine编译发布的脚本存在不少问题:
没法做到开箱即用
脚本分散在多个文件中不便于维护
Engine源码准备过程过于复杂,需要对git库重置和切换分支
另外Flutter Engine从1.5.4升级到1.9.1,Flutter Engine的产物结构发生了变化。
因此,我们对Engine打包发布的脚本进行了重写,简化编译发布的流程。
1.背景知识
想要对Engine进行定制,首先就要熟悉它的编译和调试,虽然Flutter官方文档中对Engine的编译有说明,但内容比较分散,很多地方讲解得也不够详细。
1.1.通过依赖关系确定代码版本
在我们使用Flutter开发的时候最直接接触的并不是Flutter Engine 而是 Flutter Framework(
https://github.com/flutter/flutter)。所以我们第一步就是要安装我们需要使用的Flutter Framework的版本,比如我们需要使用Flutter 1.9.1 ,则本地拉取对应tag的Flutter 进行安装
https://github.com/flutter/flutter/releases/tag/v1.9.1%2Bhotfix.6,从Flutter Framework目录下的bin/internal/engine.version文件中我们可以看到对应的Flutter Engine的版本 b863200c37df4ed378042de11c4e9ff34e4e58c9 ,这个版本是通过Flutter Engine对应commit id(git提交的sha-1哈希值)来表示的。
我们可以先把Flutter Engine的代码clone下来看下,clone之后 checkout到上面的commit节点,Flutter Engine根目录下面有一个比较重要的文件DEPS , 这个文件中描述了所有的依赖,如果你需要对其中的某些依赖比如skia,boringssl做定制的话,那么就需要基于这里面声明的版本来进行相应的修改。
1.2.工具链
在编译之前我们还需要了解下Flutter Engine编译所使用的一些工具
- gclient, https://www.chromium.org/developers/how-tos/depottools/gclient ,这是chromium所使用的一个源码库管理的工具,可以很好的管理源码以及对应的依赖,通过gclinet我们可以获取所有的编译需要的源码和依赖
- ninja, https://ninja-build.org/ ,编译工具,负责最终的编译工作
- gn, https://gn.googlesource.com/gn ,负责生成 ninja编译需要的build文件,特别像Flutter这种跨多种操作系统平台跨多种CPU架构的,就需要通过gn生成很多套不同的ninja build文件。
上面的这些工具的使用场景,简单点说就是通过gclient获取Flutter Engine编译所需要的编译环境,源码和依赖库,然后通过gn生成ninja编译所需要的build文件,最终通过ninja来进行编译。
1.3.编译
Flutter的编译并不需要我们直接取拉Flutter Engine的源码,都是通过gclient来进行源码和依赖的管理,我们要做的第一步就是创建一个工作目录,比如一个名为engine的目录,目录下创建一个gclient的配置文件.gclient, 此配置文件的语法可以参见
https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/HEAD/README.gclient.mdsolutions = [
{
"managed": False,
"name": "src/flutter",
"url": "https://github.com/flutter/flutter.git",
"custom_deps": {},
"deps_file": "DEPS",
"safesync_url": "",
},
]
进入engine目录执行 gclient sync,这个步骤比较耗时,第一次运行,即使100%之后还是会下载东西,我们可以通过进程管理器来查看gclient相应进程(.cipd_client)的网络活动情况,不要提前手动kill掉进程。第一次gclient sync 执行完成了,engine/src/flutter为Flutter Engine源码的位置,我们需要手动切换到对应的版本分支,然后再次执行gclinet sync对此版本的依赖重新同步下,此次执行会比首次执行快很多。
接下来就是对Engine进行编译了,这里我们以iOS为例,我们编译了iOS模拟器的Flutter Engine的debug产物
./flutter/tools/gn --ios --simulator --unoptimized #生成ninja编译的配置文件
./flutter/tools/gn --unoptimized
ninja -C out/ios_debug_sim_unopt && ninja -C out/host_debug_unopt
gn在生成build文件的时候有不少参数需要我们关注,可以通过类似--ios --android来指定系统平台,不指定则为host平台,比如在macOS中为macOS,在windows中为windows; 通过--unoptimized来指定Flutter Engine是否进行debug编译,如果指定了--unoptimized,则打出来的产物会带debug的一些东西,比如额外的log,assert,ios则会带上dSYM信息。这样你就可以通过IDE来进行Engine的源码调试,所以如果你想要进行Engine源码的调试则必须指定--unoptimized; 另外我们可以通过runtime-mode来指定flutter的运行模式,包含debug,release,profile不指定则为debug。
编译完成后 可以在out对应的目录中看到对应的产物 有两个比较关心的就是 Flutter.framework和clang_x64目录下的gen_snapshot,其中Flutter.framework是Flutter Engine的编译的结果,gen_snapshot则是担当着dart的编译器
同时还生成了iOS的工程文件,我们可以通过Xcode打开查看所有的Flutter Engine的源码
1.4.调试
首先我们可以通过IDE或者flutter命令创建一个demo工程,然后通过命令使用local engine来运行,
flutter run --local-engine-src-path=/Users/Luke/Projects/engine/src --local-engine=ios_debug_sim_unopt
在flutter demo工程下通过local engine的方式运行,这里我们使用的是ios模拟器来进行调试的,运行之后确认模拟器可以正常run起来。这个时候我们通过Xcode打开ios目录下的iOS的工程,会发现Generated.xcconfig中多了一些FLUTTER_ENGINE,LOCAL_ENGINE的内容,另外工程中的Flutter.framework已经变成了我们所指定的本地engine的产物(拷贝过来的)。
这个时候我们可以在main函数中设置断点(swift的工程没有main的情况下,断点设置在@UIApplicationMain下面)。debug走到断点的时候我们可以在console中通过br set -f FlutterViewController.mm -l 123来设置断点。
当然还有个更简单的方法,就是将local engine对应的生成的iOS的project拖入demo工程,就可以直接在Engine的源码中设置断点。这两种方法都可以进行断点调试。
2. Flutter Engine发布流程定制
上面介绍了如何对官方的engine代码进行编译和调试,但是在真实的开发流程中我们并不能直接使用local engine,
2.1.自己的代码库
如果你定制的代码库也是放在github上那么直接fork官方的repo进行修改便可以了,如果代码库需要在自己的服务器上,那么步骤稍微多一些,首先在你自己的git服务中创建自己的repo,比如在自己搭建的gitlab中创建一个MyFlutterEngine的repo,后继就可以进行代码库的准备了
git clone [email protected]:xxxx/MyFlutterEngine.git
git remote add upstream https://github.com/flutter/engine.git
git fetch upstream
git checkout upstream/v1.9.1-hotfixes
git branch v1.9.1
git checkout v1.9.1
git push origin v1.9.1
到这里我们就准备好我们自己的Flutter Engine的代码库了,你可以在里面进行代码的修改
2.2.Flutter Engine产物发布的格式和方式
但是当我们真正用于线上产品打包发布的时候,我们并不会使用local engine的方式来工作。Flutter Framework的目录下有一个bin/cache的目录(此目录默认是gitignore的),所有的不同架构不同平台的engine的产物都会换存在下面,通过检查会发现,这下面的engine产物和我们直接编译得出的产物并不完全一致,所以第一步就需要弄清楚bin/cache下engine产物的结构。
这里我们只关心iOS和安卓,iOS的比较简单就三个目录,ios,ios-profile,ios-release,分别对应debug,profile,release的flutter运行模式,每一个其实都是不同CPU架构进行了合并(通过lipo工具进行合并)主要包含armv7,arm64,这里gen_snapshot有两个版本,分别用于arm64和amrv7的架构进行dart的aot编译
由于安卓平台中,没法对不同CPU架构进行合并所以安卓产物的目录比较多
想知道详细的逻辑可以参见flutter tool中关于cache部分的源码
https://github.com/flutter/flutter/blob/v1.9.1-hotfixes/packages/flutter_tools/lib/src/cache.dart, 这些Flutter Engine的构建产物在需要的时候从称之为flutter infra的镜像中下载,在国内可以通过国内的镜像(
https://storage.flutter-io.cn/flutter_infra)进行下载,具体可以查看
https://flutter.dev/community/china中的说明。
2.3.发布流程
经过以上的了解,我们可以开始着手准备Flutter Engine定制化的发布了。
我们可以通过一个git库来管理我们的发布脚本和一些配置文件,这样可以保证别人只要clone下此库就可以直接使用了。如果需要支持多Flutter Engine版本的打包发布,可以一个版本对应一个打包发布脚本,将公用的方法比如log,打包状态这些抽离到公用的脚本中。
以下为单个Flutter Engine版本的发布的流程:
首先我们需要准备一个.gclient文件,实际使用时候可以将此文件做成一个模版文件,每一个Flutter Engine版本对应一个.gclient模版文件,在gclient sync之前将相应版本的模版拷贝成.gclient。
在.gclient的配置中我们可以直接指定好Flutter Engine代码及其对应的revision,如果部分依赖的库也需要修改,则可以在custom_deps中加入需要修改的依赖库的git地址及其revision,指定好revision可以避免首次gclient sync之后需要额外切换Flutter Engine的代码分支后再gclient sync的情况,也不需要手动去修改定制过的依赖的代码库和分支,可以减少不少工作量。
v1.9.1版本的.gclient模版文件:
solutions = [
{
"managed": False,
"name": "src/flutter",
"url": "[email protected]:xxxx/[email protected]",
"custom_deps": {
"src/third_party/skia":"[email protected]:xxxx/[email protected]",
"src/third_party/boringssl/src":"[email protected]:xxxx/[email protected]",
},
"deps_file": "DEPS",
"safesync_url": "",
},
]
gclient sync将Flutter Engine代码以及对应的依赖都准备好了之后就是编译的工作了,同步完成后目录下面出现的src目录其本身也是一个git库,具体可查看
https://github.com/flutter/buildroot,内容主要是Flutter Engine的编译环境,src下面的flutter则为Flutter Engine的代码,下面是具体的编译脚本,这里以iOS为例
./flutter/tools/gn --runtime-mode=debug --ios --simulator
ninja -C out/ios_debug_sim
./flutter/tools/gn --runtime-mode=debug --ios --ios-cpu=arm
ninja -C out/ios_debug_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=arm
ninja -C out/ios_release_arm
./flutter/tools/gn --runtime-mode=release --ios --ios-cpu=arm64
ninja -C out/ios_release
./flutter/tools/gn --runtime-mode=profile --ios --ios-cpu=arm
ninja -C out/ios_profile_arm
./flutter/tools/gn --runtime-mode=profile --ios --ios-cpu=arm64
ninja -C out/ios_profile
执行之后所有的初步的产物都会在out对应的子目录中,现在我们再次进入到Flutter Engine 编译的根目录中,进行产物的组装和发布,在这里我们目前采用了一种比较简单的发布方案,我们将自己的Flutter Framework的bin/cache目录从gitignore中移除,发布的时候就将产物覆盖然后提交到我们自己的Flutter Framework的库中,缺点就是Flutter Framework的git库体积会比较大,而且后继万一官方做一些缓存策略的改变也会被影响到。
下面以iOS debug的产物为例,release,profile都是类似的过程:
# ios debug
cp -rf src/out/ios_debug/Flutter.framework tmp/
lipo -create -output tmp/Flutter.framework/Flutter \
src/out/ios_debug/Flutter.framework/Flutter \
src/out/ios_debug_arm/Flutter.framework/Flutter \
src/out/ios_debug_sim/Flutter.framework/Flutter
cd tmp
zip -r Flutter.framework.zip Flutter.framework
cd ..
mkdir -p "${flutter_path}"/bin/cache/artifacts/engine/ios
cp -rf tmp/Flutter.framework "${flutter_path}"/bin/cache/artifacts/engine/ios/
cp -f tmp/Flutter.framework.zip "${flutter_path}"/bin/cache/artifacts/engine/ios/
cp -f src/out/ios_debug/clang_x64/gen_snapshot "${flutter_path}"/bin/cache/artifacts/engine/ios/gen_snapshot_arm64
cp -f src/out/ios_debug_arm/clang_x64/gen_snapshot "${flutter_path}"/bin/cache/artifacts/engine/ios/gen_snapshot_armv7
rm -rf tmp/*
收益
通过将此脚本,只要我们下载好发布工具库,直接执行脚本就可以自动开始编译发布了,真正做到开箱即用,免去别的配置和准备。
.gclient 使用模版文件,不同版本的engine对应不同的模版,打包时拷贝执行
.gclient 中指定好版本分支和自定义的依赖信息,源代码和依赖sync后一步到位,避免二次切换分支
打包流程脚本和公用方法分离,不同版本的打包脚本独立分开,通用方法共享,方便维护
后续计划
前面提到将产物放到Flutter Framework的bin/cache目录下并不是最优的方案,通过官方的文档可以知道通过设置FLUTTERSTORAGEBASEURL的环境变量可以更改flutter infra的镜像的地址,所以后继的方案就是搭建自己的flutter infra镜像,产物编译完成后提交到自己的镜像网站中。