Swift 首次调试断点慢的问题解法 | 优酷 Swift 实践
作者:段继统 & 夏磊
调试断点是与开发体验关系最为密切点之一,优酷 iOS 团队在外部调研时候发现,大量国内的 iOS APP 研发团队也遇到了类似的问题。考虑到国内 Swift 如火如荼的现状,我们尽快整理了该方案并通过本文分享出来,希望能在这个问题上帮助到大家。
前言
众所周知,Swift 是苹果公司于 2014 年苹果开发者年会(WWDC2014)上发布的编译式新开发语言,支持多编程范式,可以用来撰写基于 macOS、iOS、iPadOS、watchOS 和 tvOS 上的 APP。对于广大 iOS 开发同学来说,这也是研发未来 iOS APP 开发必须要掌握的语言技能。Swift 语言在发布后的数年里得到了飞速发展,在 2019 年苹果发布了 Swift5.0 版本并宣告 Swift ABI 稳定。
在 Swift5.0 版本的 ABI 稳定后,Swift 正式具备了完善的生产研发基础,优酷 iOS 研发团队也开始进行优酷 iOS、iPadOS 版本的 Swift 迁移。优酷在被阿里巴巴收购后,获得了大量集团移动基建和中间件的支持,因此优酷 iOS App 在持续演化数年后,基本成为标准的大型组件化工程,由数十个垂直团队负责各自业务并行开发。其中,优酷播放详情页场景是最重要的视频内容消费场景,也率先在 2020 年初开始业务页面框架、播放器框架及业务模块的 Swift 迁移。
2020 年底,优酷 iOS 消费团队完成了业务页面框架和播放器框架的 Swift 化,这两个框架代码量较少,内部代码结果合理清晰,而且对外部依赖较少。因此在完全 Swift 化后,性能上得到了提升,并且得益于 Swift 的优秀语法,团队开发业务需求代码行数下降,团队效能也获得了增幅。整个过程都比较顺畅,也并未遇到明显的工程开发或者质量问题。
进入 2021 年后,在业务页面框架及播放器框架 Swift 版本的基础上,优酷 iOS 团队全面启动了业务层代码 Swift 迁移,而在这个阶段,Swift 调试断点慢的问题开始出现并日趋严重。 在视频内容场景,核心主业务模块代码 7 万多行,外部依赖各种模块达 200 以上,在这个业务模块里,首次断点的时间恶劣情况下可以达到 180 秒以上,团队研发效率被严重制约。
2022 年初优酷 iOS 团队完成了 80%以上业务代码的 Swift 迁移,调试首次断点慢的问题已经成为业务场的效率瓶颈。在内部的研发幸福感问卷调查里,97%的 iOS 开发同学认为调试首次断点慢是目前研发过程的最大痛点,这个问题给 iOS 研发同学带来的挫败感,足以打消 Swift 的其他优势。因此,解决这个问题也成为优酷 iOS 团队年度首要目标。
调试首次断点慢现象及初步分析
Swift 调试断点慢主要现象是,当 Xcode 工程运行起来之后,我们进行首次断点的等待时间会特别漫长。大部分情况下,工程首次断点生效后,第二次及后续断点的等待时间都十分短暂,基本可以认为无等待时间。不过从团队内部收集的情况来看,不同 Mac 电脑开发设备和不同的 iOS 设备表现不全一致,部分同学首次断点之后进行断点的等待时间也极其缓慢。
这个现象或者说问题在团队内部频繁出现后,我们首先向苹果中国开发者关系团队反馈,并附上了详细的工程文档等信息。苹果方面也基于反馈在内部进行了调查和验证,并最终给我们答复,表示内部并没有类似问题的发现。在交流过程中我们发现,苹果内部的大型 APP 工程模式都是传统的单工程模式,与国内的组件化多个工程模式截然不同。基于各方面汇总信息,我们对这个问题开始进行初步分析和解决。
从下表中可以分析,播放器框架模块和播放主业务模块情况结合断点时间来看,断点时间似乎与外部依赖数量呈现等比关系,所以可以初步断定断点时间和外部依赖数量存在较强的相关性。
另外还有一个现象,如果子工程和壳工程所依赖 SDK 的 module 没有对齐,lldb 会很快断点生效,但是打印报错信息,同时无法 po 任何值。通过此现象也可以初步分析出来,在断点时 lldb 对子工程依赖的 module 进行了扫描。
但仅仅依赖表象分析还不够,所以后续的工作我们从两个方向着手,第一是从播放主业务模块的解耦测试,快速解耦播放主业务模块的外部依赖,测试耦合数量的减少对断点时间是否能有帮助;第二是从 lldb 自身断点原理的分析,看首次断点如此长的时间中 lldb 究竟在做什么动作。
通过业务模块解耦入手
我们通过删除及整理工程依赖引用代码的方式,快速清理外部模块依赖,最终将播放主业务模块的外部依赖降到 90 个左右。整理完毕后,播放主业务首次调试断点时间也从 200 秒左右降到 120 秒左右,对团队开发困难现状有所缓解。但是经过实际验证和应用后,我们也发现这种依赖业务层解耦的方式是对于团队来说不可行的,根本原因有二:
1、改造成本高
播放主业务模块从 200 多个模块依赖降到了 90 多个,一方面来说说对于防止工程腐化起到了积极帮助,另一方面在业务需求的压力下,研发人员需要投入了巨大的精力来进行代码重构和解耦。长期来看,不同垂直业务团队面临的情况不同,未来的业务技术需求复杂度也不尽相同,这个方案是无法做到快速复用。从人力成本来说,这个方案只能短期进行工程治理,无法长期坚持下去。
2、实际收益低
从获得的收益来看,播放主业务模块外部依赖降低到 90 多个后,我们原来的预期是调试首次断点时间能降低 50%甚至更低,但是结果来看,在外部依赖已经无法解除的情况下,首次断点等待时间依然长达 120 秒以上,这样的收益结果是我们无法接受的。因此也得出来结论,在优酷 iOS 这样大型组件化多工程的模式下,我们用业务模块解耦的方式是无法根治该问题的。
通过 LLDB 分析入手
经过工程治理后,我们觉得还是应该从正面攻克该问题,从 LLDB 分析来查看根本原因并且解决。如果要分析 LLDB 入手,对于工程师来说最好的办法还是查看 Swift 源码,跑起来看一看内部的原型机制。我们首先根据苹果的文档将源码下载下来,然后进行配置,具体文档可以参考 How to Set Up an Edit-Build-Test-Debug Loop,一步一步的跟着做就可以。
由于 Swift 是依赖于 LLVM,并且在其基础上做了自己的定制化开发,所以切换分支不能只切换 Swift 源码的,需要将 LLVM 一起切到对应的分支上, 保证代码同步。正好 Swift 提供了相应的工具来帮助我们切换对应分支,只需要运行 Swift 文件下的 utils/update-checkout 相关命令即可。优酷 iOS 团队目前使用的是 Swift5.4 版本,对应 Xcode 版本为 13.2.1。
1、使用 LLVM 自带耗时工具
想要看到底在断点命中后,到底哪块最耗时,就需要使用工具来计算耗时,而这块 LLVM 有自带的工具类 TimeProfiler,里面封装了计时方法,并且输出相关 json 文件,然后可以用 chrome 自带的 tracing 工具解析后现实相关图表
2、耗时最多的两个地方
通过 TimeProfiler 对关键函数进行耗时埋点,发现有两个函数耗时较多,如下代码:
一个是 SwiftASTContext 类的 GetCompileUnitImportsImpl 方法,这个方法主要是解析当前编译单元与 Module 相关的操作,另一个则是在某一个变量如果是 Any 类型,则需要对其进行解析,找到其类型相关的操作,而最终这两个函数的操作都与当前工程的二进制依赖分析有关系,所以,如果能减少在断点命中后对依赖的分析,那么断点时间就会越快。
无效的解决方案
根据上面对源码的分析,我们最开始的考虑是否能够通过编译器的一些选项,跳过对一些 module 的扫描,从而提升首次断点速度,以比较小的成本来尽快解决。
无效方案 1 - 对编译选项的修改
通过对编译日志的分析,在构建的时候发现一个参数-serialize-debugging-options,从名字判断是用于 debug 调试的时候序列化生成调试关联产物,接着我们再通过 swiftc -frontend --help 命令发现了以下这个选项:
针对这个参数,我们进行了尝试,在 Xcode 构建设置里的 Other Swift Flags 里加上这个参数,但是从结果发现也没生效。于是我们再次查内外部资料,并且在官方 Swift 论坛发帖进行咨询,这其中有个外国的 iOS 开发者回复表示需要添加自定义 flag SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO。随后我们立刻在 Xcode 工程里加上该选项后并进行验证,从实际结果来说,首次断点速度获得了显著的提升,但也同时发现了严重的缺陷。当团队同学想要 po 打印相关变量的时候,却什么都打不出来,lldd 直接无法解析,从实际开发角度来说该方案不行。
无效方案 2 - 对依赖库的修改
在我们自己构建的 lldb 去调试工程的时候,由于编译的 lldb 是 debug 包,当命中断点后,lldb 会打印一些 debug 的 log 信息。这其中有一堆 log 非常引人注目,会持续地打好几十秒,因此我们立刻对这部份 log 俩进行分析,下面是部分截取的 log:
这块 log 是其中某一个依赖库的报错,大概问题是说在找这个库的 modulecache 的时候无法找到其路径。因为优酷 iOS 的二进制依赖库都是通过阿里远程编译集群生成,因此在生成这个库的 debug 调试信息的时候,其路径指向的是远程机器的路径。因此,在我们本地机器上去搜索这个远程服务器的地址肯定是找不到的,然后报错。
通过这个现象,我们猜测是否是因为无法找到正确的 modulecache,导致我们当前工程的整个工程 Swift 依赖库的 cache 都无法正确的构建起来,所以每次断点都得重新搜索依赖库,然后构建 cache。
那么,这个路径是哪儿带进来的呢?通过研究发现,这个路径是卸载 Mach-O 文件 DWARF 的 debug 信息里的:
那核心就在于怎么处理这个信息,想要修改相对来说有点麻烦,还得弄个 Mach-O 修改工具,那最快的方式就是去掉这个 section。编译设置里面恰好有这个选项可以直接去掉,叫做Generate Debug Symbol
。
因为报错这个 log 涉及到几百个库,即使改这个选项有用,那改一个肯定是看不出效果的,所以我们直接修改了一百来个库,将这些库在 release 编译环境下把这个选项都改为 NO,试试是否有效果。
结果令人失望,通过我们的测试,即使改了这么多库的情况,对首次断点速度也毫无提升,问题依旧存在。
既然这两种路都走不通,那 lldb 自身有相关设置吗?如果有的话那是否 lldb 的设置可以生效呢?
有效的解决方案 - LLDB 配置优化
从上述我们对 lldb 的分析上已经可以知道,调试首次断点开始,从执行到断点正式生效包含的时间主要包含两部分,其中大部分是模块依赖的 module 化解析构建,另一部分是自身 Any 类型的解析。既然业务解耦的工程化以及对编译选项的配置修改明确不可行,那我们就考虑从 lldb 自身着手,通过 setting list 命令找到所有与 Swift 调试有关的设置项,在这其中发现最关键的有两个:
memory-module-load-level
在调试时从内存加载 module 信息的级别,默认为 complete,另外还有 partial 和 minimal 两种,其中 minimal 最快。
use-swift-clangimporter
Swift 调试时是否重新构建所依赖的 module,默认值为 true。
所以我们从以上两个配置项着手,在命中任意断点时执行以下两个命令:
执行后发现断点速度明显提升,首次断点从 180 秒缩短到 40 秒,两条命令单独测试,memory-module-load-level 设置优化约 6 秒左右,其他时间优化来源于 use-swift-clangimporter 设置。在论证这个方式后,我们在此配置基础上,征集优酷及集团内部 iOS 同学试用。验证不同的开发环境后,我们惊喜地发现,首次断点时间均有大幅度提升,基本达到可用程度。
阿里巴巴集团内部验证结果如图:
配置优化后存在的问题及解决
当然,在在进行上述优化设置后,我们也发现了问题,会出现部分 OC 属性无法 po 的情况,例如 Swift 继承 OC 基类的情况:
此时“po video.sid”无法输出,但是“po video.desc”正常,这样就导致调试时有很大的局限性。通过查阅 lldb 文档发现,lldb 可以把指定代码绑定到自定义命令,所以我们可以使用这个机制解决部分属性无法 po 的问题。
首先新建 Swift 代码库,外部同学参考时可以放入到自身工程的相关基础库中,在库里实现方法:
打包后将包含该代码的模块 SDK 加入主工程依赖,再通过命令
将 px 命令绑定到 aliprint 方法,注意此处 px 为自定义命令,这样就解决了部分属性无法 po 的问题,经测试完全可用:
总结
优酷 iOS 团队在作为阿里内部 Swift 迁移的先驱,在 Swift 迁移过程中遇到了不少问题,也总结了大量的经验。调试断点是与开发体验关系最为密切点之一,我们在外部调研时候发现,大量国内的 iOS APP 研发团队也遇到了类似的问题。
考虑到国内 Swift 如火如荼的现状,我们尽快整理了该方案并分享外部,希望能在这个问题上帮助到大家。同时,如果有 iOS 团队和大神有更加优秀的解决方案,也希望能够分享出来,共同帮助国内 iOS Swift 开发生态的蓬勃发展。
目前,优酷 iOS 团队在此方向上做的投入和研究只是一个开始,后续在性能体验、编译速度、包大小优化等方向上也将积极探索,希望通过开发效能和技术的革新,为用户带来更好的优质服务体验。
关注【阿里巴巴移动技术】,阿里前沿移动干货 &实践给你思考!
版权声明: 本文为 InfoQ 作者【阿里巴巴移动技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/c3f376731bff1d3c0c7505321】。文章转载请联系作者。
评论