乐趣区

关于swift: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 工具解析后事实相干图表

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

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 团队在此方向上做的投入和钻研只是一个开始,后续在性能体验、编译速度、包大小优化等方向上也将积极探索,心愿通过开发效力和技术的变革,为用户带来更好的优质服务体验。

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

退出移动版