Flutter - iOS编译加速

1,918 阅读7分钟

欢迎关注微信公众号:FSA全栈行动 👋

一、前言

在项目完全重构成纯 Flutter 之后 ,iOS 端在 i7 Mac Mini 构建机上的打包时间差不多在 12分钟 左右,而在升级了 Xcode 16 之后,构建机的打包时间有了质的 “提升”,来到了 25分钟,换成 M1 来了也压不住,甚至更久~

这种情况在退回 Xcode 15 是可以解决的,但是这并不是长久之计,因为苹果早晚会强制要求升级的,好在申请了台 M4 Mac Mini 来打包,时间来到了 15 分钟,不过随着业务功能不断迭代,构建时间也慢慢增加,目前来到了 17、18分钟,但一旦哪天对 M4 构建机进行维护,让 i7M1 顶上时,再加上多个打包任务并行,完成打包的时间动不动就得 半小时 起步,真的很令人绝望~

这里先给出优化后的打包时间

构建机优化前(min)优化后(min)
i725+14+
M416+8+
  • 优化前 : Profile + 源码依赖 原生插件
  • 优化后 : Release + 二进制依赖 原生插件
  • 环境 : Xcode 16

二、编译模式对比

这里我拿了一个业务组件来做测试,分别使用 Xcode 15Xcode 16ProfileRelease 两种模式来观察编译用时

版本Profile (s)Release (s)
Xcode 15389384.6
Xcode 16952.3477.4

可以看到升级到 Xcode 16 后,两种模式的编译时间都比使用 Xcode 15 的要久,特别是 Profile 模式下的编译时间更离谱,是 Release2倍 多~

而我们的项目为了方便,是以编译模式进行环境区分的。

  • Profile : 测试包使用,对应 kProfileMode
  • Release : 上架包使用,对应 kReleaseMode

三、调整环境判断

基于现状,只能调整项目中对环境的区分逻辑,改用 Dart Define 将环境参数传入。

这里使用 --dart-define-from-file 传递文件的方式

fvm spawn 3.24.5 build ipa --release --export-options-plist=path/to/ad_hoc.plist --dart-define-from-file=path/to/test.env

test.env 文件以键值对的方式设置环境变量

APP_ENV=test

取值方式如下,注意,一定要加上 const

/// dart define 环境变量
String get appEnv => const String.fromEnvironment('APP_ENV');

判断是否为 release

enum AppBuildMode {
  release,
  debug,
  test,
}

AppBuildMode? fetchAppEnvType() {
  switch (appEnv.toLowerCase()) {
    case "debug":
      return AppBuildMode.debug;
    case "test":
      return AppBuildMode.test;
    case "release":
      return AppBuildMode.release;
    default:
      return null;
  }
}

bool isRelease() {
  final envType = fetchAppEnvType();
  if (envType == null) {
    // 没有使用 dart define 设置环境变量
    return kReleaseMode;
  } else {
    return AppBuildMode.release == envType;
  }
}

四、浅探索耗时

当然,我们也可以尝试去探索一下,到底是哪里耗时这么久。

通过 Xcode 自身去查看编译耗时会发现最长的是 Run Script,其主要负责编译 Flutter 侧的代码。

注:这里的时间是 Xcode 16 + Release 下的

但是展开详细内容会发现一点有用的信息都没有,无法定位到具体问题。

经过对 flutter_tools 的代码进行阅读后发现,可以通过设置环境变量 VERBOSE_SCRIPT_LOGGING 来使其加上 --verbose 参数,进而将打包过程中的一些信息打印出来。

具体操作: Runner -> Build Phases -> Run Script 中补充一句 export VERBOSE_SCRIPT_LOGGING=1

# 补充这一句
export VERBOSE_SCRIPT_LOGGING=1

/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build

再次编译就可以看到详细的 flutter 命令打包信息,可以将其导出后慢慢查看。

下面是摘出的主要耗时记录和文件大小

Profile

# Xcode 15
[   +2 ms] executing: xcrun cc -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-15.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.5.sdk -c /Users/lxf/.../.dart_tool/flutter_build/80c2b96b2938ac2118bcd57be8744d2f/arm64/snapshot_assembly.S -o /Users/lxf/.../.dart_tool/flutter_build/80c2b96b2938ac2118bcd57be8744d2f/arm64/snapshot_assembly.o
[+165207 ms] executing: xcrun clang -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-15.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.5.sdk -dynamiclib -Xlinker -rpath -Xlinker @executable_path/Frameworks -Xlinker -rpath -Xlinker @loader_path/Frameworks -fapplication-extension -install_name @rpath/App.framework/App -o /Users/lxf/.../.dart_tool/flutter_build/80c2b96b2938ac2118bcd57be8744d2f/arm64/App.framework/App /Users/lxf/.../.dart_tool/flutter_build/80c2b96b2938ac2118bcd57be8744d2f/arm64/snapshot_assembly.o
[ +289 ms] ...
[   +1 ms] executing: /Users/lxf/fvm/versions/3.24.5/bin/cache/artifacts/engine/ios-profile/gen_snapshot_arm64 --deterministic --snapshot_kind=app-aot-assembly --assembly=/Users/lxf/.../.dart_tool/flutter_build/80c2b96b2938ac2118bcd57be8744d2f/arm64/snapshot_assembly.S /Users/lxf/.../.dart_tool/flutter_build/80c2b96b2938ac2118bcd57be8744d2f/app.dill
[+96580 ms] ...


# Xcode 16
[        ] executing: xcrun cc -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-16.0.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk -c /Users/lxf/.../.dart_tool/flutter_build/503cc01726bac6c42836b48ae4a747ed/arm64/snapshot_assembly.S -o /Users/lxf/.../.dart_tool/flutter_build/503cc01726bac6c42836b48ae4a747ed/arm64/snapshot_assembly.o
[+596589 ms] executing: xcrun clang -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-16.0.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk -dynamiclib -Xlinker -rpath -Xlinker @executable_path/Frameworks -Xlinker -rpath -Xlinker @loader_path/Frameworks -fapplication-extension -install_name @rpath/App.framework/App -o /Users/lxf/.../.dart_tool/flutter_build/503cc01726bac6c42836b48ae4a747ed/arm64/App.framework/App /Users/lxf/.../.dart_tool/flutter_build/503cc01726bac6c42836b48ae4a747ed/arm64/snapshot_assembly.o
[ +290 ms] ...
[   +1 ms] executing: /Users/lxf/fvm/versions/3.24.5/bin/cache/artifacts/engine/ios-profile/gen_snapshot_arm64 --deterministic --snapshot_kind=app-aot-assembly --assembly=/Users/lxf/.../.dart_tool/flutter_build/503cc01726bac6c42836b48ae4a747ed/arm64/snapshot_assembly.S /Users/lxf/.../.dart_tool/flutter_build/503cc01726bac6c42836b48ae4a747ed/app.dill
[+92259 ms] ...
# Xcode 15、Xcode 16 一样

ls -lh
total 853368
drwxr-xr-x@ 3 lxf  staff    96B  3  7 15:48 App.framework
drwxr-xr-x@ 3 lxf  staff    96B  3  7 15:48 App.framework.dSYM
-rw-r--r--@ 1 lxf  staff   323M  3  7 15:38 snapshot_assembly.S
-rw-r--r--@ 1 lxf  staff    93M  3  7 15:48 snapshot_assembly.o

Release

# Xcode 15
[        ] executing: xcrun cc -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-15.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.5.sdk -c /Users/lxf/.../.dart_tool/flutter_build/a403cb206ef9086380afa3baff59c37e/arm64/snapshot_assembly.S -o /Users/lxf/.../.dart_tool/flutter_build/a403cb206ef9086380afa3baff59c37e/arm64/snapshot_assembly.o
[+92077 ms] executing: xcrun clang -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-15.4.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.5.sdk -dynamiclib -Xlinker -rpath -Xlinker @executable_path/Frameworks -Xlinker -rpath -Xlinker @loader_path/Frameworks -fapplication-extension -install_name @rpath/App.framework/App -o /Users/lxf/.../.dart_tool/flutter_build/a403cb206ef9086380afa3baff59c37e/arm64/App.framework/App /Users/lxf/.../.dart_tool/flutter_build/a403cb206ef9086380afa3baff59c37e/arm64/snapshot_assembly.o
[ +245 ms] ...
[   +1 ms] executing: /Users/lxf/fvm/versions/3.24.5/bin/cache/artifacts/engine/ios-release/gen_snapshot_arm64 --deterministic --snapshot_kind=app-aot-assembly --assembly=/Users/lxf/.../.dart_tool/flutter_build/a403cb206ef9086380afa3baff59c37e/arm64/snapshot_assembly.S /Users/lxf/.../.dart_tool/flutter_build/a403cb206ef9086380afa3baff59c37e/app.dill
[+88256 ms] ...

# ========== 华丽的分割线 ========== #

# Xcode 16
[        ] executing: xcrun cc -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-16.0.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk -c /Users/lxf/.../.dart_tool/flutter_build/6294397816f76932cad621f56d6b967b/arm64/snapshot_assembly.S -o /Users/lxf/.../.dart_tool/flutter_build/6294397816f76932cad621f56d6b967b/arm64/snapshot_assembly.o
[+246277 ms] executing: xcrun clang -arch arm64 -miphoneos-version-min=12.0 -isysroot /Applications/Xcode-16.0.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk -dynamiclib -Xlinker -rpath -Xlinker @executable_path/Frameworks -Xlinker -rpath -Xlinker @loader_path/Frameworks -fapplication-extension -install_name @rpath/App.framework/App -o /Users/lxf/.../.dart_tool/flutter_build/6294397816f76932cad621f56d6b967b/arm64/App.framework/App /Users/lxf/.../.dart_tool/flutter_build/6294397816f76932cad621f56d6b967b/arm64/snapshot_assembly.o
[ +237 ms] ...
[   +1 ms] executing: /Users/lxf/fvm/versions/3.24.5/bin/cache/artifacts/engine/ios-release/gen_snapshot_arm64 --deterministic --snapshot_kind=app-aot-assembly --assembly=/Users/lxf/.../.dart_tool/flutter_build/6294397816f76932cad621f56d6b967b/arm64/snapshot_assembly.S /Users/lxf/.../.dart_tool/flutter_build/6294397816f76932cad621f56d6b967b/app.dill
[+88139 ms] ...
# Xcode 15、Xcode 16 一样

ls -lh
total 572808
drwxr-xr-x@ 3 lxf  staff    96B  3  7 17:08 App.framework
drwxr-xr-x@ 3 lxf  staff    96B  3  7 16:02 App.framework.dSYM
-rw-r--r--@ 1 lxf  staff   213M  3  7 17:04 snapshot_assembly.S
-rw-r--r--@ 1 lxf  staff    67M  3  7 17:08 snapshot_assembly.o

你可能会觉得最耗时的是 xcrun clang,但其实每一行前面的中括号内的时间,是上一行的命令的耗时,即 xcrun cc 最耗时,而其它命令的执行时间是差不多的。

xcrun cc 命令是用于将 Flutter 生成的汇编代码(snapshot_assembly.S)编译为目标文件(snapshot_assembly.o),不知道苹果使用的 clang 版本是有什么问题,在 Profile 下的编译时长是 Release 下的 2倍 多,它就是造成编译时间变长的主要原因,到这我就没继续往下研究了,有兴趣的小伙伴可以尝试研究看看。

除此之外,汇编文件 snapshot_assembly.S 的大小相差 100M+,我们可以在日志中找到生成汇编代码的 gen_snapshot_* 命令,如下所示

executing: /Users/lxf/fvm/versions/3.24.5/bin/cache/artifacts/engine/ios-release/gen_snapshot_arm64 --deterministic --snapshot_kind=app-aot-assembly --assembly=/Users/lxf/.../.dart_tool/flutter_build/0385e340094e836ea63c75553c018e82/arm64/snapshot_assembly.S /Users/lxf/.../.dart_tool/flutter_build/0385e340094e836ea63c75553c018e82/app.dill

gen_snapshot_* 命令加上 --trace-compiler 标志并重新运行,让其提供每个函数的编译时间,并记录到 result.txt 中,精简命令如下

gen_snapshot_* --trace-compiler ... app.dill > result.txt 2>&1

result.txt 中的内容长这个样子

Precompiling optimized function: 'dart:core_StateError_StateError.' @ token 21950, size 52
--> 'dart:core_StateError_StateError.' entry: 0x108d00090 size: 56 time: 935 us
Precompiling optimized function: 'dart:core_RangeError_RangeError.' @ token 9976, size 94
--> 'dart:core_RangeError_RangeError.' entry: 0x108d000e0 size: 72 time: 133 us
...

根据 result.txt 中的耗时(time)进行从大到小排序,并输出到 sorted_result.txt

grep '^-->' result.txt | awk '{for(i=1;i<=NF;i++) if($i=="time:") print $(i+1), $0}' | sort -nr | cut -d' ' -f2- > sorted_result.txt

排序后我们就可以清晰的知道哪些方法是比较耗时的,大家自行判断是否优化即可。

经过对比两个 sorted_result.txt 后发现,一些方法在 Profile 中存在而 Release 中没有,即发生了 Tree Shaking

Flutter 中,Tree Shaking 是一种优化技术,用于删除未使用的代码,以减小应用的大小并提高性能。对于不同的构建模式,Tree Shaking 的行为有所不同:

模式描述
Debug不会进行 Tree Shaking
因为 Debug 模式主要用于开发和调试,保留所有代码和调试信息,以便于开发者进行调试。
Profile会进行部分 Tree Shaking
主要用于性能分析,尽可能地优化代码,同时保留一些调试信息,以便开发者能分析性能问题。
Release会进行全面的 Tree Shaking
会删除未使用的代码,并进行其他优化,以确保应用的体积尽可能小,并且性能最佳。

关于构建模式的详细说明,可以看官方文档 docs.flutter.dev/testing/bui…

因此,如果我们希望最大限度地减少应用的体积并提高性能,建议在 Release 模式下构建 Flutter 应用。

Profile 模式切到 Release 模式后的打包时间如下

构建机Profile(min)Release(min)
i725+18+
M416+9+

可以看到,切换编译模式已经很大程度地优化了编译时长,不过我们还可以再进一步优化。

五、二进制依赖

二进制依赖iOS 端老生常谈的优化点了,通过直接使用编译好的库或模块,从而避免编译的时间和资源消耗。

因此,原生插件越多,编译速度就越慢,二进制依赖的优化效果越好,二进制依赖的优化效果越好,编译速度就越快,所以编译越慢,编译越快 ~

在这里我使用的是 Rugby 这个工具。

安装

curl -Ls https://swiftyfinch.github.io/rugby/install.sh | bash

安装完成后输出如下内容

🏈 Rugby has been installed ✓

/Users/lxf/.rugby/clt is not in your $PATH
Add it manually to your shell profile.
For example, if you use zsh, run this command:
$ echo '\nexport PATH=$PATH:~/.rugby/clt' >> ~/.zshrc
Than open a new window or tab in the terminal for applying changes.

根据提示,将 rugby 添加到环境变量中。

完成后新开个终端,执行如下命令验证 rugby 是否可以被正常使用

rugby --version

# 输出
2.10.2

使用

在执行完 pod install 后,再执行 rugby cache 即可将原生插件从源码依赖转成二进制依赖了

rugby cache \
  --arch arm64 \
  --sdk ios \
  --except chat_bottom_container realm dart_native \
  --config Release

这里通过 --except 将一些不做二进制依赖的包过滤掉了。

当这些参数太多之后,命令会变得很长,不好看,可以将这些参数整理到 plans.yml 文件中

profile:
- command: cache
  sdk: ios
  config: Profile
  except:
    - chat_bottom_container
    - realm
    - dart_native

release:
- command: cache
  sdk: ios
  config: Release
  except:
    - chat_bottom_container
    - realm
    - dart_native

然后改为 rugby plan 去执行,并且指定使用 plans.yml 中的 release

rugby plan release -p /User/lxf/.../plans.yml

不过需要注意的是,如果你再次执行 pod install 将会还原为源码依赖!rugby 的修改就会失效~

而我们平时执行的 flutter build ipa 命令,其内部是有可能会去执行 pod install 的,那如何避免呢?

经过 flutter_tools 的源码阅读,发现它会做如下判断

  1. 涉及的文件是否存在
  2. 对比 pod_inputs.fingerprint 中的各项 MD5
  3. 对比 Podfile.lockPods/Manifest.lock 内容

pod_inputs.fingerprint 位于 build/ios 目录,内容如下

{
    "files": {
        "/Users/lxf/.../ios/Runner.xcodeproj/project.pbxproj": "21b527dc18081de6eabe26c6a4e851b2",
        "/Users/lxf/.../ios/Podfile": "25baa69590b287fd88a578ae5fa2f964",
        ".../flutter/packages/flutter_tools/bin/podhelper.rb": "29abcfc3297c225fc1d1ae2380787cd6"
    }
}

所以现在很明确,我们需要调整打包步骤

  1. flutter pub get/upgrade
  2. cd ios && pod install
  3. 切成二进制依赖
  4. 自己生成 pod_inputs.fingerprint
  5. 拷贝 Podfile.lock 至 Pods/Manifest.lock
  6. flutter build ipa

其中第 3 ~ 第 5 步我已经做了封装在我的 github.com/LinXunFeng/… 项目中,使用如下

Condor

安装 condor

brew tap LinXunFeng/tap && brew install condor

指定编译模式

设置环境变量 CONDOR_BUILD_MODE,对应 plans.yml 里的 profilerelease

export CONDOR_BUILD_MODE=release

也可以使用 --mode 参数来指定模式

condor optimize-build --mode release

二进制依赖与同步文件

进入到 Flutter 项目的根目录,执行如下命令

cd path/to/your/flutter_project

condor optimize-build --config path/to/rugby/plans.yml

如果你想指定 fvm 安装的且非全局默认的 flutter,则可以加上 --flutter 参数

condor optimize-build --config path/to/rugby/plans.yml --flutter "fvm spawn 3.24.5"

最后执行打包命令即可。

六、最后

希望苹果下一个版本的 Xcode 可以解决这个问题吧,不然的话,emmm,我也不会升级电脑的~

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~