Swift 首次调试断点慢的问题解法 | 优酷 Swift 实践

477次阅读  |  发布于2年以前

前言

众所周知,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 设备表现不全一致,部分同学首次断点之后进行断点的等待时间也极其缓慢。

这个现象或者说问题在团队内部频繁出现后,我们首先与外部资深iOS开发团队交流,并附上了详细的工程文档。对方也基于反馈在内部进行了调查和验证,并最终给我们答复,表示内部并没有类似问题的发现。在交流过程中我们发现,其内部的大型 APP 工程模式都是传统的单工程模式,与国内的组件化多个工程模式截然不同。基于各方面汇总信息,我们对这个问题开始进行初步分析和解决。

从下表中可以分析,播放器框架模块和播放主业务模块情况结合断点时间来看,断点时间似乎与外部依赖数量呈现等比关系,所以可以初步断定断点时间和外部依赖数量存在较强的相关性。

代码量 外部依赖数量 断点时间
播放器框架模块 30000 行以上 20 多个 20 秒
播放主业务模块 70000 行以上 200 多个 180~200 秒以上

另外还有一个现象,如果子工程和壳工程所依赖 SDK 的 module 没有对齐,lldb 会很快断点生效,但是打印报错信息,同时无法 po 任何值。通过此现象也可以初步分析出,在断点时 lldb 对子工程依赖的 module 进行了扫描。

但仅仅依赖表象分析还不够,所以后续的工作我们从两个方向着手,第一是从播放主业务模块的解耦测试,快速解耦播放主业务模块的外部依赖,测试耦合数量的减少对断点时间是否能有帮助;第二是从 lldb 自身断点原理的分析,看首次断点如此长的时间中 lldb 究竟在做什么动作。

通过业务模块解耦入手

我们通过删除及整理工程依赖引用代码的方式,快速清理外部模块依赖,最终将播放主业务模块的外部依赖降到 90 个左右。整理完毕后,播放主业务首次调试断点时间也从 200 秒左右降到 120 秒左右,对团队开发困难现状有所缓解。但是经过实际验证和应用后,我们也发现这种依赖业务层解耦的方式是对于团队来说不可行的,根本原因有二:

改造成本高

播放主业务模块从 200 个多个模块依赖降到了 90 多个,一方面来说说对于防止工程腐化起到了积极帮助,另一方面在业务需求的压力下,研发人员需要投入了巨大的精力来进行代码重构和解耦。长期来看,不同垂直业务团队面临的情况不同,未来的业务技术需求复杂度也不尽相同,这个方案是无法做到快速复用。从人力成本来说,这个方案只能短期进行工程治理,无法长期坚持下去。

实际收益低

从获得的收益来看,播放主业务模块外部依赖降低到 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 工具解析后显示相关图表

//TimeProfiler.h 
void timeTraceProfilerBegin(StringRef Name, StringRef Detail); 
void timeTraceProfilerBegin(StringRef Name, 
                            llvm::function_ref<std::string()> Detail); 
void timeTraceProfilerEnd();

2. 耗时最多的两个地方

通过 TimeProfiler 对关键函数进行耗时埋点,发现有两个函数耗时较多,如下代码:

// SwiftASTContext.cpp
bool SwiftASTContext::GetCompileUnitImportsImpl(
    SymbolContext &sc, lldb::StackFrameWP &stack_frame_wp,
    llvm::SmallVectorImpl<swift::AttributedImport<swift::ImportedModule>>
        *modules,
    Status &error)
// SymbolFileDWARF.cpp
void SymbolFileDWARF::FindTypes(
    ConstString name, const CompilerDeclContext &parent_decl_ctx,
    uint32_t max_matches,
    llvm::DenseSet<lldb_private::SymbolFile *> &searched_symbol_files,
    TypeMap &types)

一个是 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:

warning: (arm64) /Users/ray/workspace/YouKuUniversal/Pods/SOME/SOME.framework/SOME(SOME9999999.o) 0x00004c50: unable to locate module needed for external types: /Users/remoteserver/build/14695183/workspace/iphone-out/ModuleCache.noindex/2YQ3UYLF0BE3R/UIKit-1XGSPECLTDLOB.pcm
error: '/Users/remoteserver/build/14695183/workspace/iphone-out/ModuleCache.noindex/2YQ3UYLF0BE3R/UIKit-1XGSPECLTDLOB.pcm' does not exist
Debugging will be degraded due to missing types. Rebuilding the project will regenerate the needed module files.

这块 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 调试有关的设置项,在这其中发现最关键的有两个:

1、memory-module-load-level

在调试时从内存加载 module 信息的级别,默认为 complete,另外还有 partial 和 minimal 两种,其中 minimal 最快。

memory-module-load-level            -- Loading modules from memory can be
                                         slow as reading the symbol tables and
                                         other data can take a long time
                                         depending on your connection to the
                                         debug target. This setting helps users
                                         control how much information gets
                                         loaded when loading modules from
                                         memory.'complete' is the default value
                                         for this setting which will load all
                                         sections and symbols by reading them
                                         from memory (slowest, most accurate).
                                         'partial' will load sections and
                                         attempt to find function bounds
                                         without downloading the symbol table
                                         (faster, still accurate, missing
                                         symbol names). 'minimal' is the
                                         fastest setting and will load section
                                         data with no symbols, but should
                                         rarely be used as stack frames in
                                         these memory regions will be
                                         inaccurate and not provide any context
                                         (fastest). 

2、use-swift-clangimporter

Swift 调试时是否重新构建所依赖的 module,默认值为 true。

 use-swift-clangimporter      -- Reconstruct Clang module dependencies from
                                 headers when debugging Swift code

所以我们从以上两个配置项着手,在命中任意断点时执行以下两个命令:

settings set target.memory-module-load-level minimal
settings set symbols.use-swift-clangimporter false

执行后发现断点速度明显提升,首次断点从 180 秒缩短到 40 秒,两条命令单独测试,memory-module-load-level 设置优化约 6 秒左右,其他时间优化来源于 use-swift-clangimporter 设置。在论证这个方式后,我们在此配置基础上,征集优酷及集团内部 iOS 同学试用。验证不同的开发环境后,我们惊喜地发现,首次断点时间均有大幅度提升,基本达到可用程度。

阿里巴巴集团内部验证结果如图:

环境 优化前 (s) 优化后 (s) 提升
1 i7 + 13 pro max 40 10 75.00%
2 i5+ iPhone13pro 180 30 83.33%
3 i5+iPhone12 30 3 90.00%
4 i7+ iPhone simulator 12 3 75.00%
5 M1+iPhoneX 153 38 75.16%
6 M1+iPhone 13p 52 26 50.00%
7 M1+iPhoneX 263 72 72.62%
8 M1+iPhone 13p 67 47 29.85%
9 m1+iPhoneX 180 46 74.44%
10 m1+iPhone 13 pro 50 18 64.00%
11 i7+iPhoneX 200 50 75.00%
12 m1+iPhone7p 101 17 83.17%
13 m1+iPhone11 pro max 53 22 58.49%
14 i7+iPhone12 40 10 75.00%

配置优化后存在的问题及解决

当然,在在进行上述优化设置后,我们也发现了问题,会出现部分 OC 属性无法 po 的情况,例如 Swift 继承 OC 基类的情况:

//oc
@interface OPVideo : NSObject

@property (nonatomic, strong) NSString *sid;

@end

//swift
@objc public class DetailVideoSwift: OPVideo {@objc public var desc: String?}

此时“po video.sid”无法输出,但是“po video.desc”正常,这样就导致调试时有很大的局限性。通过查阅 lldb 文档发现,lldb 可以把指定代码绑定到自定义命令,所以我们可以使用这个机制解决部分属性无法 po 的问题。

首先新建 Swift 代码库,外部同学参考时可以放入到自身工程的相关基础库中,在库里实现方法:

public func aliprint(_ target:Any?,selector:String?){
    if let target = target as AnyObject?{
        if let selector = selector {let returnValue = target.perform(NSSelectorFromString(selector))
            print("\(String(describing: returnValue?.takeUnretainedValue()))") }else{print("\(String(describing: target))")}}
}

打包后将包含该代码的模块 SDK 加入主工程依赖,再通过命令

command regex px 's/(.+) (.+)/expr -l Swift -O -- import AliOneUtils; aliprint(%1,selector:%2);/'

将 px 命令绑定到 aliprint 方法,注意此处 px 为自定义命令,这样就解决了部分属性无法 po 的问题,经测试完全可用:

总结

优酷 iOS 团队在作为阿里内部 Swift 迁移的先驱,在 Swift 迁移过程中遇到了不少问题,也总结了大量的经验。调试断点是与开发体验关系最为密切点之一,我们在外部调研时候发现,大量国内的 iOS APP 研发团队也遇到了类似的问题。

考虑到国内 Swift 如火如荼的现状,我们尽快整理了该方案并分享外部,希望能在这个问题上帮助到大家。同时,如果有 iOS 团队和大神有更加优秀的解决方案,也希望能够分享出来,共同帮助国内 iOS Swift 开发生态的蓬勃发展。

目前,优酷 iOS 团队在此方向上做的投入和研究只是一个开始,后续在性能体验、编译速度、包大小优化等方向上也将积极探索,希望通过开发效能和技术的革新,为用户带来更好的优质服务体验。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8