Swift 首次除錯斷點慢的問題解法 | 優酷 Swift 實踐

阿里巴巴移動技術發表於2022-05-06

作者:段繼統 & 夏磊

除錯斷點是與開發體驗關係最為密切點之一,優酷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團隊在此方向上做的投入和研究只是一個開始,後續在效能體驗、編譯速度、包大小優化等方向上也將積極探索,希望通過開發效能和技術的革新,為使用者帶來更好的優質服務體驗。

關注【阿里巴巴移動技術】,阿里前沿移動乾貨&實踐給你思考!

相關文章