当前位置: 首页 > 科技观察

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

时间:2023-03-20 22:36:11 科技观察

背景如果你的Flutter版本号小于等于2.5.3或者大于等于3.0.5,你的应用不会出现下面描述的问题,但是我相信大多数应用程序都会达到这个范围。最近发生了我们应用新上线的iOS版本(设计稿)崩溃数据暴涨的事情。根据崩溃日志和用户反馈,大多数新的崩溃都来自同一个原因:内存不足。有的直接OOM,不好排查。其中有部分申请内存失败,导致后续逻辑错误崩溃。结合“遍地开花,多点爆破”的情况,应该是某种底层的内存管理问题。这有点让人头疼,因为这个版本没有进行任何与内存相关的更改。于是我采取二分法,花了两个小时尝试了版本中的所有PR,发现罪魁祸首是Flutter版本升级:2.5.3→2.10。那么问题转化为:Flutter在2.5.3→2.10.做了哪些改动,导致了内存崩溃的问题。问题分析根据用户反馈,我们找到了一个一定会导致内存崩溃的操作路径,于是我尝试测试了Flutter2.5.3和2.10.5版本的内存情况:对比内存情况,我们可以得出一个结论:之前升级内存容忍度更高,峰值1.2G没问题;升级后内存容忍度更低,1.1G的峰值会崩盘。这让我想起了“压缩内存”:当内存吃紧的时候,iOS系统会压缩一些不用的内存来释放内存空间。当需要读取这些压缩后的内存时,也需要先解压再读取。听起来是个不错的机制,为什么会出错呢?有一个经典案例:SDWebImage[1]是iOS开发中常用的第三方图片缓存库。它将使用过的图像缓存在内存中,以便后续快速重用,并在内存紧张时释放缓存。.一个细节是早期SDWebImage将缓存放在了NSMutableDictionary中,这会导致部分图片缓存在一段时间后被系统压缩。当内存高峰来临时,系统会发出内存警告,SDWebImage收到警告后会选择释放缓存。你是否记得?发布前必须先解压再发布。在解压的瞬间,内存的峰值被推高了,于是系统杀掉了进程,造成了经典的OOM。后来SDWebImage使用系统提供的NSCache进行缓存。NSCache专门针对内存压缩进行了优化来解决这个问题。于是,顺藤摸瓜,在Flutter的issue中搜索了几个关键词:iOScompressmemory,第一篇帖子[2]证实了我的猜测:文中提到了几个关键点:2.5.3版本后,内存崩溃开始增加。在2.5.3之后的版本中,Flutter确实改变了内存策略,采用了压缩内存的方式(帖子中称之为compressedpointers)。有人实验性地关闭了压缩内存,解决了这个问题。结合我们的升级版本,就是2.5.3→2.10.5。基本上就是可以加锁的压缩内存的问题。两种方案目前有两种解决方案:方案一:等待Flutter官方的方案出来,然后我们就可以升级版本了。方案二:我们自己修改FlutterEngine的源码,关闭内存压缩。魔改FlutterEngine源码的成本其实非常高。需要了解FlutterEngine与Flutter的依赖关系,构造方法,FlutterEngine的代码逻辑等。本来想等解决方案一,但随着后台用户反馈越来越多,急需解决内存引起的死机问题,所以我们决定采用解决方案2。客户投诉是第一生产力?于是我,一个从来没有写过一行Flutter代码的人,硬着头皮上了。看了无数的官方/私人文档,花了三天时间摸索出来,在FlutterEngine中加入了自定义打印:下面详细介绍具体方案二中的问题是如何解决的。巧合的是,就在我们使用方案二解决问题的时候,方案一也迎来了曙光:Flutter紧急发布了3.0.5版本,其中FlutterEngine关闭了内存压缩。于是,我们马上升级试了一下,果然不死机。我们稍微调整了一下,就上线了。根据网上资料反馈,内存崩溃问题已经完美解决。FlutterEngine定制及源码调试接下来详细介绍方案2的运行过程。先从一个流程图说起:下载源码查看Flutter官方文档[3],发现有一页文档是下载源码的。可想而知,这个坑有多么深ForkFlutterEngine仓库打开github.com/flutter/eng...[4],fork一份到自己的仓库。比如我的是github.com/JPlay/engin...[5]。fork就是在修改源代码后,有一个地方可以保存修改后的代码。(这里fork就好,不用clone)安装depot_toolsdepot_tools[6]是Google提供的一个管理项目代码的工具集。它包含许多套件。列出我们将使用的那些:gclient-源代码管理工具,它可以帮助您拉取项目源代码和依赖项。gn-创建编译素材,特别适用于Flutter等跨平台多编译目标项目。ninja-负责编译gn生成的编译素材的编译工具。开始安装depot_tools:$gitclonehttps://chromium.googlesource.com/chromium/tools/depot_tools.git安装完成后,在~/.bashrc或~/.zshrc中添加如下命令,将depot_tools设置为环境变量,方便后续使用:exportPATH=/path/to/depot_tools:$PATH拉取源码不像使用git直接拉取,这里必须使用gclient,因为有很多依赖只有gclient可以拉下。我们新建一个文件夹,命名为engine(名字任意),后续的源代码都放在这里。在引擎中创建一个新的配置文件。名称必须是.gclient。使用文本编辑器添加以下内容:"custom_deps":{},"deps_file":"DEPS","safesync_url":"",},]这里是url的值:git@github.com[7]:JPlay/engine.git是git我刚刚分叉的存储库。57d3bac3dd5cb5b0e464ab70e7bc8a0d8cf083ab是对应我们当前Flutter版本2.10.5的commitid。可以在你的Flutter目录下的/bin/internal/engine.version中找到,比如我的是:$cat/Users/JPlay/development/flutter/bin/internal/engine.version配置完成后挂掉proxy,并且可以在engine文件夹中拉取代码:$gclientsync--verbose这里必须强调一下,这里的代码量超过了10GB,过程相当缓慢。如果有任何错误或卡在中间,那基本上是网络问题。建议仔细阅读日志。大多是克隆仓库或访问地址失败。推荐使用gitclone或者curl试试看网络是否通畅。PS:我的第一个代理可以拉取大部分代码,但是有一小部分代码拉不下来,浪费了我大部分时间。后来换了个agent,顺利的拉下来了。成功后,你会发现代码全部集中在engine/src目录下,类似这样:如果后面想切换engine分支,可以先进入/src/flutter,然后执行:$gitreset--hard$gclientsync--with_branch_heads--with_tagscompile然后再编译,我们分两步:使用gn创建编译素材。用忍者编译。想了解gn和ninja,请看这里[8],想了解gn,请看这里,想了解ninja,请看这里[9]值得一提的是,自从Flutter的编译产品是分平台的,我们目前主要需求是iOS和Android,这个在macOS上就可以完成。在编译iOS/Android产品的同时,还需要编译一个host产品,因为我们需要编译一个对应当前版本的DarkSDK。由于代码版本、目标平台、目标架构不唯一,这里以iOSarm64目标为例。其他情况请酌情模仿。创建编译资料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}][--模拟器][--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-toolsDEPOT_TOOLS][--lto][--no-lto][---clang][--no-clang][--clang-static-analyzer][--no-clang-static-analyzer][--target-sysrootTARGET_SYSROOT][--target-toolchainTARGET_TOOLCHAIN][--target-tripleTARGET_TRIPLE][--operator-new-alignmentOPERATOR_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-dirOUT_DIR][--full-dart-sdk][--no-full-dart-sdk][--ideIDE][--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-levelFUCHSIA_TARGET_API_LEVEL][--use-mallinfo2][--asan][--lsan][--msan][--tsan][--ubsan][--trace-gn][--verbose]这里会用到几个:参数名称说明--unoptimized默认会优化,如果设置为未优化(unoptimized),编译后的产品会留下一些方便调试的内容,比如:log、asset、dSYM之类的--simulator连接平台,指定目标是否为模拟器--runtime-mode指定目标运行模式,有debug、profile、release、jit_release,见官方文档[10]--ios-cpu/--androidfordetails-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第一行是生成host素材,第二行是iOS素材(没有输入架构,默认是arm64)。于是在src/out/下新增了两个文件夹,这些是编译资料:执行编译资料准备好了,我们要开始编译了,如果你是IntelCPU(x64架构)的Mac,那就万事大吉了,就直接执行命令:$ninja-Cout/ios_debug_unopt&&ninja-Cout/host_debug_unopt但是,如果你是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。以上修改都是为了解决“构建脚本默认将编译宿主机视为x64架构”,我们所做的修改是为了适配arm64架构。由于编译脚本经常更新,以上修改方案可能只对当前commit生效,不过我总结了一些经验方便大家修改脚本:注意你的host和target系统和架构,一般要点需要修改的都是基于这些参数。gen_snapshot是Dart的编译产物,请确保将其放置在正确的文件夹中并正确调用。巧妙地使用调试打印的方法,可以使用print打印出需要修改的.gn.py文件的参数。不熟悉的可以快速预览一下gn[11]和Python[12]的语法(我就是这样的)。仔细阅读报错信息,非常详细,可以根据线索解决问题。最好的办法是找到x64Mac。这个修改方案是我个人的临时方案。问题中还有一些高手的其他想法。可以参考:github.com/flutter/flu...[13]如果修改源代码一切顺利,我们就通过了编译级别。现在可以修改源码了,我这里举个例子来证明我们已经成功修改了源码:在/src/flutter/shell/common/engine.cc的Run方法中添加打印信息,将使引擎启动打印此消息。不要忘记我们的初衷:在/src/flutter/tools/gn中关闭iOS内存压缩解决内存问题:修改后,重新编译:(这次是增量更新,很快):$ninja-Cout/ios_debug_unopt&&ninja-Cout/host_debug_unopt接下来进入一个Flutter工程目录,执行:$flutterrun--local-engine-src-path=/path/to/engine/src/--local-engine=ios_debug_unopt可以看到控制台输出:应用程序成功运行并输出我们的自定义信息。至此我们已经取得了阶段性的成功,并成功在Flutter项目中运行了我们修改后的代码。源码调试Flutter官方文档[14]对调试部分说的很全。我这里只举一个Xcode源码调试的例子。我们打开一个Flutter工程,比如Runner.xcworkspace,因为我们刚才运行了:$flutterrun--local-engine-src-path=/path/to/engine/src/--local-engine=ios_debug_unopt所以,生成了.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与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。