1. 现状
在我目前的项目中,Flutter使用的是产物集成的方式,即Flutter单独作为一个端进行开发,随后Android与iOS端分别接入Flutter端编译完成的产物文件,然后产物文件以组件的形式被Native工程所引入,安卓端为so文件,iOS端则使用CocoaPod
引入xcframework。
iOS Flutter包大小
Framework | 文件名称 | 大小 | 备注 |
App.xcframework | App | 7.2M | AOT Snapshot数据,大小取决于业务代码量,动态链接库 |
flutter_assets | 868KB | 图片资源、字体等 | |
Flutter.xcframework | Flutter | 7.5M | Flutter引擎,大小取决于Flutter版本,基本固定,动态链接库 |
icudtl.dat | 898KB | 国际化支持相关数据文件 | |
三方库 | plugins | 468KB | 三方插件与静态库等 |
当APP集成Flutter后,难免会导致包大小的增加,在目前,Android可以利用插件化框架,动态下发Flutter组件,所以对安装包的大小影响几乎为0。但是在iOS上,由于iOS系统的限制,可执行文件是不可以动态下发的,所以没有办法像Android一样处理。
从上面的表格可以看到,整个Flutter产物的总大小达到了16.2M,而目前项目iOS通用安装包大小只有44M,Flutter产物占了包大小36%,急需优化。
2. 优化方案
在2020年初,字节跳动技术沙龙上,分享了关于《如何缩减接近50%的Flutter包体积》,里面提到了主要有3个方法:删、缩、挪,“删”就是删除无用代码与无用资源,“缩”就是压缩图片资源等,“挪”则是将非必要的数据与资源从产物中移除,进行动态下发。
除了文中所提到的三个方法以外,其实还需要一个“控”,就是控制包大小的增长,所以我写了一个插件,可以在Flutter与Native之间互相使用对方的资源文件,也能从一定程度上控制Flutter包大小的增长。
总的来说,iOS端需要处理的事情有:
- 分离App.framework中的
flutter_assets
- 分离Flutter.framework中的
icudtl.dat
- 分离App.framework中的数据段产物
- 将分离的
flutter_assets
、icudtl.dat
、数据段打包压缩 - 在需要时这些数据时重新解压并加载
flutter_assets
与icudtl.dat
的分离比较简单,可以直接使用脚本移除即可:
# 移除flutter_assets
# $res_release 为framework产物所在的位置,通常为/{FLUTTER_ROOT}/build/ios/framework/Release
# $flutter_data 为需要被分离的产物的收集目录
mv $res_release/App.xcframework/ios-arm64_armv7/App.framework/flutter_assets $flutter_data
mv $res_release/Flutter.xcframework/ios-armv7_arm64/Flutter.framework/icudtl.dat $flutter_data
而数据段的分离相对比较复杂,而且在Flutter 2.0 中,数据段的写入方式也发生了不少改变,接下来将详细讲解如何分离AOT编译产物,以及为什么能分离。
3. App.xcframewok 产物分析
3.1 App.xcframework 的构成
在release模式下,iOS端产物为AOT(Ahead Of Time 事前编译)编译产物,类似于C++代码,需要被提前编译为特殊的二进制,才可以被加载与运行。它的核心优势是速度快,利用编译好的二进制代码,能够提高加载与执行的速度。但是二进制代码需要获得执行权限,无法在iOS系统中动态更新。
其中二进制产物的构成如下:
nm App.framework/App
...
其他符号
...
0000000000544008 b _kDartIsolateSnapshotBss
00000000002c0920 S _kDartIsolateSnapshotData
0000000000009000 T _kDartIsolateSnapshotInstructions
0000000000544000 b _kDartVmSnapshotBss
00000000002b8720 S _kDartVmSnapshotData
0000000000005000 T _kDartVmSnapshotInstructions
U dyld_stub_binder
符号分析:
-
kDartIsolateSnapshotBss
:isolate静态变量数据段 -
kDartIsolateSnapshotData
:isolate数据段,包含了每个isolate运行所需的数据 -
kDartIsolateSnapshotInstructions
:isolate指令段,包含了每个isolate运行所需的代码指令 -
kDartVmSnapshotBss
:Dart虚拟机静态变量数据段 -
kDartVmSnapshotData
:为Dart虚拟机运行所需的数据 -
kDartVmSnapshotInstructions
:Dart虚拟机运行所需的代码指令
其中bss
相关的数据段由于为静态变量,所以无法动态下发,而instructions
相关指令段由于iOS系统限制不能随意标记指令的可执行状态,所以也无法动态下发,只有Data
相关的数据段能够在加载时不受限制,可以被分离。
3.2 App.xcframework 的生成
在分离数据段之前,我们应该了解一下这个文件是如何生成的,从而找到分离的方法。生成App.xcframework需要执行指令:
flutter build ios-framework --release --no-debug --no-profile
就可以生成出release模式下AOT的编译产物,要了解它是怎么生成,可以看整体的时序图:
这个命令做了几件事:
- 解析命令参数,找到对应的编译iOS framework的命令执行
- 获取当前的构建信息,选择构建模式
- 使用
FlutterBuildSystem
进行构建AOT产物的构建 -
FlutterBuildSystem
内将每一个编译步骤定义为一个target
,所以编译就是让每一个target
都执行build
指令,来完成自己的编译任务 - 在
build ios-framework
的模式下,target
为aot-assembly模式,他最终会调用GenSnapshot
这个二进制可执行文件来进行aot产物的生成 - 当
GenSnapshot
返回了产物后,则进行打包成framework的操作,最后输出到对应的路径
而genSnapshot
执行run
命令后,则执行了以下流程:
-
GenSnapshot
执行main
函数,并启动Dart虚拟机 - 启动了虚拟机后,将根据需要的编译产物类型选择编译所用的函数,总共有7中编译产物类型:
- kCore
- kCoreJIT
- kApp
- kAppJIT
- kAppAOTAssembly
- kAppAOTElf
- kVMAOTAssembly
iOS使用的是
kAppAOTAssembly
,将会调用CreateAndWritePrecompiledSnapshot
方法,先进行预编译,再调用Dart_CreateAppAOTSnapshotAsAssembly
生成snapshot产物 - kCore
- 在生成snapshot产物时,会使用
FullSnapshotWriter
,将会先写入VMSnapshot数据,包括记录头部信息、版本信息等,然后再写入VM的数据段与指令段 - 随后
FullSnapshotWriter
将会继续写入IsolateSnapshot数据,同样会包括头部信息、版本信息等,随后再写入数据Isolate的数据段与指令段 - 而数据段与指令段的写入,其实是调用了
AssemblyImageWriter
的write
方法,它将会依次写入bss
(静态数据段)、Text
(指令段)、ROData
(只读数据段) - 当所有数据写入完毕,则返回并生成最终的snapshot编译产物,再交回给上级进一步进行打包成framework等操作。
所以我们需要从产物中分离的是ROData
只读数据段。
3.3 App.xcframework 产物的加载
了解完构成与生成方式,我们也需要了解Flutter引擎是如何加载保存在App.xcframework/App中的代码段与数据段,否则分离后导致引擎无法加载数据,所谓的分离也只能是空谈了。
-
FlutterEngine
初始化时需要初始化一个FlutterDartProject
,FlutterDartProject
需要读取默认的设置,里面的设置包括assetsPath
、icudataPath
、applicationFrameworkPath
等。 -
FlutterEngine
初始化完成后执行run
指令就可以启动引擎,这时需要启动Dart虚拟机 - 创建Dart虚拟机时,
dart_snapshot
需要从一个全局的setting文件中读取VMSnapshot
与IsolateSnapshot
的数据段与指令段位置,并形成文件映射 - Dart虚拟机创建完成后才能正式启动Flutter引擎。
所以Dart虚拟机创建时依然有机会重定向数据段的位置,这就为产物的分离与正确加载提供了可能性,接下来就开始分离产物。
4. App.xcframework 产物分离
4.1 Flutter引擎编译
genSnapshot
对应执行方法的源码为flutter_engine/third_party/dart/runtime/bin/gen_snapshot.cc
,所以我们如果要修改genSnapshot
的产物,这就意味着我们需要修改Flutter引擎的源码。
Flutter引擎的编译环境配置与编译步骤,可以在Flutter的github wiki页面查看到。
编译引擎时需要把模拟器、arm64、armv7的架构全部完成编译,因为在Flutter2.0中使用的是xcframework,里面会同时包含了模拟器架构,所以不编译模拟器架构是没有办法flutter build
成功,以下是我使用的编译脚本:
ps: 如果不想因为引擎的修改影响现在使用的FlutterSDK,可以使用FVM(flutter version manager),对Flutter进行版本管理。
ios_flutter_engine.sh
# 编译模拟器架构
echo "start complie iOS debug simulator flutter engine"
./flutter/tools/gn --runtime-mode debug --simulator
./flutter/tools/gn --ios --runtime-mode debug --simulator
ninja -C out/ios_debug_sim -j 20
# 编译引擎十分消耗内存,这里我使用的是mac pro进行编译,所以设置成了20
# 可以根据自己电脑的性能调整为10或5左右
echo "simulator flutter engine complied succeed"
# 编译arm64架构
echo "start complie iOS release arm64 flutter engine"
./flutter/tools/gn --ios --runtime-mode release --ios-cpu arm64
ninja -C out/ios_release -j 20
echo "arm64 flutter engine complied succeed"
# 编译armv7架构
echo "start complie iOS release armv7 flutter engine"
./flutter/tools/gn --ios --runtime-mode release --ios-cpu arm
ninja -C out/ios_release_arm -j 20
echo "armv7 flutter engine complied succeed"
# 合并armv7和arm64架构
echo "merge framework"
rm -rf tmp/*
cp -rf out/ios_release/Flutter.framework tmp/
lipo -create -output tmp/Flutter.framework/Flutter \
out/ios_release/Flutter.framework/Flutter \
out/ios_release_arm/Flutter.framework/Flutter
# 将编译后的 gen_snapshot 文件替换现有的 FlutterSDK 使用的 gen_snapshot
# ${FLUTTER_PATH} 为 FlutterSDK 所在的位置
cp -rf tmp/Flutter.framework "${FLUTTER_PATH}"/bin/cache/artifacts/engine/ios-release/Flutter.xcframework/ios-armv7_arm64/
cp -rf out/ios_debug_sim/Flutter.framework "${FLUTTER_PATH}"/bin/cache/artifacts/engine/ios-release/Flutter.xcframework/ios-x86_64-simulator/
cp -f out/ios_release/clang_x64/gen_snapshot "${FLUTTER_PATH}"/bin/cache/artifacts/engine/ios-release/gen_snapshot_arm64
cp -f out/ios_release_arm/clang_x64/gen_snapshot "${FLUTTER_PATH}"/bin/cache/artifacts/engine/ios-release/gen_snapshot_armv7
echo "complie end"
另外flutter引擎默认不支持bitcode,如果需要开启bitcode,需要添加--bitcode
指令
./flutter/tools/gn --ios --runtime-mode release --ios-cpu arm --bitcode
编译成功后,就会将编译完成的gen_snapshot替换到指定FlutterSDK的引擎。
4.2 分离 Snapshot ROData
能成功编译Flutter引擎后,就可以开始着手修改gen_snapshot的编译步骤,进行产物数据段的分离了,上文分析到是在AssemblyImageWriter
里的writeROData
的方法实现了写入数据段,所以我们需要在这个方法中重定向数据段的写入:
// file: image_snapshot.cc
// AssemblyImageWriter 写入ROData的方法
void AssemblyImageWriter::WriteROData(NonStreamingWriteStream* clustered_stream,
bool vm) {
ImageWriter::WriteROData(clustered_stream, vm);
if (!EnterSection(ProgramSection::Data, vm, ImageWriter::kRODataAlignment)) {
return;
}
#if defined(TARGET_OS_MACOS_IOS)
WriteRODataToLocalFile(clustered_stream, vm); // ios下写到外部文件中, WriteRODataToLocalFile为将ROData写入到文件的函数,下文将会有具体实现
#else
WriteBytes(clustered_stream->buffer(), clustered_stream->bytes_written());
#endif // TARGET_OS_MACOS_IOS
ExitSection(ProgramSection::Data, vm, clustered_stream->bytes_written());
}
这样在iOS下,AssemblyImageWriter
将不会调用WriteBytes
方法,将数据段写入到assembly_stream
中,并最后保存到snapshot中,而是调用WriteRODataToLocalFile
方法,在里面我们需要将其重定向,写入到文件中并输出。
// file: image_snapshot.cc
#include "bin/file.h"
#include <iostream>
void WriteRODataToLocalFile(NonStreamingWriteStream* clustered_stream, bool vm) {
#if defined(TARGET_OS_MACOS_IOS)
auto OpenFile = [](const char* filename) {
bin::File* file = bin::File::Open(NULL, filename, bin::File::kWriteTruncate);
if (file == NULL) {
Syslog::PrintErr("Error: Unable to open file: %s\n", filename);
Dart_ExitScope();
Dart_ShutdownIsolate();
exit(255);
}
return file;
};
auto StreamingWriteCallback = [](void* callback_data,
const uint8_t* buffer,
intptr_t size) {
bin::File* file = reinterpret_cast < bin::File* >(callback_data);
if (!file->WriteFully(buffer, size)) {
Syslog::PrintErr("Error: Unable to write snapshot file\n");
Dart_ExitScope();
Dart_ShutdownIsolate();
exit(255);
}
};
// ARM64 架构
#if defined(TARGET_ARCH_ARM64)
// 定义自己需要的输出路径
bin::File *file = OpenFile(vm ? "{SNAPSHOT_SAVE_PATH}/arm64/VmSnapshotData.S" : "./SnapshotData/arm64/IsolateSnapshotData.S");
#else
// ARMV7 架构
// 定义自己需要的输出路径
bin::File *file = OpenFile(vm ? "{SNAPSHOT_SAVE_PATH}/armv7/VmSnapshotData.S" : "./SnapshotData/armv7/IsolateSnapshotData.S");
#endif //end of TARGET_ARCH_ARM64
bin::RefCntReleaseScope rs(file);
StreamingWriteStream stream = StreamingWriteStream(512 * KB, StreamingWriteCallback, file);
intptr_t length = clustered_stream->bytes_written(); // 获取数据段长度
auto const start = reinterpret_cast<const uint8_t*>(clustered_stream->buffer()); // 获取数据段起始位置
auto const end = start + length; // 获取数据段终止位置
auto const end_of_words = start + Utils::RoundDown(length, compiler::target::kWordSize); // 获取数据段最后一个word的终止位置
// 枚举并写入每一个word
// 这里等于 ‘AssemblyImageWritter::WriteBytes’方法,这个方法是将数据段写入assembly_stream中
// 这里则是写入文件stream中,两个方法的实现需要保持一致
for (auto cursor = reinterpret_cast<const compiler::target::word*>(start);
cursor < reinterpret_cast<const compiler::target::word*>(end_of_words);
cursor++) {
word value = *cursor;
stream.Printf("%s 0x%.*" Px "\n", kWordDirective,
2 * compiler::target::kWordSize, value);
// 根据架构写入不同的指令,64位时为‘.quad xxxxx’,32位时为'.long xxxxx'
}
if (end != end_of_words) {
// 写入结尾
stream.Printf("%s", kSizeDirectives[kInt8SizeLog2]);
for (auto cursor = end_of_words;
cursor < end; cursor++) {
stream.Printf("%s 0x%.2x", cursor != end_of_words ? "," : "",
*cursor);
}
stream.Printf("\n");
}
#endif //end of TARGET_OS_MACOS_IOS
}
在WriteRODataToLocalFile
方法中,将文件写入到了StreamingWriteStream
中,并区分架构,最终生成IsolateSnapshotData.S
和VmSnapshotData.S
文件,写入的方法实现需要与WriteBytes
方法一样,如果不一致将导致产生的.S
文件里面的汇编语言发生错乱。
在这里我将分离的汇编文件保存为{SNAPSHOT_SAVE_PATH}/armv7/VmSnapshotData.S
,这是我定义的保存路径,可以根据自己的需要进行修改,但是一定要保证目录是存在的,否则会报找不到目录的错误,这里建议使用编译脚本配合,提前建立好对应目录,我使用的编译脚本会在下文提到。
分离了IsolateSnapshotData.S
和VmSnapshotData.S
文件后,它们只是汇编语言,并不能被 Flutter 直接使用,需要重新将他们编译成机器码,还原成之前在App.framework/App中的二进制形式:
echo ">>>>>> Compile SnapshotData"
# armv7
xcrun cc -arch armv7 -c $armv7/IsolateSnapshotData.S -o $armv7/HeadIsolateData.dat
xcrun cc -arch armv7 -c $armv7/VmSnapshotData.S -o $armv7/HeadVMData.dat
# 去除多余头部
tail -c +313 $armv7/HeadIsolateData.dat > $armv7/IsolateData.dat
tail -c +313 $armv7/HeadVMData.dat > $armv7/VMData.dat
编译成二进制后,会多出头部数据,需要利用tail
指令去掉,否则接下来加载时将无法识别,最终生成的IsolateData.dat
与VMData.dat
才是我们想要的数据段。
4.3 重新加载数据段
现在我们已经将数据段分离了,那我们要怎么加载这些被分离的数据段呢?上文提到dart_snapshot
需要从setting文件中获取数据段路径,所以我们需要在setting文件中添加新的属性,用来存放我们分离数据段的路径:
[flutter_engine/src/flutter/common/setting.h]
// file: setting.h
// snapshot data path
std::string ios_vm_snapshot_data_path; // vm data path
std::string ios_isolate_snapshot_data_path; // isolate data path
然后我们需要在dart_snapshot
加载时使用我们分离数据段的路径进行加载:
[flutter_engine/src/flutter/runtime/dart_snapshot.cc]
// file: dart_snapshot.cc
// 根据分离数据的路径,完成数据段重建
std::shared_ptr<const fml::Mapping> SnapshotDataMapping(const std::string &path) {
auto fd = fml::OpenFile(path.c_str(), false, fml::FilePermission::kRead);
if (!fd.is_valid()) {
auto directory = fml::paths::GetExecutableDirectoryPath();
if (!directory.first) {
return nullptr;
}
std::string path_to_executable = fml::paths::JoinPaths({directory.second, path});
fd = fml::OpenFile(path_to_executable.c_str(), false, fml::FilePermission::kRead);
}
if (!fd.is_valid()) {
return nullptr;
}
// 加载成功
std::initializer_list<fml::FileMapping::Protection> protection = {fml::FileMapping::Protection::kRead};
// 映射文件
auto file_mapping = std::make_unique<fml::FileMapping>(fd, std::move(protection));
if (file_mapping->GetSize() != 0) {
return file_mapping;
}
return nullptr;
}
// 处理VMSnapshot数据段的重建
static std::shared_ptr<const fml::Mapping> ResolveVMData(
const Settings& settings) {
#if DART_SNAPSHOT_STATIC_LINK
return std::make_unique<fml::NonOwnedMapping>(kDartVmSnapshotData, 0);
#else // DART_SNAPSHOT_STATIC_LINK
#if OS_IOS
if (settings.ios_vm_snapshot_data_path.empty()) {
return SearchMapping(
settings.vm_snapshot_data,
settings.vm_snapshot_data_path,
settings.application_library_path,
DartSnapshot::kVMDataSymbol,
false
);
} else {
return SnapshotDataMapping(settings.ios_vm_snapshot_data_path); // setting文件中传入的分离的vm数据段路径
}
#else
// 原重建方法
return SearchMapping(
settings.vm_snapshot_data, // embedder_mapping_callback
settings.vm_snapshot_data_path, // file_path
settings.application_library_path, // native_library_path
DartSnapshot::kVMDataSymbol, // native_library_symbol_name
false // is_executable
);
#endif // OS_IOS
#endif // DART_SNAPSHOT_STATIC_LINK
}
// 处理IsolateSnapshot数据段的重建
static std::shared_ptr<const fml::Mapping> ResolveIsolateData(
const Settings& settings) {
#if DART_SNAPSHOT_STATIC_LINK
return std::make_unique<fml::NonOwnedMapping>(kDartIsolateSnapshotData, 0);
#else // DART_SNAPSHOT_STATIC_LINK
#if OS_IOS
if (settings.ios_isolate_snapshot_data_path.empty()) {
return SearchMapping(
settings.isolate_snapshot_data, // embedder_mapping_callback
settings.isolate_snapshot_data_path, // file_path
settings.application_library_path, // native_library_path
DartSnapshot::kIsolateDataSymbol, // native_library_symbol_name
false // is_executable
);
} else {
return SnapshotDataMapping(settings.ios_isolate_snapshot_data_path); // setting文件中传入的分离的isolate数据段路径
}
#else
// 原重建方法
return SearchMapping(
settings.isolate_snapshot_data, // embedder_mapping_callback
settings.isolate_snapshot_data_path, // file_path
settings.application_library_path, // native_library_path
DartSnapshot::kIsolateDataSymbol, // native_library_symbol_name
false // is_executable
);
#endif // OS_IOS
#endif // DART_SNAPSHOT_STATIC_LINK
}
在上面的代码中,我们从新定义了一个SnapshotDataMapping
方法,他会从给定的路径中打开文件,并完成数据段的重建,所以在iOS环境下,我们使用ResolveVMData
和ResolveIsolateData
使用这个重建方法,代替原来的重建方法,达到了使用我们分离的数据段的目的。
最后我们需要在最上层暴露接口,让外部能够传入数据段的路径,所以我们在FlutterDartProject
中添加了新的初始化方法,并新增一个FlutterSettingModel
类,用来传入被分离的数据段、assets、icudat.dat的路径:
[flutter_engine/src/flutter/shell/platform/darwin/ios/framework/Headers/FlutterDartProject.h]
// 新增FlutterSettingModel类,用来设置数据段以及icudata、assets的路径
FLUTTER_DARWIN_EXPORT
@interface FlutterSettingModel: NSObject
@property (nonatomic, copy, nullable) NSString *assetsPath; // assets路径
@property (nonatomic, copy, nullable) NSString *icuDataPath; // icudat.dat文件路径
@property (nonatomic, copy, nullable) NSString *vmDataPath; // vm数据段路径
@property (nonatomic, copy, nullable) NSString *isolateDataPath; // isolate数据段路径
@end
@interface FlutterDartPrject : NSObject
...
/**
* 新增一个初始化方法,传入FlutterSettingModel,以便提供数据段等数据的路径
* @param settingModel 设置数据,可以带有被分离的数据段以及资源文件等的路径
*/
- (instancetype)initWithPrecompiledDartBundle:(nullable NSBundle *)bundle flutterSetting:(FlutterSettingModel *)settingModel;
...
@end
同时添加FlutterSettingModel
的实现,并为FlutterDartProject
补充新添加的初始化方法的实现:
[flutter_engine/src/flutter/shell/platform/darwin/ios/framework/SourceFlutterDartProject.mm]
@implementation FlutterSettingModel
@end
@implementation FlutterDartProject
...
- (instancetype)initWithPrecompiledDartBundle:(nullable NSBundle *)bundle flutterSetting:(FlutterSettingModel *)settingModel {
if (self = [self initWithPrecompiledDartBundle:bundle]) {
#if (FLUTTER_RUNTIME_MODE != FLUTTER_RUNTIME_MODE_DEBUG) // debug模式下不使用
if (settingModel.vmDataPath.length > 0) {
_settings.ios_vm_snapshot_data_path = settingModel.vmDataPath.UTF8String;
}
if (settingModel.isolateDataPath.length > 0) {
_settings.ios_isolate_snapshot_data_path = settingModel.isolateDataPath.UTF8String;
}
if (settingModel.assetsPath.length > 0) {
_settings.assets_path = settingModel.assetsPath.UTF8String;
}
if (settingModel.icuDataPath.length > 0) {
_settings.icu_data_path = settingModel.icuDataPath.UTF8String;
}
#endif // FLUTTER_RUNTIME_MODE
}
return self;
}
这里我添加了DEBUG模式下不设置setting的相关路径,是因为在DEBUG模式下,打出来的编译产物是JIT模式,app.xcframework中并没有包含相关数据,所以无法使用。这样保证了在DEBUG模式下打出来的包依旧是可用的,仅会在RELEASE模式下进行AOT产物分离。
最终所有的修改完成,执行编译引擎脚本,替换flutter使用的引擎即可。
4.4 Flutter产物编译脚本
在上面的修改中,我们分离了数据段到特定的路径,也希望将assets与icudat.dat从产物中抽离,与我们分离的数据段共同存放到一起,并可以上传到云端或本地压缩,所以我们需要一个统一的编译脚本为我们完成这些事务:
首先是方法定义:
# 更新podspec版本号
updatePodsepcVersion(){
podPath=${1}
while read -r line
do
if [[ "$line" =~ .version ]]; then
array=(${line//"'"/ })
index=`expr ${#array[@]} - 1`
lastVersion=${array[$index]}
echo "${line} index=${index} lastVersion=${lastVersion}"
current_version=$(echo ${lastVersion} | awk -F. -v OFS=. 'NF==1{print ++$NF}; NF>1{if(length($NF+1)>length($NF))$(NF-1)++; $NF=sprintf("%0*d", length($NF), ($NF+1)%(10^length($NF))); print}')
echo "current_version = ${current_version}"
gsed -i "s/${line}/s.version = '${current_version}'/g" $podPath
fi
done < $podPath
}
# 收集所有plugin,并为产物瘦身
pluginCollect(){
res_plugins_build=${1}
target_plugins=${2}
files=$(ls $res_plugins_build)
for filename in $files; do
sourcePath=$res_plugins_build/${filename}
targetPath=$target_plugins/${filename}
frameworkName=$(echo ${filename//.xcframework/})
if [ -e $sourcePath ]; then
if [ -e $sourcePath ]; then
if [ $frameworkName != "App" -a $frameworkName != "Flutter" ]; then
cp -rf $sourcePath $targetPath
xcrunBitcode_strip $targetPath/ios-arm64_armv7/${frameworkName}.framework $frameworkName
fi
fi
fi
done
}
# 产物瘦身,仅使用arm64架构(因为我们项目只支持arm64),并移除bitcode
xcrunBitcode_strip() {
framework_path=${1}
framework_name=${2}
cd ${framework_path}
lipo ${framework_name} -thin arm64 -output ${framework_name}
rm -rf arm64
xcrun bitcode_strip -r ${framework_name} -o ${framework_name}
}
# 压缩分离的数据段等数据
packUpROData() {
echo ">>>>>> zip snapshotData"
zip -q -r $target_dir/FlutterSnapshot.zip $flutter_reduce
mv $target_dir/FlutterSnapshot.zip $target_dir/FlutterSnapshot
}
# 将分离的数据段等数据上传到服务器
uploadROData() {
echo ">>>>>> Will upload snapshotData"
# 这里按照自己的需要上传即可
}
这里定义了几个方法:
-
updatePodsepcVersion
:由于产物使用CocoaPods集成,所以每次执行编译脚本后都要更新产物的podspec版本号,以便能够pod update
-
pluginCollect
:这个方法用来把除了App.xcframework和Flutter.xcframework以外的framework收集到plugins文件夹,并进行瘦身 -
xcrunBitcode_strip
:这个方法让产物只使用arm64的架构,因为我们项目目前只支持arm64,这里可以根据自己需要进行修改,最后会执行bitcode_strip
,移除bitcode -
packUpROData
:这个方法用来执行压缩方法,将所有需要分离的数据进行压缩 -
uploadROData
:这个方法会把压缩后的zip文件上传到服务器,以便下载使用,这里实现可以根据自己的需要去实现
然后是整体的编译流程:
# 开始构建 Flutter iOS
# 我使用了fvm进行版本管理
fvm use 2.0.3
fvm flutter --version
# 解除 flutter 构建锁
lockFile="$FLUTTER_HOME/cache/lockfile"
if [[ -a "$lockFile" ]]; then
echo ">>>>>> Contains Lockfile !!";
rm -f "$lockFile"
fi
fvm flutter clean
fvm flutter packages get
# 删除历史编译产物
rm -rf build
# 各种目录位置的定义
res_application=$PWD
res_build=$PWD/build
res_dir=$PWD/.ios/Flutter
res_flutter_plugins=$PWD/.flutter-plugins
res_source_dir=$res_dir/FlutterPluginRegistrant/Classes
res_release=$res_build/ios/framework/Release
target_dir=$PWD/LPEDU_Flutter_iOS
target_release_dir=$target_dir/Flutter_Release
target_release_plugins_dir=$target_release_dir/Plugins
target_podspec=$target_dir/LPEDU_Flutter_iOS.podspec
target_branch=master
# 创建分离数据段的目录位置
armv7=./SnapshotData/armv7
arm64=./SnapshotData/arm64
flutter_reduce=./SnapshotData/flutter_reduce
mkdir -p $armv7
mkdir -p $arm64
mkdir -p $flutter_reduce
if [ -d "$target_release_dir" ]; then
rm -rf $target_release_dir
fi
mkdir -p $target_release_dir
cd $res_application
# 执行构建命令
fvm flutter build ios-framework --release --no-debug --no-profile
# 编译分离的数据段为可用的二进制
xcrun cc -arch armv7 -c $armv7/IsolateSnapshotData.S -o $armv7/HeadIsolateData.dat
xcrun cc -arch armv7 -c $armv7/VmSnapshotData.S -o $armv7/HeadVMData.dat
# 去除多余头部
tail -c +313 $armv7/HeadIsolateData.dat > $armv7/IsolateData.dat
tail -c +313 $armv7/HeadVMData.dat > $armv7/VMData.dat
xcrun cc -arch arm64 -c $arm64/IsolateSnapshotData.S -o $arm64/HeadIsolateData.dat
xcrun cc -arch arm64 -c $arm64/VmSnapshotData.S -o $arm64/HeadVMData.dat
tail -c +313 $arm64/HeadIsolateData.dat > $arm64/IsolateData.dat
tail -c +313 $arm64/HeadVMData.dat > $arm64/VMData.dat
mkdir -p $flutter_reduce/arm64
# mkdir -p $flutter_reduce/armv7
# 不使用armv7的snasphotData
# cp -rf $armv7/IsolateData.dat $flutter_reduce/armv7
# cp -rf $armv7/VMData.dat $flutter_reduce/armv7
cp -rf $arm64/IsolateData.dat $flutter_reduce/arm64
cp -rf $arm64/VMData.dat $flutter_reduce/arm64
# 分离assets文件夹以及icudat.dat,也放到flutter_reduce目录下
mv $res_release/App.xcframework/ios-arm64_armv7/App.framework/flutter_assets $flutter_reduce
mv $res_release/Flutter.xcframework/ios-armv7_arm64/Flutter.framework/icudtl.dat $flutter_reduce
# 定义当前版本号,会随着updatePodsepcVersion方法进行更新
current_version='0.0.1'
cd $target_dir
cp -rf $res_release/App.xcframework $target_release_dir/App.xcframework
cp -rf $res_release/Flutter.xcframework $target_release_dir/Flutter.xcframework
if [ -d "$target_release_plugins_dir" ]; then
rm -rf $target_release_plugins_dir
fi
mkdir -p $target_release_plugins_dir
# 收集plugin产物,并更新产物podspec
pluginCollect $res_release $target_release_plugins_dir
updatePodsepcVersion $target_podspec
xcrunBitcode_strip $target_release_dir/Flutter.xcframework/ios-armv7_arm64/Flutter.framework Flutter
xcrunBitcode_strip $target_release_dir/App.xcframework/ios-arm64_armv7/App.framework App
# 移除符号表
xcrun dsymutil -o $target_release_dir/Flutter.xcframework/ios-armv7_arm64/Flutter.framework.DSYM
xcrun strip -x -S $target_release_dir/Flutter.xcframework/ios-armv7_arm64/Flutter.framework/flutter
rm -rf $target_dir/FlutterSnapshot.bundle
mkdir -p $target_dir/FlutterSnapshot
cd $res_application
# 压缩数据
packUpROData
# 上传数据
uploadROData $current_version
# 创建FlutterSnapshot.bundle,用于存放分离的数据的zip文件
mv $target_dir/FlutterSnapshot $target_dir/FlutterSnapshot.bundle
rm -rf ./SnapshotData
lastCommit="version $current_version"
# git 上提交产物
cd $target_dir
git add .
git commit -m "$lastCommit"
git push origin ${target_branch}
cd -
# 清空工作区
if [ -d "$target_dir" ]; then
rm -rf $target_dir
fi
整个编译脚本比较长,主要有几个要点:
- 使用了fvm进行Flutter的版本管理,所以所有
flutter
命令前都带有了fvm
命令,可以根据自己实际情况去掉 - 创建了分离数据段的目录位置,这里一定要和
image_snapshot.cc
文件中定义的路径一致,否则会出现找不到目录的错误 - 将assets与icudat.dat也从产物中分离,并一同放到分离数据段所在目录,随后一同打包压缩
-
分离的数据可以有两种加载方式,可以根据项目Flutter的情况去选择使用哪种方法:
- 本地压缩包形式:随着产物一同下发,本地解压加载
- 云端压缩包形式:不随着产物下发,云端下载后才解压加载
- 更新产物的podspec,最后push到git仓库
- 由于使用了bundle去保存压缩后的数据,而且产物为xcframework形式,所以Flutter产物podspec应该添加如下语句,以确保能够正确把xcframework与bundle文件引入:
s.resource = "FlutterSnapshot.bundle"
s.vendored_frameworks = 'Flutter_Release/**/*.{xcframework}'
4.5 iOS端加载并使用分离数据段
最后需要在iOS端正确初始化引擎,并使用我们的分离产物,这里以使用本地压缩的分离产物为例:
获取压缩包路径,并解压
NSURL *flutterBundleURL = [[NSBundle mainBundle] URLForResource:@"FlutterSnapshot" withExtension:@"bundle"];
if (flutterBundleURL) {
NSBundle *flutterBundle = [NSBundle bundleWithURL:flutterBundleURL];
self.bundleSnapshotPath = [flutterBundle pathForResource:@"FlutterSnapshot" ofType:@"zip"];
NSFileManager *fileManager = [NSFileManager defaultManager];
NSURL *documentURL = [fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
self.snapshotSavePath = [documentURL URLByAppendingPathComponent:[NSString stringWithFormat:@"Flutter"]].relativePath;
if (![fileManager fileExistsAtPath:self.snapshotSavePath]) {
[fileManager createDirectoryAtPath:self.snapshotSavePath withIntermediateDirectories:YES attributes:nil error:nil];
}
if (self.bundleSnapshotPath && [fileManager fileExistsAtPath:self.bundleSnapshotPath]) { // 使用压缩包
[SSZipArchive unzipFileAtPath:self.bundleSnapshotPath toDestination:self.snapshotSavePath delegate:self];
}
}
根据解压路径创建settingModel
#pragma mark - SSZipArchiveDelegate
- (void)zipArchiveDidUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo unzippedPath:(NSString *)unzippedPath
{
[self _setupFlutterWithSettingModel:[self _settingModelWithPath:unzippedPath]];
}
- (nullable FlutterSettingModel *)_settingModelWithPath:(NSString *)path
{
NSFileManager *fileManager = [NSFileManager defaultManager];
if ([fileManager fileExistsAtPath:path]) {
FlutterSettingModel *settingModel = [FlutterSettingModel new];
if defined(__arm64__)
settingModel.vmDataPath = [NSString stringWithFormat:@"%@/SnapshotData/flutter_reduce/arm64/VMData.dat", path];
settingModel.isolateDataPath = [NSString stringWithFormat:@"%@/SnapshotData/flutter_reduce/arm64/IsolateData.dat", path];
#else
return nil;
#endif
settingModel.assetsPath = [NSString stringWithFormat:@"%@/SnapshotData/flutter_reduce/flutter_assets", path];
settingModel.icuDataPath = [NSString stringWithFormat:@"%@/SnapshotData/flutter_reduce/icudtl.dat", path];
return settingModel;
}
return nil;
}
使用FlutterSettingModel启动引擎
FlutterDartProject *project = [[FlutterDartProject alloc] initWithPrecompiledDartBundle:nil flutterSetting:settingModel];
self.flutterEngine = [[FlutterEngine alloc] initWithName:@"flutter-engine" project:project];
至此Flutter产物分离的加载步骤完结
5. 总结
通过分离编译产物数据段的方法,在iOS上能够实现Flutter包大小的有效减少:
名称 | 原大小 | 优化后 | 备注 |
App.xcframework | 7.2M | 3.7M | 分离数据段、assest文件夹 |
Flutter.xcframework | 8.5M | 7.6M | 移除符号表、分离icudat.dat |
Flutter总体大小 | 16.2M | 11.8M(云端下发数据段) 13.5M(本地压缩数据段) |
最终的结果是比较令人满意的,云端下发数据段能够带来约 27% 的收益,本地压缩数据段也能带来约 17% 的收益。
6. 参考文献
- flutter-platform-image
- Setting up the Engine development environment
- Introduction to Dart VM
-
[Flutter 沙龙回顾 如何缩减接近 50% 的 Flutter 包体积](https://juejin.cn/post/6844904078154137608) - 手把手教你分离flutter ios 编译产物–附工具
- Flutter机器码生成gen_snapshot