关注前沿技术,分享热点话题,QCon全球软件开发大会三站同启,重磅回归!立即查看 了解详情

Flutter包大小治理上的探索与实践

2020 年 9 月 27 日

Flutter包大小治理上的探索与实践

Flutter 作为一种全新的响应式、跨平台、高性能的移动开发框架,在性能、稳定性和多端体验一致上都有着较好的表现,自开源以来,已经受到越来越多开发者的喜爱。
但是,Flutter 的引入往往带来包体积的增大,给很多研发团队带来了很大的困扰。美团外卖前端团队对 Flutter 的包大小问题进行了调研和实践,设计并实现了一套基于动态下发的包大小优化方案,希望对从事 Flutter 开发相关的同学能够带来一些启发或者帮助。

一、背景

随着 Flutter 框架的不断发展和完善,业内越来越多的团队开始尝试并落地 Flutter 技术。不过在实践过程中我们发现,Flutter 的接入会给现有的应用带来比较明显的包体积增加。不论是在 Android 还是在 iOS 平台上,仅仅是接入一个 Flutter Demo 页面,包体积至少要增加 5M,这对于那些包大小敏感的应用来说其实是很难接受的。

对于包大小问题,Flutter 官方也在持续跟进优化:

除了 Flutter SDK 内部或 Dart 实现的优化,我们是否还有进一步优化的空间呢?答案是肯定的。为了帮助业务方更好的接入和落地 Flutter 技术,MTFlutter 团队对 Flutter 的包大小问题进行了调研和实践,设计并实现了一套基于动态下发的包大小优化方案,瘦身效果也非常可观。这里分享给大家,希望对大家能有所帮助或者启发。

二、Flutter 包大小问题分析

在 Flutter 官方的优化文档中,提到了减少应用尺寸的方法:在 V1.16.2 及以上使用—split-debug-info 选项(可以分离出 debug info);移除无用资源,减少从库中带入的资源,控制适配的屏幕尺寸,压缩图片文件。这些措施比较直接并容易理解,但为了探索进一步瘦身空间并让大家更好的理解技术方案,我们先从了解 Flutter 的产物构成开始,然后再一步步分析有哪些可行的方案。

2.1 Flutter 产物介绍

我们首先以官方的 Demo 为例,介绍一下 Flutter 的产物构成及各部分占比。不同 Flutter 版本以及打包模式下,产物有所不同,本文均以 Flutter 1.9 Release 模式下的产物为准。

2.1.1 iOS 侧 Flutter 产物

图 1 Flutter iOS 产物组成示意图

iOS 侧的 Flutter 产物主要由四部分组成(info.plist 比较小,对包体积的影响可忽略,这里不作为重点介绍),表格 1 中列出了各部分的详细信息。

表 1 Flutter 产物组成

2.1.2 Android 侧 Flutter 产物

图 2 Flutter Android 产物组成示意图

Android 侧的 Flutter 产物总共 5.16MB,由四部分组成,表格 2 中列出了各部分的详细信息。

表 2 Flutter Android 产物组成

2.1.3 各部分产物的变化趋势

无论是 Android 还是 iOS,Flutter 的产物大体可以分为三部分:

  1. Flutter 引擎,该部分大小固定不变,但初始占比较高。
  2. Flutter 业务与框架,该部分大小随着 Flutter 业务代码的增多而逐渐增加。它是这样的一个曲线:初始增长速度极快,随着代码增多,增长速度逐渐减缓,最终趋近线性增长。原因是 Flutter 有一个 Tree Shaking 机制,从 Main 方法开始,逐级引用,最终没有被引用的代码,比如类和函数都会被裁剪掉。一开始引入 Flutter 之后随便写一个业务,就会大量用到 Flutter/Dart SDK 代码,这样初期 Flutter 包体积极速增加,但是过了一个临界点,用户包体积的增加就基本取决于 Flutter 业务代码增量,不会增长得太快。
  3. Flutter 资源,该部分初始占比较小,后期增长主要取决于用到的本地图片资源的多少,增长趋势与资源多少成正比。

下图 3 展示了 Flutter 各资源变化的趋势:

图 3 Flutter 各资源大小变化的趋势图

2.2 不同优化思路分析

上面我们对 Flutter 产物进行了分析,接下来看一下官方提供的优化思路如何应用于 Flutter 产物,以及对应的困难与收益如何。

1. 删减法

Flutter 引擎中包括了 Dart、skia、boringssl、icu、libpng 等多个模块,其中 Dart 和 skia 是必须的,其他模块如果用不到倒是可以考虑裁掉,能够带来几百 k 的瘦身收益。业务方可以根据业务诉求自定义裁剪。

Flutter 业务产物,因为 Flutter 的 Tree Shaking 机制,该部分产物从代码的角度已经是精简过的,要想继续精简只能从业务的角度去分析。

Flutter 资源中占比较多的一般是图片,对于图片可以根据业务场景,适当降低图片分辨率,或者考虑替换为网络图片。

2. 压缩法

因为无论是 Android 还是 iOS,安装包本身已经是压缩包了,对 Flutter 产物再次压缩的收益很低,所以该方法并不适用。

3. 动态下发

对于静态资源,理论上是 Android 和 iOS 都可以做到动态下发。而对于代码逻辑部分的编译产物,在 Android 平台支持可执行产物的动态加载,iOS 平台则不允许执行动态下发的机器指令。

经过上面的分析可以发现,除了删减、压缩,对所有业务适用、可行且收益明显的进一步优化空间重点在于动态下发了。能够动态下发的部分越多,包大小的收益越大。因此我们决定从动态下发入手来设计一套 Flutter 包大小优化方案。

三、基于动态下发的 Flutter 包大小优化方案

我们在 Android 和 iOS 上实现的包大小优化方案有所不同,区别在于 Android 侧可以做到 so 和 Flutter 资源的全部动态下发,而 iOS 侧由于系统限制无法动态下发可执行产物,所以需要对产物的组成和其加载逻辑进行分析,将其中非必须和动态链接库一起加载的部分进行动态下发、运行时加载。

当将产物动态下发后,还需要对引擎的初始化流程做修改,这样才能保证产物的正常加载。由于两端技术栈的不同,在很多具体实现上都采用了不同的方式,下面就分别来介绍下两端的方案。

3.1 iOS 侧方案

在 iOS 平台上,由于系统的限制无法实现在运行时加载并运行可执行文件,而在上文产物介绍中可以看到,占比较高的 App 及 Flutter 这两个均是可执行文件,理论上是不能进行动态下发的,实际上对于 Flutter 可执行文件我们能做的确实不多,但对于 App 这个可执行文件,其内部组成的四个模块并不是在链接时都必须存在的,可以考虑部分移出,进而来实现包体积的缩减。

因此,在该部分我们首先介绍 Flutter 产物的生成和加载的流程,通过对流程细节的分析来挖掘出产物可以被拆分出动态下发的部分,然后基于实现原理来设计实现工程化的方案。

3.1.1 实现原理简析

为了实现 App 的拆分,我们需要了解下 App.framework 是怎样生成以及各部分资源时如何加载的。如下图 4 所示,Dart 代码会使用 gen_snapshot 工具来编译成.S 文件,然后通过 xcrun 工具来进行汇编和链接最终生成 App.framework。其中 gen_snapshot 是 Dart 编译器,采用了 Tree Shaking 等技术,用于生成汇编形式的机器代码。

图 4 App.framework 生成流程示意图

产物加载流程:

图 5 Flutter 产物加载流程图

如上图 5 所示,Flutter engine 在初始化时会从根据 FlutterDartProject 的 settings 中配置资源路径来加载可执行文件(App)、flutter_assets 等资源,具体 settings 的相关配置如下:

复制代码
// settings
{
...
// snapshot 文件地址或内存地址
std::string vm_snapshot_data_path;
MappingCallback vm_snapshot_data;
std::string vm_snapshot_instr_path;
MappingCallback vm_snapshot_instr;
std::string isolate_snapshot_data_path;
MappingCallback isolate_snapshot_data;
std::string isolate_snapshot_instr_path;
MappingCallback isolate_snapshot_instr;
// library 模式下的 lib 文件路径
std::string application_library_path;
// icudlt.dat 文件路径
std::string icu_data_path;
// flutter_assets 资源文件夹路径
std::string assets_path;
//
...
}

以加载 vm_snapshot_data 为例,它的加载逻辑如下:

load vm_snapshot_data

复制代码
std::unique_ptr<DartSnapshotBuffer> ResolveVMData(const Settings& settings) {
// 从 settings.vm_snapshot_data 中取
if (settings.vm_snapshot_data) {
...
}
// 从 settings.vm_snapshot_data_path 中取
if (settings.vm_snapshot_data_path.size() > 0) {
...
}
// 从 settings.application_library_path 中取
if (settings.application_library_path.size() > 0) {
...
}
auto loaded_process = fml::NativeLibrary::CreateForCurrentProcess();
// 根据 kVMDataSymbol 从 native library 中加载
return DartSnapshotBuffer::CreateWithSymbolInLibrary(
loaded_process, DartSnapshot::kVMDataSymbol);
}

对于 iOS 来说,它默认会根据 kVMDataSymbol 来从 App 中加载对应资源,而其实 settings 是给提供了通过 path 的方式来加载资源和 snapshot 入口,那么对于 flutter_assets、icudtl.dat 这些静态资源,我们完全可以将其移出托管到服务端,然后动态下发。

而由于 iOS 系统的限制,整个 App 可执行文件则不可以动态下发,但在第二部分的介绍中我们了解到,其实 App 是由 kDartVmSnapshotData、kDartVmSnapshotInstructions、kDartIsolateSnapshotData、kDartIsolateSnapshotInstructions 等四个部分组成的,其中 kDartIsolateSnapshotInstructions、kDartVmSnapshotInstructions 为指令段,不可通过动态下发的方式来加载,而 kDartIsolateSnapshotData、kDartVmSnapshotData 为数据段,它们在加载时不存在限制。

到这里,其实我们就可以得到 iOS 侧 Flutter 包大小的优化方案:将 flutter_assets、icudtl.dat 等静态资源及 kDartVmSnapshotData、kDartIsolateSnapshotData 两部分在编译时拆分出去,通过动态下发的方式来实现包大小的缩减。但此方案有个问题,kDartVmSnapshotData、kDartIsolateSnapshotData 是在编译时就写入到 App 中了,如何实现自动化地把此部分拆分出去是一个待解决的问题。为了解决此问题,我们需要先了解 kDartVmSnapshotData、kDartIsolateSnapshotData 的写入时机。接下来,我们通过下图 6 来简单地介绍一下该过程:

图 6 Flutter Data 段写入时序图

代码通过 gen_snapshot 工具来进行编译,它的入口在 gen_snapshot.cc 文件,通过初始化、预编译等过程,最终调用 Dart_CreateAppAOTSnapshotAsAssembly 方法来写入 snapshot。因此,我们可以通过修改此流程,在写入 snapshot 时只将 instructions 写入,而将 data 重定向输入到文件,即可实现 kDartVmSnapshotData、kDartIsolateSnapshotData 与 App 的分离。此部分流程示意图如下图 7 所示:

图 7 Flutter 产物拆分流程示意图

3.1.2 工程化方案

在完成了 App 数据段与代码段分离的工作后,我们就可以将数据段及资源文件通过动态下发、运行时加载的方式来实现包体积的缩减。由此思路衍生的 iOS 侧整体方案的架构如下图 8 所示;其中定制编译产物阶段主要负责定制 Flutter engine 及 Flutter SDK,以便完成产物的“瘦身”工作;发布集成阶段则为产物的发布和工程集成提供了一套标准化、自动化的解决方案;而运行阶段的使命是保证“瘦身”的资源在 engine 启动的时候能被安全稳定地加载。

图 8 架构设计

注:图例中 MTFlutterRoute 为 Flutter 路由容器,MWS 指的是美团云。

3.1.2.1 定制编译产物阶段

虽然我们不能把 App.framework 及 Flutter.framework 通过动态下发的方式完全拆分出去,但可以剥离出部分非安装时必须的产物资源,通过动态下发的方式来达到 Flutter 包体积缩减的目的,因此在该阶段主要工作包括三部分。

1. 新增编译 command

在将 Flutter 包瘦身工程化时,我们必须保证现有的流程的编译规则不会被影响,需要考虑以下两点:

  • 增加编译“瘦身”的 Flutter 产物构建模式, 该模式应能编译出 AOT 模式下的瘦身产物。
  • 不对常规的编译模式(debug、profile、release)引入影响。

对于 iOS 平台来说,AOT 模式 Flutter 产物编译的关键工作流程图如下图 9 所示。runCommand 会将编译所需参数及环境变量封装传递给编译后端(gen_snapshot 负责此部分工作),进而完成产物的编译工作:

图 9 AOT 模式 Flutter 产物编译的关键工作流程图

为了实现“瘦身”的工作流,工具链在图 9 的流程中新增了 buildwithoutdata 的编译 command,该命令针对通过传递相应参数(without-data=true)给到编译后端(gen_snapshot),为后续编译出剥离 data 段提供支撑:

xcode_backend.sh

复制代码
if [[ $# == 0 ]]; then
# Backwards-compatibility: if no args are provided, build.
BuildApp
else
case $1 in
"build")
BuildApp ;;
"buildWithoutData")
BuildAppWithoutData ;;
"thin")
ThinAppFrameworks ;;
"embed")
EmbedFlutterFrameworks ;;
esac
fi

build_aot.dart

复制代码
..addFlag('without-data',
negatable: false,
defaultsTo: false,
hide: true,
)

2. 编译后端定制

该部分主要对 gen_snapshot 工具进行定制,当 gen_snapshot 工具在接收到 Dart 层传来的“瘦身”命令时,会解析参数并执行我们定制的方法 Dart_CreateAppAOTSnapshotAsAssembly,该部分主要做了两件事:

  • 定制产物编译过程,生成剥离 data 段的编译产物。
  • 重定向 data 段到文件中,以便后续进行使用。

具体到处理的细节,首先我们需要在 gen_sanpshot 的入口处理传参,并指定重定向 data 文件的地址:

gen_snapshot.cc

复制代码
CreateAndWritePrecompiledSnapshot() {
...
if (snapshot_kind == kAppAOTAssembly) { // 常规 release 模式下产物的编译流程
...
} else if (snapshot_kind == kAppAOTAssemblyDropData) {
...
result = Dart_CreateAppAOTSnapshotAsAssembly(StreamingWriteCallback,
file,
&vm_snapshot_data_buffer,
&vm_snapshot_data_size,
&isolate_snapshot_data_buffer,
&isolate_snapshot_data_size,
true); // 定制产物编译过程,生成剥离 data 段的编译产物 snapshot_assembly.S
...
} else if (...) {
...
}
...
}

在接受到编译“瘦身”模式的命令后,将会调用定制的 FullSnapshotWriter 类来实现 Snapshot_assembly.S 的生成,该类会将原有编译过程中 vm_snapshot_data、isolate_snapshot_data 的写入过程改写成缓存到 buff 中,以便后续写入到独立的文件中:

dart_api_imp.cc

复制代码
// drop_data=true, 表示后瘦身模式的编译过程
// vm_snapshot_data_buffer、isolate_snapshot_data_buffer 用于保存 vm_snapshot_data、isolate_snapshot_data 以便后续写入文件
Dart_CreateAppAOTSnapshotAsAssembly(Dart_StreamingWriteCallback callback,
void* callback_data,
bool drop_data,
uint8_t** vm_snapshot_data_buffer,
uint8_t** isolate_snapshot_data_buffer) {
...
FullSnapshotWriter writer(Snapshot::kFullAOT, &vm_snapshot_data_buffer,
&isolate_snapshot_data_buffer, ApiReallocate,
&image_writer, &image_writer);
if (drop_data) {
writer.WriteFullSnapshotWithoutData(); // 分离出数据段
} else {
writer.WriteFullSnapshot();
}
...
}

当 data 段被缓存到 buffer 中后,便可以使用 gen_snapshot 提供的文件写入的方法 WriteFile 来实现数据段以文件形式从编译产物中分离:

gen_snapshot.cc

复制代码
static void WriteFile(const char* filename, const uint8_t* buffer, const intptr_t size);
// 写 data 到指定文件中
{
...
WriteFile(vm_snapshot_data_filename, vm_snapshot_data_buffer, vm_snapshot_data_size); // 写入 vm_snapshot_data
WriteFile(isolate_snapshot_data_filename, isolate_snapshot_data_buffer, isolate_snapshot_data_size); // 写入 isolate_snapshot_data
...
}

3. engine 定制

编译参数修改

iOS 侧使用 -0z 参数可以获得包体积缩减的收益(大约为 700KB 左右的收益),但会有相应的性能损耗,因此该部分作为一个可选项提供给业务方,工具链提供相应版本的 Flutter engine 的定制。

资源加载方式定制

对于 engine 的定制,主要围绕如何“手动”引入拆分出的资源来展开,好在 engine 提供了 settings 接口让我们可以实现自定义引入文件的 path,因此我们需要做的就是对 Flutter engine 初始化的过程进行相应改造:

shell/platform/darwin/ios/framework/Headers/FlutterDartProject.h

复制代码
/**
* custom icudtl.dat path
*/
@property(nonatomic, copy) NSString* icuDataPath;
/**
* custom flutter_assets path
*/
@property(nonatomic, copy) NSString* assetPath;
/**
* custom isolate_snapshot_data path
*/
@property(nonatomic, copy) NSString* isolateSnapshotDataPath;
/**
*custom vm_snapshot_data path
*/
@property(nonatomic, copy) NSString* vmSnapshotDataPath;

在运行时“手动”配置上述路径,并结合上述参数初始化 FlutterDartProject,从而达到 engine 启动时从配置路径加载相应资源的目的。

engine 编译自动化

在完成 engine 的定制和改造后,还需要手动编译一下 engine 源码,生成各平台、架构、模式下的产物,并将其集成到 Flutter SDK 中,为了让引擎定制的流程标准化、自动化,MTFlutter 工具链提供了一套 engine 自动化编译发布的工具。如流程图 10 所示,在完成 engine 代码的自定义修改之后,工具链会根据 engine 的 patch code 编译出各平台、架构及不同模式下的 engine 产物,然后自动上传到美团云上,在开发和打包时只需要通简单的命令,即可安装和使用定制后的 Flutter engine:

图 10 Flutter engine 自动化编译发布流程
3.1.2.2 发布集成阶段

当完成 Dart 代码编译产物的定制后,我们下一步要做的就是改造 MTFlutter 工具链现有的产物发布流程,支持打出“瘦身”模式的产物,并将瘦身模式下的产物进行合理的组织、封装、托管以方便产物的集成。从工具链的视角来看,该部分的流程示如下图 11 所示:

图 11 Flutter 产物发布集成流程示意图

自动化发布与版本管理

MTFlutter 工具链将“瘦身”集成到产物发布的流水线中,新增一种 thin 模式下的产物,在 iOS 侧该产物包括 release 模式下瘦身后的 App.framework、Flutter.framework 以及拆分出的数据、资源等文件。当开发者提交了代码并使用 Talos(美团内部前端持续交付平台)触发 Flutter 打包时,CI 工具会自动打出瘦身的产物包及需要运行时下载的资源包、生成产物相关信息的校验文件并自动上传到美团云上。

对于产物资源的版本管理,我们则复用了美团云提供资源管理的能力。在美团云上,产物资源以文件目录的形式来实现各版本资源的相互隔离,同时对“瘦身”资源单独开一个 bucket 进行单独管理,在集成产物时,集成插件只需根据当前产物 module 的名称及版本号便可获取对应的产物。

自动化集成

针对瘦身模式 MTFlutter 工具链对集成插件也进行了相应的改造,如下图 12 所示。我们对 Flutter 集成插件进行了修改,在原有的产物集成模式的基础上新增一种 thin 模式,该模式在表现形式与原有的 debug、release、profile 类似,区别在于:为了方便开发人员调试,该模式会依据当前工程的 buildconfigration 来做相应的处理,即在 debug 模式下集成原有的 debug 产物,而在 release 模式下才集成“瘦身”产物包。

图 12 Flutter iOS 端集成插件修改
3.1.2.3 运行阶段

运行阶段所处理的核心问题包括资源下载、缓存、解压、加载及异常监控等。一个典型的瘦身模式下的 engine 启动的过程如图 13 所示。

该过程包括:

  • 资源下载 :读取工程配置文件,得到当前 Flutter module 的版本,并查询和下载远程资源。
  • 资源解压和校验 :对下载资源进行完整性校验,校验完成则进行解压和本地缓存。
  • 启动 engine :在 engine 启动时加载下载的资源。
  • 监控和异常处理 :对整个流程可能出现的异常情况进行处理,相关数据情况进行监控上报。

图 13 iOS 侧瘦身模式下 engine 启动流程图

为了方便业务方的使用、减少其接入成本,MTFlutter 将该部分工作集成至 MTFlutterRoute 中,业务方仅需引入 MTFlutterRoute 即可将“瘦身”功能接入到项目中。

3.2 Android 侧方案

3.2.1 整体架构

在 Android 侧,我们做到了除 Java 代码外的所有 Flutter 产物都动态下发。完整的优化方案概括来说就是:动态下发 + 自定义引擎初始化 + 自定义资源加载。方案整体分为打包阶段和运行阶段,打包阶段会将 Flutter 产物移除并生成瘦身的 APK,运行阶段则完成产物下载、自定义引擎初始化及资源加载。其中产物的上传和下载由 DynLoader 完成,这是由美团平台迭代工程组提供的一套 so 与 assets 的动态下发框架,它包括编译时和运行时两部分的操作:

  1. 工程配置:配置需要上传的 so 和 assets 文件。
  2. App 打包时,会将配置 1 中的文件压缩上传到动态发布系统,并从 APK 中移除。
  3. App 每次启动时,向动态发布系统发起请求,请求需要下载的压缩包,然后下载到本地并解压,如果本地已经存在了,则不进行下载。

我们在 DynLoader 的基础上,通过对 Flutter 引擎初始化及资源加载流程进行定制,设计了整体的 Flutter 包大小优化方案:

图 14 Android 侧 Flutter 包大小优化方案整体架构

打包阶段 :我们在原有的 APK 打包流程中,加入一些自定义的 gradle plugin 来对 Flutter 产物进行处理。在预处理流程,我们将一些无用的资源文件移除,然后将 flutter_assets 中的文件打包为 bundle.zip。然后通过 DynLoader 提供的上传插件将 libflutter.so、libapp.so 和 flutter_assets/bundle.zip 从 APK 中移除,并上传到动态发布系统托管。其中对于多架构的 so,我们通过在 build.gradle 中增加 abiFilters 进行过滤,只保留单架构的 so。最终打包出来的 APK 即为瘦身后的 APK。

不经处理的话,瘦身后的 APK 一进到 Flutter 页面肯定会报错,因为此时 so 和 flutter_assets 可能都还没下载下来,即使已经下载下来,其位置也发生了改变,再使用原来的加载方式肯定会找不到。所以我们在运行阶段需要做一些特殊处理:

1. Flutter 路由拦截

首先要使用 Flutter 路由拦截器,在进到 Flutter 页面之前,要确保 so 和 flutter_assets 都已经下载完成,如果没有下载完,则显示 loading 弹窗,然后调用 DynLoader 的方法去异步下载。当下载完成后,再执行原来的跳转逻辑。

2. 自定义引擎初始化

第一次进到 Flutter 页面,需要先初始化 Flutter 引擎,其中主要是将 libflutter.so 和 libapp.so 的路径改为动态下发的路径。另外还需要将 flutter_assets/bundle.zip 进行解压。

3. 自定义资源加载

当引擎初始化完成后,开始执行 Dart 代码的逻辑。此时肯定会遇到资源加载,比如字体或者图片。原有的资源加载器是通过 method channel 调用 AssetManager 的方法,从 APK 中的 assets 中进行加载,我们需要改成从动态下发的路径中加载。

下面我们详细介绍下某些部分的具体实现。

3.2.2 自定义引擎初始化

原有的 Flutter 引擎初始化由 FlutterMain 类的两个方法完成,分别为 startInitialization 和 ensureInitializationComplete,一般在 Application 初始化时调用 startInitialization(懒加载模式会延迟到启动 Flutter 页面时再调用),然后在 Flutter 页面启动时调用 ensureInitializationComplete 确保初始化的完成。

图 15 Android 侧 Flutter 引擎初始化流程图

在 startInitialization 方法中,会加载 libflutter.so,在 ensureInitializationComplete 中会构建 shellArgs 参数,然后将 shellArgs 传给 FlutterJNI.nativeInit 方法,由 jni 侧完成引擎的初始化。其中 shellArgs 中有个参数 AOT_SHARED_LIBRARY_NAME 可以用来指定 libapp.so 的路径。

自定义引擎初始化,主要要修改两个地方,一个是 System.loadLibrary(“flutter”),一个是 shellArgs 中 libapp.so 的路径。有两种办法可以做到:

  • 直接修改 FlutterMain 的源码,这种方式简单直接,但是需要修改引擎并重新打包,业务方也需要使用定制的引擎才可以。
  • 继承 FlutterMain 类,重写 startInitialization 和 ensureInitializationComplete 的逻辑,让业务方使用我们的自定义类来初始化引擎。当自定义类完成引擎的初始化后,通过反射的方式修改 sSettings 和 sInitialized,从而使得原有的初始化逻辑不再执行。

本文使用第二种方式,需要在 FlutterActivity 的 onCreate 方法中首先调用自定义的引擎初始化方法,然后再调用 super 的 onCreate 方法。

3.2.3 自定义资源加载

Flutter 中的资源加载由一组类完成,根据数据源的不同分为了网络资源加载和本地资源加载,其类图如下:

图 16 Flutter 资源加载相关类图

AssetBundle 为资源加载的抽象类,网络资源由 NetworkAssetBundle 加载,打包到 Apk 中的资源由 PlatformAssetBundle 加载。

PlatformAssetBundle 通过 channel 调用,最终由 AssetManager 去完成资源的加载并返回给 Dart 层。

我们无法修改 PlatformAssetBundle 原有的资源加载逻辑,但是我们可以自定义一个资源加载器对其进行替换:在 widget 树的顶层通过 DefaultAssetBundle 注入。

自定义的资源加载器 DynamicPlatformAssetBundle,通过 channel 调用,最终从动态下发的 flutter_assets 中加载资源。

3.2.4 字体动态加载

字体属于一种特殊的资源,其有两种加载方式:

  • 静态加载 :在 pubspec.yaml 文件中声明的字体及为静态加载,当引擎初始化的时候,会自动从 AssetManager 中加载静态注册的字体资源。
  • 动态加载 :Flutter 提供了 FontLoader 类来完成字体的动态加载。

当资源动态下发后,assets 中已经没有字体文件了,所以静态加载会失败,我们需要改为动态加载。

3.2.5 运行时代码组织结构

整个方案的运行时部分涉及多个功能模块,包括产物下载、引擎初始化、资源加载和字体加载,既有 Native 侧的逻辑,也有 Dart 侧的逻辑。如何将这些模块合理的加以整合呢?平台团队的同学给了很好的答案,并将其实现为一个 Flutter Plugin:flutter_dynamic(美团内部库)。其整体分为 Dart 侧和 Android 侧两部分,Dart 侧提供字体和资源加载方法,方法内部通过 method channel 调到 Android 侧,在 Android 侧基于 DynLoader 提供的接口实现产物下载和资源加载的逻辑。

图 17 FlutterDynamic 结构图

四、方案的接入与使用

为了让大家了解上述方案使用层面的设计,我们在此把美团内部的使用方式介绍给大家,其中会涉及到一些内部工具细节我们暂不展开,重点解释设计和使用体验部分。由于 Android 和 iOS 的实现方案有所区别,故在接入方式相应的也会有些差异,下面针对不同平台分开进行介绍:

4.1 iOS

在上文方案的设计中,我们介绍到包瘦身功能已经集成进入美团内部 MTFlutter 工具链中,因此当业务方在使用了 MTFlutter 后只需简单的几步配置便可实现包瘦身功能的接入。iOS 的接入使用上总体分为三步:

1. 引入 Flutter 集成插件(cocoapods-flutter-plugin 美团内部 Cocoapods 插件,进一步封装 Flutter 模块引入,使之更加清晰便捷)

Gemfile

复制代码
gem 'cocoapods-flutter-plugin', '~> 1.2.0'

2. 接入 MTFlutterRoute 混合业务容器(美团内部 pod 库,封装了 Flutter 初始化及全局路由等能力),实现基于“瘦身”产物的初始化

Flutter 业务工程中引入 mt_flutter_route:

pubspec.yaml

复制代码
dependencies:
mt_flutter_route: ^2.4.0

3. 在 iOS Native 工程中引入 MTFlutterRoute pod

podfile

复制代码
binary_pod 'MTFlutterRoute', '2.4.1.8'

经过上面的配置后,正常 Flutter 业务发版时就会自动产生“瘦身”后的产物,此时只需在工程中配置瘦身模式即可完成接入:

podfile

复制代码
flutter 'your_flutter_project', 'x.x.x', :thin => true

4.2 Android

4.2.1 Flutter 侧修改

  1. 在 Flutter 工程 pubspec.yaml 中添加 flutter_dynamic(美团内部 Flutter Plugin,负责 Dart 侧的字体、资源加载)依赖。
  2. 在 main.dart 中添加字体动态加载逻辑,并替换默认资源加载器。

main.dart

复制代码
void main() async {
// 动态加载字体
await dynFontInit();
// 自定义资源加载器
runApp(DefaultAssetBundle(
bundle: dynRootBundle,
child: MyApp(),
));
}

4.2.2 Native 侧修改

1. 打包脚本修改

在 App 模块的 build.gradle 中通过 apply 特定 plugin 完成产物的删减、压缩以及上传。

2. 在 Application 的 onCreate 方法中初始化 FlutterDynamic。

3. 添加 Flutter 页面跳转拦截。

在跳转到 Flutter 页面之前,需要使用 FlutterDynamic 提供的接口来确保产物已经下载完成,在下载成功的回调中来执行真正的跳转逻辑。

复制代码
class FlutterRouteUtil {
public static void startFlutterActivity(final Context context, Intent intent) {
FlutterDynamic.getInstance().ensureLoaded(context, new LoadCallback() {
@Override
public void onSuccess() {
// 在下载成功的回调中执行跳转逻辑
context.startActivity(intent);
}
});
}
}

备注:如果 App 有使用类似 WMRoute 之类的路由组件的话,可以自定义一个 UriHandler 来统一处理所有的 Flutter 页面跳转,同样在 ensureLoaded 方法回调中执行真正的跳转逻辑。

4. 添加引擎初始化逻辑

我们需要重写 FlutterActivity 的 onCreate 方法,在 super.onCreate 之前先执行自定义的引擎初始化逻辑。

MainFlutterActivity.java

复制代码
public class MainFlutterActivity extends FlutterActivity {
@Override
protected void onCreate(Bundle savedInstanceState)
// 确保自定义引擎初始化完成
FlutterDynamic.getInstance().ensureFlutterInit(this);
super.onCreate(savedInstanceState);
}
}

五、总结展望

目前,动态下发的方案已在美团内部 App 上线使用,Android 包瘦身效果到达 95%,iOS 包瘦身效果达到 30%+。动态下发的方案虽然能显著减少 Flutter 的包体积,但其收益是通过运行时下载的方式置换回来的。当 Flutter 业务的不断迭代增长时,Flutter 产物包也会随之不断变大,最终导致需下载的产物变大,也会对下载成功率带来压力。

未来,我们还会探索 Flutter 的分包逻辑,通过将不同的业务模块拆分来降低单个产物包的大小,来进一步保障包瘦身功能的可用性。

作者介绍

艳东,2018 年加入美团,到家平台前端工程师。

宗文,2019 年加入美团,到家平台前端高级工程师。

会超,2014 年加入美团,到家平台前端技术专家。

本文转载自公众号美团技术团队(ID:meituantech)。

原文链接

Flutter 包大小治理上的探索与实践

2020 年 9 月 27 日 10:06 975

评论

发布
暂无评论
发现更多内容

区块链3.0时代:大规模商业应用开发即将实现

CECBC区块链专委会

区块链 数字金融

架构师训练营 - 第 3 周学习总结(1 期)

阿甘

week03

……

Golang单例模式手写稿

Jacky.Chen

最完整的PyTorch数据科学家指南(2)

计算机与AI

深度学习 深度学习框架 PyTorch

区块链可以为物联网做些什么?

CECBC区块链专委会

区块链 物联网

Architecture Phase1 Week3:HomeWork

phylony-lu

架构师训练营第一期

Mongodb异常关闭,再次启动报错

MySQL从删库到跑路

mongodb

训练营 - 第三周 - 作业一

行者

第三周-代码重构-学习总结

刘希文

当区块链遇见共享经济,会碰撞出怎样的火花?

CECBC区块链专委会

区块链

架构一期第三周作业

Airs

Architecture Phase1 Week3:Design Pattern

phylony-lu

架构师训练营第一期

架构师训练营第二周作业

xs-geek

为什么Rust的println!不会发生所有权转移?

袁承兴

rust 元编程

架构师训练营第二周总结

xs-geek

架构师训练营 Week3 代码重构 - 学习总结 设计模式

spring 设计模式 JUnit

架构师训练营 -week03- 作业

大刘

架构师训练营第 1 期

【荒于嬉】common pool2 源码阅读纪要

luojiahu

源码阅读 common-pool2

Springboot 邮件任务

hepingfly

springboot 发送邮件

第三周作业

架构师训练营第 1 期

[Python3]三子棋游戏!祝大家中国71周年国庆节快乐!

MengZian

Python Python3

Week 3 命题作业及总结

阿泰

JD-GUI反编译jar包为Java源代码

MySQL从删库到跑路

jar 程序员 Spring Boot Java、 jar包的小秘密

vagrant 开发环境配置

孙志平

架构师训练营第 1 期 week3

Geek_b9c418

「架构师训练营第 1 期」

架构师训练营 - 作业 - 第三周

Max2@12

架构师训练营 - 第3周课后作业(1 期)

阿甘

极客大学 - 架构师训练营 第三周

9527

架构师训练营第三周学习总结

文智

架构师训练营第一期

分布式系统的核心:共识问题

多颗糖

分布式计算 计算机基础 分布式系统 架构师

Flutter包大小治理上的探索与实践-InfoQ