【AICon】探索八个行业创新案例,教你在教育、金融、医疗、法律等领域实践大模型技术! >>> 了解详情
写点什么

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

  • 2020-09-27
  • 本文字数:12448 字

    阅读完需:约 41 分钟

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.  BuildAppelse  case $1 in    "build")      BuildApp ;;    "buildWithoutData")      BuildAppWithoutData ;;    "thin")      ThinAppFrameworks ;;    "embed")      EmbedFlutterFrameworks ;;  esacfi
复制代码


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-09-27 10:062025

评论

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

2022第13周-技术分享记事

李印

随笔 工作经验

软件设计模式:桥接模式

正向成长

设计模式 桥接模式

TensorFlow的常用函数

Peter

人工智能 深度学习 tensorflow

浅析基于Linux下的调度类分析(代码演示)

简说Linux内核

内存管理 Linux Kenel Linux内核 进程管理 嵌入式开发

从单机定时到多层分发

程序员小航

Java 定时任务 XXL-JOB

一文简述:云原生应用十二要素

穿过生命散发芬芳

4月月更

一文读完吴恩达-Machine Learning Yearning

AIWeker

人工智能 机器学习 吴恩达

编程新手如何提高编程能力?

Jackpop

在线时间戳计算时间差

入门小站

工具

C++内存池的简单原理及实现(纯代码解析)

简说Linux内核

Linux服务器开发 Linux内核 驱动开发 嵌入式开发

深度学习实战:基于卷积神经网络的猫狗识别

Peter

人工智能 机器学习 深度学习

jackson学习之四:WRAP_ROOT_VALUE(root对象)

程序员欣宸

4月月更

AI大咖说-如何有效的读论文

AIWeker

人工智能 论文阅读 李沐

内存是什么?一文搞懂内存是怎么实现的

简说Linux内核

内存管理 Linux内核 嵌入式开发 设备驱动

茴字有四种写法,HTAP呢?

MatrixOrigin

数据库 MatrixOrigin MatrixOne 矩阵起源 超融合数据库

MongoDB的原理、基本使用、集群和分片集群

神农写代码

jupyter notebook更换皮肤

Peter

数据分析 工具 jupyter

DIKW金字塔,AI爬到第几层了?

脑极体

Python 中有什么不容易让人察觉的有趣的事实?

Jackpop

每位互联网人才都应该明白怎么通过XSS获取cookie

喀拉峻

网络安全 安全 信息安全

框架中的自定义网关

Rubble

4月日更

微信朋友圈的高性能复杂度

锎心😌😌😌

mass哈希娱乐游戏Dapp开发搭建

薇電13242772558

区块链

数据库的物理存储系统

Joseph295

数据库 数据库系统

在线SQL美化格式化工具

入门小站

工具

[Day10]-[动态规划]最长回文子序列

方勇(gopher)

LeetCode 动态规划 数据结构算法

Go 实现 WebSockets:2. 如何在 Go 中创建 WebSockets 应用程序

宇宙之一粟

Go 语言 web socket 4月月更

8000字长文图解String,这次彻底搞懂了

Jackpop

微信朋友圈的高性能复杂度分析

高山觅流水

「架构实战营」

python中self与__init__怎么解释能让小白弄懂?

Jackpop

linux之tree命令

入门小站

Flutter包大小治理上的探索与实践_移动_艳东_InfoQ精选文章