深圳幻海软件技术有限公司 欢迎您!

解决 Flutter 引起的 iOS 内存崩溃问题

2023-03-01

背景如果你的Flutter版本号小于等于2.5.3或大于等于3.0.5,以下描述的问题将不会发生在你的应用中,但是我相信大部分应用都会命中此区间。事情发生在最近,我们的应用(稿定设计)新上线的iOS版本崩溃数据飙升。根据崩溃日志和用户反馈,大部分新增崩溃都来自于同一个原因:内存不足。有的直接变成OO

背景

如果你的 Flutter 版本号小于等于 2.5.3 或大于等于 3.0.5,以下描述的问题将不会发生在你的应用中,但是我相信大部分应用都会命中此区间。

事情发生在最近,我们的应用(稿定设计)新上线的 iOS 版本崩溃数据飙升。根据崩溃日志和用户反馈,大部分新增崩溃都来自于同一个原因:内存不足。有的直接变成 OOM,不易排查。有的则是申请内存失败,导致后续逻辑错误的崩溃。

结合「处处开花,多点爆破」的情况来看,应该是某种偏底层的内存管理问题。这就有点挠头了,因为这个版本并没有做什么内存相关的改动。于是我采取了二分法,花了两个小时试了版本中所有 PR,发现罪魁祸首是 Flutter 版本升级:2.5.3 → 2.10.。

那么问题就转化为:Flutter 在 2.5.3 → 2.10. 中做了什么改动,导致了内存崩溃问题。

分析问题

根据用户反馈,我们发现了一个必现内存崩溃的操作路径,于是我尝试在 Flutter 2.5.3 版本和 2.10.5 版本各自测试了一下内存情况:

对比内存情况可以得出一个结论:升级前内存容忍度更高,1.2G 峰值都没问题;升级后内存容忍度更低,1.1G 峰值就崩溃。

这让我联想到了「压缩内存」:iOS 系统会在内存紧张的时候,把一部分不用的内存做压缩,以腾出内存空间。在需要读取这些压缩内存的时候,也需要先解压再读取。

听起来很好的机制,为什么会出问题呢?有一个经典案例:

  • SDWebImage[1]是 iOS 开发中常用的第三方图片缓存库,它会将使用过的图片缓存在内存中,以供后续快速复用,同时在内存紧张的时候会释放掉缓存。有一个细节是,SDWebImage 早期是将缓存放在 NSMutableDictionary 中,这会使得部分图片缓存在一段时间不用后就被系统压缩了。当内存峰值来临时,系统会发送一个内存警告,SDWebImage 在收到警告的时候会选择释放掉缓存。还记得吗?释放之前要先解压,才能释放。在解压的一瞬间,内存峰值被推得更高,于是系统就杀掉进程,制造了一次经典的 OOM。后来 SDWebImage 采用了系统提供的 NSCache 来做缓存,NSCache 有专门针对内存压缩做优化,才解决了此问题。

于是,顺藤摸瓜,我在 Flutter 的 issue 中搜索了几个关键词:iOS compress memory,第一个帖子[2]就证实了我的猜想:

文中提到了几个关键点:

  1. 2.5.3 之后的版本,内存崩溃都开始变得多。
  2. 2.5.3 之后的版本,Flutter 确实改变了内存策略,采用了压缩内存的方式(贴子中叫做压缩指针)。
  3. 有人实验性地关掉了压缩内存,解决了此问题。

结合我们升级的版本就是 2.5.3 → 2.10.5,基本上可以锁定就是这个压缩内存的问题了。

两种方案

目前有两个解决方案:

  • 方案一:等待 Flutter 官方解决,我们再升级版本就好。
  • 方案二:我们自己魔改 Flutter Engine 源码,关掉内存压缩。

魔改 Flutter Engine 源码的成本其实是很高的,要理解 Flutter Engine 和 Flutter 的依赖关系,构建方式,以及 Flutter Engine 代码逻辑等等。

原本是想等待方案一的,但是随着后台用户反馈越来越多,解决内存导致的崩溃已经刻不容缓了,我们决定采取方案二。

客户投诉是第一生产力?

于是我这个一行 Flutter 代码都没写过的人,就硬着头皮上了。在阅读了无数官方 / 民间文档之后,花了三天时间,硬是整出来了,在 Flutter Engine 中加上了自定义打印:

具体方案二是如何解决问题的,下文细说。

碰巧的是,就在我们用方案二解决问题之时,方案一也迎来了曙光:Flutter 紧急发布了 3.0.5 版本,该版本中 Flutter Engine 关闭了内存压缩。于是,我们立刻升级尝试了一下,确实不会崩溃了,我们稍加适配,就上线了。目前根据线上数据反馈,内存崩溃问题已经完美解决。

Flutter Engine 定制与源码调试

接下来将详细介绍方案二的操作流程,先来个流程图:

下载源码

查了一下 Flutter官方文档[3],发现下载源码就有一页文档,可想而知这个坑有多深

Fork Flutter Engine 仓库

打开 github.com/flutter/eng…[4],fork 一份到自己的仓库。比如,我的是 github.com/JPlay/engin…[5] 。

fork 是为了修改源码后有个地方能存修改过的代码。(这里 fork 就好,不用 clone)

安装 depot_tools

depot_tools[6] 是 Google 提供用来用来管理项目代码的工具集,它内含了许多套件,列举一下我们将会用到的:

  • gclient - 源码管理工具,可以帮助你拉取项目源码以及依赖。
  • gn - 创建编译材料,特别适合 Flutter 这种跨平台多编译目标的项目。
  • ninja - 编译工具,负责编译 gn 生成的编译材料。

开始安装 depot_tools:

$ git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
  • 1.

安装完成之后,在  ~/.bashrc 或者  ~/.zshrc 中加入以下命令,把 depot_tools 设置成环境变量,方便后续使用:

export PATH=/path/to/depot_tools:$PATH
  • 1.

拉源码

不像往常我们用 git 直接拉,这里必须使用 gclient,因为有很多依赖只有 gclient 才能拉下来。

我们先新建一个名为 engine 文件夹(名字随意),后续源码都会放在这里,在 engine 里面新建一个配置文件,名字必须是 .gclient,使用文本编辑器添加以下内容如下:

solutions = [
  {
    "managed": False,
    "name": "src/flutter",
    "url": "git@github.com:JPlay/engine.git@57d3bac3dd5cb5b0e464ab70e7bc8a0d8cf083ab",
    "custom_deps": {},
    "deps_file": "DEPS",
    "safesync_url": "",
  },
]
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

这里要说一下 url 的值:

  • git@github.com[7]:JPlay/engine.git 是我刚才 fork 的 git 仓库。
  • 57d3bac3dd5cb5b0e464ab70e7bc8a0d8cf083ab 是我们当前 Flutter 版本 2.10.5 对应的 commit id。

在你的 Flutter 目录下的 /bin/internal/engine.version 可以找到,比如我的是:

$ cat /Users/JPlay/development/flutter/bin/internal/engine.version
  • 1.

完成配置之后,挂上代理,就可以在 engine 文件夹下,执行拉代码的操作了:

$ gclient sync --verbose
  • 1.

这里必须强调一下,这里的代码量超过 10GB,过程相当慢。如果中途有任何报错或者卡住,基本上都是网络问题,建议认真看下日志,大部分是 clone 某个仓库失败或者访问地址失败,建议用 git clone 或者 curl 试试看网络是否通畅。

PS:我的第一个代理就是能拉大部分代码,而小部分代码死活拉不下来而浪费了我大半天时间,后来换了一个代理就顺利拉下来了。

成功之后,你会发现代码全都集中在 engine/src 目录下,类似这样:

后续如果想再切换 engine 的分支,可以先进入  /src/flutter,然后执行:

$ git reset --hard <commit id>
$ gclient sync --with_branch_heads --with_tags
  • 1.
  • 2.

编译

接着就是编译,我们会分两个步骤:

  1. 用 gn 创建编译材料。
  2. 用 ninja 执行编译。

想简单了解一下 gn 和 ninja 的看这里[8],想详细了解 gn 的看这里,想详细了解 ninja 的看这里[9]

值得一提的是,由于 Flutter 的编译产物是分平台的,我们目前主要需要的是 iOS 和 Android,这在 macOS 上都能搞定。

在编译 iOS / Android 产物的同时,还需要而外编译一个 host 产物,这是因为我们需要编译出一个与当前版本对应的的 Dark SDK。

因为代码版本、目标平台、目标架构都不唯一,所以接下来拿 iOS arm64 目标来举例,其他情况请酌情仿造。

创建编译材料

gn 提供了一堆参数来帮助我们创建编译材料:

usage: gn [-h] [--unoptimized] [--enable-unittests]
          [--runtime-mode {debug,profile,release,jit_release}] [--interpreter]
          [--dart-debug] [--no-dart-version-git-info] [--full-dart-debug]
          [--target-os {android,ios,mac,linux,fuchsia,win,winuwp}] [--android]
          [--android-cpu {arm,x64,x86,arm64}] [--ios] [--ios-cpu {arm,arm64}]
          [--mac] [--mac-cpu {x64,arm64}] [--simulator] [--linux] [--fuchsia]
          [--winuwp] [--linux-cpu {x64,x86,arm64,arm}]
          [--fuchsia-cpu {x64,arm64}] [--windows-cpu {x64,arm64}]
          [--simulator-cpu {x64,arm64}] [--arm-float-abi {hard,soft,softfp}]
          [--goma] [--no-goma] [--xcode-symlinks] [--no-xcode-symlinks]
          [--depot-tools DEPOT_TOOLS] [--lto] [--no-lto] [--clang]
          [--no-clang] [--clang-static-analyzer] [--no-clang-static-analyzer]
          [--target-sysroot TARGET_SYSROOT]
          [--target-toolchain TARGET_TOOLCHAIN]
          [--target-triple TARGET_TRIPLE]
          [--operator-new-alignment OPERATOR_NEW_ALIGNMENT]
          [--macos-enable-metal] [--enable-vulkan] [--enable-fontconfig]
          [--enable-vulkan-validation-layers] [--enable-skshaper]
          [--no-enable-skshaper] [--always-use-skshaper]
          [--embedder-for-target] [--coverage] [--out-dir OUT_DIR]
          [--full-dart-sdk] [--no-full-dart-sdk] [--ide IDE]
          [--disable-desktop-embeddings] [--build-glfw-shell]
          [--no-build-glfw-shell] [--build-embedder-examples]
          [--no-build-embedder-examples] [--bitcode] [--stripped]
          [--no-stripped] [--prebuilt-dart-sdk] [--no-prebuilt-dart-sdk]
          [--fuchsia-target-api-level FUCHSIA_TARGET_API_LEVEL]
          [--use-mallinfo2] [--asan] [--lsan] [--msan] [--tsan] [--ubsan]
          [--trace-gn] [--verbose]
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.

这里我们会用到几个:

参数名

说明

--unoptimized

默认是会进行优化(optimized)的,如果设置了不优化(unoptimized),则编译产物会留下一些方便调试的内容,比如:log, asset,dSYM 之类的

--simulator

接在平台之后,指定目标是否为模拟器

--runtime-mode

指定目标运行时模式,有 debug,profile,release,jit_release,详情看官方文档[10]

--ios-cpu / --android-cpu

指定目标 CPU 架构,iOS 有 arm 和 arm64,Android 有 arm,x64,x86,arm64

--ios --android

指定目标平台,如果是编译 host,则不需要设置此参数

具体说明可以输入:/path/to/gn --help 查看。

我们在 src/ 目录下创建一个 iOS 调试用的编译材料:

$ ./flutter/tools/gn --runtime-mode=debug --unoptimized
$ ./flutter/tools/gn --ios --runtime-mode=debug --unoptimized
  • 1.
  • 2.

第一行是生成 host 材料,第二行是 iOS 材料(没有输入架构,默认是 arm64) 。于是在 src/out/ 下新增了两个文件夹,这些就是编译材料:

执行编译

材料准备好了,我们就要开始编译了,如果你是 Intel CPU 的 Mac(x64 架构),那将一切顺利,直接执行命令就行:

$ ninja -C out/ios_debug_unopt && ninja -C out/host_debug_unopt
  • 1.

但是,如果你是 M 系列的 Mac(arm64 架构)那就需要折腾一番了(我估计大家都是 ¬_¬):

  1. 修改/src/flutter/sky/tools/create_macos_gen_snapshots.py。

  1. 修改/src/flutter/lib/snapshot/BUILD.gn。

3. 修改  /src/third_party/dart/runtime/BUILD.gn。

以上修改都是为了解决「构建脚本默认把编译的 host 机器认为是 x64 架构」,而我们做的修改就是为了适配的 arm64 架构。

由于编译脚本经常更新,所以以上修改方案可能只对当前 commit 生效,不过我总结出一些经验方便大家修改脚本:

  1. 关注你的 host 和 target 系统和架构,一般需要修改的点都是围绕这些参数展开。
  2. gen_snapshot 是 Dart 的编译产物,要确保它放在正确的文件夹,并且被正确调用。
  3. 巧用调试打印大法,需要修改的 .gn .py 文件都可以用 print 打印参数,如果不熟悉可以快速预览一下gn[11]Python[12]的语法(我就是这样)。
  4. 认真看报错信息,都说得非常详细,完全可以顺藤摸瓜解决问题。
  5. 最好的办法还是找一台 x64 的 Mac。

这个修改方案是我个人的临时方案,issue 中也有一些大神的其他思路,可以参考:github.com/flutter/flu…[13]

修改源码

一切顺利的话,我们闯过了编译这一关。现在可以修改源码了,我这里随便举个例子,只为了证明我们修改源码成功:

在 /src/flutter/shell/common/engine.cc 的 Run 方法中加入一个打印信息,这会让 engine 在启动的时候就打印这条信息。

别忘了我们的初衷:在  /src/flutter/tools/gn 中关闭 iOS 的内存压缩,以解决内存问题:

修改完之后,重新编译一下:(这次是增量更新,很快):

$ ninja -C out/ios_debug_unopt && ninja -C out/host_debug_unopt
  • 1.

接着,进入一个 Flutter 项目目录,执行:

$ flutter run --local-engine-src-path=/path/to/engine/src/ --local-engine=ios_debug_unopt
  • 1.

可以看到控制台输出:

应用成功运行了起来,并且输出了我们自定义的信息。到此我们取得了阶段性的成功,已经把我们修改的代码成功在 Flutter 项目中运行起来了。

源码调试

Flutter 官方文档[14]关于调试部分写的非常完整了,我这里只举一个 Xcode 源码调试的例子。

我们打开一个 Flutter 项目,比如,Runner.xcworkspace,由于刚才我们跑过:

$ flutter run --local-engine-src-path=/path/to/engine/src/ --local-engine=ios_debug_unopt
  • 1.

所以,Generated.xcconfig 文件已经已经被设置了相关参数(没有的话自己设置一下):

接着把  /src/out/ios_debug_unopt/flutter_engine.xcodeproj 拖到 Runner 项目中:

找个会运行到的地方下个断点,比如 FlutterAppDelegate.mm 中 - init 方法。运行项目:

断点成功,接着就可以愉快地调试了。

总结

这次问题排查真的很像一次探案过程,根据蛛丝马迹一点点找出线索,最终解决问题。过程中虽然踩了不少坑,但是一路走到最后还是感觉很有一种推理断案的爽快感。特此分享出来,希望能帮大家解决相同的内存问题。

参考资料

[1]SDWebImage: https://github.com/SDWebImage/SDWebImage。

[2]第一个帖子: https://github.com/flutter/flutter/issues/105183。

[3]Flutter 官方文档: https://github.com/flutter/flutter/wiki/Setting-up-the-Engine-development-environment。

[4]github.com/flutter/engine: https://github.com/flutter/engine。

[5]github.com/JPlay/engine: https://github.com/JPlay/engine。

[6]depot_tools: http://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html#_setting_up。

[7]git@github.com: mailto:git@github.com。

[8]gn and ninja: https://zhuanlan.zhihu.com/p/136954435。

[9]https://ninja-build.org/: https://ninja-build.org/。

[10]https://github.com/flutter/flutter/wiki/Flutter%27s-modes: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fflutter%2Fflutter%2Fwiki%2FFlutter%2527s-modes。

[11]gn: https://chromium.googlesource.com/chromium/src/tools/gn/+/48062805e19b4697c5fbd926dc649c78b6aaa138/docs/language.md#GN-Language-and-Operation。

[12]Python: https://www.runoob.com/python/att-string-format.html。

[13]flutter/issues/96745: https://github.com/flutter/flutter/issues/96745。

[14]官方文档: https://github.com/flutter/flutter/wiki/Debugging-the-engine。