likes
comments
collection
share

Flutter 2.0 iOS包大小优化 -- 分离AOT编译产物

作者站长头像
站长
· 阅读数 3

Flutter是一款开源的移动跨平台UI开发套件,它不仅与现存的项目代码兼容,还能帮你用Dart语言快速开发高质量的跨平台App。本文结合Flutter的编译原理,探讨Flutter接入时包大小的优化方案。

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. 优化方案

除了文中所提到的三个方法以外,其实还需要一个 “控” ,就是控制包大小的增长,所以我写了一个插件,可以在Flutter与Native之间互相使用对方的资源文件,也能从一定程度上控制Flutter包大小的增长。

总的来说,iOS端需要处理的事情有:

  • 分离App.framework中的flutter_assets
  • 分离Flutter.framework中的icudtl.dat
  • 分离App.framework中的数据段产物
  • 将分离的flutter_assetsicudtl.dat、数据段打包压缩
  • 在需要时这些数据时重新解压并加载

flutter_assetsicudtl.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

Flutter 2.0 iOS包大小优化 -- 分离AOT编译产物

符号分析:

  • 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的编译产物,要了解它是怎么生成,可以看整体的时序图:

Flutter 2.0 iOS包大小优化 -- 分离AOT编译产物

这个命令做了几件事:

  1. 解析命令参数,找到对应的编译iOS framework的命令执行
  2. 获取当前的构建信息,选择构建模式
  3. 使用FlutterBuildSystem进行构建AOT产物的构建
  4. FlutterBuildSystem内将每一个编译步骤定义为一个target,所以编译就是让每一个target都执行build指令,来完成自己的编译任务
  5. build ios-framework的模式下,target为aot-assembly模式,他最终会调用GenSnapshot这个二进制可执行文件来进行aot产物的生成
  6. GenSnapshot返回了产物后,则进行打包成framework的操作,最后输出到对应的路径

genSnapshot执行run命令后,则执行了以下流程:

Flutter 2.0 iOS包大小优化 -- 分离AOT编译产物

  1. GenSnapshot执行main函数,并启动Dart虚拟机
  2. 启动了虚拟机后,将根据需要的编译产物类型选择编译所用的函数,总共有7中编译产物类型:
    • kCore
    • kCoreJIT
    • kApp
    • kAppJIT
    • kAppAOTAssembly
    • kAppAOTElf
    • kVMAOTAssembly
    iOS使用的是kAppAOTAssembly,将会调用CreateAndWritePrecompiledSnapshot方法,先进行预编译,再调用Dart_CreateAppAOTSnapshotAsAssembly生成snapshot产物
  3. 在生成snapshot产物时,会使用FullSnapshotWriter,将会先写入VMSnapshot数据,包括记录头部信息、版本信息等,然后再写入VM的数据段与指令段
  4. 随后FullSnapshotWriter将会继续写入IsolateSnapshot数据,同样会包括头部信息、版本信息等,随后再写入数据Isolate的数据段与指令段
  5. 而数据段与指令段的写入,其实是调用了AssemblyImageWriterwrite方法,它将会依次写入bss(静态数据段)、Text(指令段)、ROData(只读数据段)
  6. 当所有数据写入完毕,则返回并生成最终的snapshot编译产物,再交回给上级进一步进行打包成framework等操作。

所以我们需要从产物中分离的是ROData只读数据段。

3.3 App.xcframework 产物的加载

了解完构成与生成方式,我们也需要了解Flutter引擎是如何加载保存在App.xcframework/App中的代码段与数据段,否则分离后导致引擎无法加载数据,所谓的分离也只能是空谈了。

Flutter 2.0 iOS包大小优化 -- 分离AOT编译产物

  1. FlutterEngine初始化时需要初始化一个FlutterDartProjectFlutterDartProject需要读取默认的设置,里面的设置包括assetsPathicudataPathapplicationFrameworkPath等。
  2. FlutterEngine初始化完成后执行run指令就可以启动引擎,这时需要启动Dart虚拟机
  3. 创建Dart虚拟机时,dart_snapshot需要从一个全局的setting文件中读取VMSnapshotIsolateSnapshot的数据段与指令段位置,并形成文件映射
  4. 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.SVmSnapshotData.S文件,写入的方法实现需要与WriteBytes方法一样,如果不一致将导致产生的.S文件里面的汇编语言发生错乱。

在这里我将分离的汇编文件保存为{SNAPSHOT_SAVE_PATH}/armv7/VmSnapshotData.S,这是我定义的保存路径,可以根据自己的需要进行修改,但是一定要保证目录是存在的,否则会报找不到目录的错误,这里建议使用编译脚本配合,提前建立好对应目录,我使用的编译脚本会在下文提到。

分离了IsolateSnapshotData.SVmSnapshotData.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.datVMData.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. 参考文献