美团技术整理:Flutter包大小治理上的探索与实践

对于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来简单地介绍一下该过程:

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

3.1.2 工程化方案

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

注:图例中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的流程中新增了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:

3.1.2.2 发布集成阶段

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

自动化发布与版本管理

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

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

自动化集成

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

3.1.2.3 运行阶段

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

该过程包括:

  • 资源下载:读取工程配置文件,得到当前Flutter module的版本,并查询和下载远程资源。
  • 资源解压和校验:对下载资源进行完整性校验,校验完成则进行解压和本地缓存。
  • 启动engine:在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包大小优化方案:

打包阶段:我们在原有的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确保初始化的完成。

在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中的资源加载由一组类完成,根据数据源的不同分为了网络资源加载和本地资源加载,其类图如下:

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提供的接口实现产物下载和资源加载的逻辑。

四、方案的接入与使用

为了让大家了解上述方案使用层面的设计,我们在此把美团内部的使用方式介绍给大家,其中会涉及到一些内部工具细节我们暂不展开,重点解释设计和使用体验部分。由于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中添加字体动态加载逻辑,并替换默认资源加载器。

最后

对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长。而不成体系的学习效果低效漫长且无助。时间久了,付出巨大的时间成本和努力,没有看到应有的效果,会气馁是再正常不过的。

所以学习一定要找到最适合自己的方式,有一个思路方法,不然不止浪费时间,更可能把未来发展都一起耽误了。

如果你是卡在缺少学习资源的瓶颈上,那么刚刚好我能帮到你。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

。时间久了,付出巨大的时间成本和努力,没有看到应有的效果,会气馁是再正常不过的。

所以学习一定要找到最适合自己的方式,有一个思路方法,不然不止浪费时间,更可能把未来发展都一起耽误了。

如果你是卡在缺少学习资源的瓶颈上,那么刚刚好我能帮到你。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值