背景
如果你的 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]就证实了我的猜想:
文中提到了几个关键点:
- 2.5.3 之后的版本,内存崩溃都开始变得多。
- 2.5.3 之后的版本,Flutter 确实改变了内存策略,采用了压缩内存的方式(贴子中叫做压缩指针)。
- 有人实验性地关掉了压缩内存,解决了此问题。
结合我们升级的版本就是 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.
编译
接着就是编译,我们会分两个步骤:
- 用 gn 创建编译材料。
- 用 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 架构)那就需要折腾一番了(我估计大家都是 ¬_¬):
- 修改/src/flutter/sky/tools/create_macos_gen_snapshots.py。
- 修改/src/flutter/lib/snapshot/BUILD.gn。
3. 修改 /src/third_party/dart/runtime/BUILD.gn。
以上修改都是为了解决「构建脚本默认把编译的 host 机器认为是 x64 架构」,而我们做的修改就是为了适配的 arm64 架构。
由于编译脚本经常更新,所以以上修改方案可能只对当前 commit 生效,不过我总结出一些经验方便大家修改脚本:
- 关注你的 host 和 target 系统和架构,一般需要修改的点都是围绕这些参数展开。
- gen_snapshot 是 Dart 的编译产物,要确保它放在正确的文件夹,并且被正确调用。
- 巧用调试打印大法,需要修改的 .gn .py 文件都可以用 print 打印参数,如果不熟悉可以快速预览一下gn[11]和Python[12]的语法(我就是这样)。
- 认真看报错信息,都说得非常详细,完全可以顺藤摸瓜解决问题。
- 最好的办法还是找一台 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。