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

2022年05月14日 阅读数:6
这篇文章主要向大家介绍Swift 首次调试断点慢的问题解法 | 优酷 Swift 实践,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

做者:段继统 & 夏磊chrome

调试断点是与开发体验关系最为密切点之一,优酷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迁移。json

2020年末,优酷iOS消费团队完成了业务页面框架和播放器框架的Swift化,这两个框架代码量较少,内部代码结果合理清晰,并且对外部依赖较少。所以在彻底Swift化后,性能上获得了提高,而且得益于Swift的优秀语法,团队开发业务需求代码行数降低,团队效能也得到了增幅。整个过程都比较顺畅,也并未遇到明显的工程开发或者质量问题。swift

进入2021年后,在业务页面框架及播放器框架Swift版本的基础上,优酷iOS团队全面启动了业务层代码Swift迁移,而在这个阶段,Swift调试断点慢的问题开始出现并日趋严重。 在视频内容场景,核心主业务模块代码7万多行,外部依赖各类模块达200以上,在这个业务模块里,首次断点的时间恶劣状况下能够达到180秒以上,团队研发效率被严重制约。服务器

2022年初优酷iOS团队完成了80%以上业务代码的Swift迁移,调试首次断点慢的问题已经成为业务场的效率瓶颈。在内部的研发幸福感问卷调查里,97%的iOS开发同窗认为调试首次断点慢是目前研发过程的最大痛点,这个问题给iOS研发同窗带来的挫败感,足以打消Swift的其余优点。所以,解决这个问题也成为优酷iOS团队年度首要目标。框架

调试首次断点慢现象及初步分析

Swift调试断点慢主要现象是,当Xcode工程运行起来以后,咱们进行首次断点的等待时间会特别漫长。大部分状况下,工程首次断点生效后,第二次及后续断点的等待时间都十分短暂,基本能够认为无等待时间。不过从团队内部收集的状况来看,不一样Mac电脑开发设备和不一样的iOS设备表现不全一致,部分同窗首次断点以后进行断点的等待时间也极其缓慢。frontend

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

从下表中能够分析,播放器框架模块和播放主业务模块状况结合断点时间来看,断点时间彷佛与外部依赖数量呈现等比关系,因此能够初步判定断点时间和外部依赖数量存在较强的相关性。ide

另外还有一个现象,若是子工程和壳工程所依赖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。

一、使用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();

二、耗时最多的两个地方

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

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).

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同窗试用。验证不一样的开发环境后,咱们惊喜地发现,首次断点时间均有大幅度提高,基本达到可用程度。

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

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

固然,在在进行上述优化设置后,咱们也发现了问题,会出现部分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团队在此方向上作的投入和研究只是一个开始,后续在性能体验、编译速度、包大小优化等方向上也将积极探索,但愿经过开发效能和技术的革新,为用户带来更好的优质服务体验。

关注【阿里巴巴移动技术】,阿里前沿移动干货&实践给你思考!