
前言
啟動優(yōu)化一役后,超預(yù)期將所負(fù)責(zé)的 App 雙端啟動的耗時都降低了65%以上,iOS 在iPhone7上速度達(dá)到了400毫秒以內(nèi)。就像產(chǎn)品們用后說的,快到不習(xí)慣。由于 App 日活用戶過億,算一下每天為用戶省下的時間,還是蠻有成就感的。

啟動階段性能多維度分析
要優(yōu)化,先要做到的是對啟動階段各個性能緯度做分析,包括主線程耗時、CPU、內(nèi)存、I/O、網(wǎng)絡(luò)。這樣才能夠更加全面的掌握啟動階段的開銷,找出不合理的方法調(diào)用。啟動越快,更多的方法調(diào)用就應(yīng)該做成按需執(zhí)行,將啟動壓力分?jǐn)?,只留下那些啟動后方法都會依賴的方法和庫的初始化,比如網(wǎng)絡(luò)庫、Crash 庫等。而剩下那些需要預(yù)加載的功能可以放到啟動階段后再執(zhí)行。
啟動有哪幾種類型,啟動有哪些階段呢?
啟動類型分為:
- Cold:App 重啟后啟動,不在內(nèi)存里也沒有進(jìn)程存在。
- Warm:App 最近結(jié)束后再啟動,有部分在內(nèi)存但沒有進(jìn)程存在。
- Resume:App 沒結(jié)束,只是暫停,全在內(nèi)存中,進(jìn)程也存在。
分析階段一般都是針對 Cold 類型進(jìn)行分析,目的就是要讓測試環(huán)境穩(wěn)定。為了穩(wěn)定測試環(huán)境有時還需要找些穩(wěn)定的機(jī)型,對于 iOS 來說iPhone7性能中等,穩(wěn)定性也不錯就很適合,Android 的 Vivo 系列也相對穩(wěn)定,華為和小米系列數(shù)據(jù)波動就比較大。除了機(jī)型外控制測試機(jī)溫度也很重要,一旦溫度過高系統(tǒng)還會降頻執(zhí)行影響測試數(shù)據(jù)。有時候還會置飛行模式采用 Mock 網(wǎng)絡(luò)請求的方式來減少不穩(wěn)定的網(wǎng)絡(luò)影響測試數(shù)據(jù)。最好時重啟后退 iCloud 賬號,放置一段時間再測,更加準(zhǔn)確些。

了解啟動的階段目的就是聚焦范圍,從用戶體驗(yàn)上來確定哪個階段要快,以便能夠讓用戶可視和響應(yīng)用戶操作的時間更快。
簡單來說 iOS 啟動分為加載 Mach-O 和運(yùn)行時初始化過程,加載 Mach-O 會先判斷加載的文件是不是 Mach-O,通過文件第一個字節(jié),也叫魔數(shù)來判斷,當(dāng)是下面四種時可以判定是 Mach-O 文件:
- 0xfeedface 對應(yīng)的 loader.h 里的宏是 MH_MAGIC
- 0xfeedfact 宏是 MH_MAGIC_64
- NXSwapInt(MH_MAGIC) 宏 MH_GIGAM
- NXSwapInt(MH_MAGIC_64) 宏 MH_GIGAM_64
Mach-O 分為主要分為 中間對象文件(MH_OBJECT)、可執(zhí)行二進(jìn)制(MH_EXECUTE)、VM 共享庫文件(MH_FVMLIB)、Crash 產(chǎn)生的 Core 文件(MH_CORE)、preload(MH_PRELOAD)、動態(tài)共享庫(MH_DYLIB)、動態(tài)鏈接器(MH_DYLINKER)、靜態(tài)鏈接文件(MH_DYLIB_STUB)、符號文件和調(diào)試信息(MH_DSYM)這幾種。確定是 Mach-O 后,內(nèi)核會 fork 一個進(jìn)程,execve 開始加載。檢查 Mach-O Header。隨后加載 dyld 和程序到 Load Command 地址空間。通過 dyld_stub_binder 開始執(zhí)行 dyld,dyld 會進(jìn)行 rebase、binding、lazy binding、導(dǎo)出符號,也可以通過 DYLD_INSERT_LIBRARIES 進(jìn)行 hook。dyld_stub_binder 給偏移量到 dyld 解釋特殊字節(jié)碼 Segment 中,也就是真實(shí)地址,把真實(shí)地址寫入到 la_symbol_ptr 里,跳轉(zhuǎn)時通過 stub 的 jump 指令跳轉(zhuǎn)到真實(shí)地址。 dyld 加載所有依賴庫,將動態(tài)庫導(dǎo)出的 trie 結(jié)構(gòu)符號執(zhí)行符號綁定,也就是 non lazybinding,綁定解析其他模塊功能和數(shù)據(jù)引用過程,就是導(dǎo)入符號。
Trie 也叫數(shù)字樹或前綴樹,是一種搜索樹。查找復(fù)雜度 O(m),m 是字符串的長度。和散列表相比,散列最差復(fù)雜度是 O(N),一般都是 O(1),用 O(m)時間評估 hash。散列缺點(diǎn)是會分配一大塊內(nèi)存,內(nèi)容越多所占內(nèi)存越大。Trie 不僅查找快,插入和刪除都很快,適合存儲預(yù)測性文本或自動完成詞典。為了進(jìn)一步優(yōu)化所占空間,可以將 Trie 這種樹形的確定性有限自動機(jī)壓縮成確定性非循環(huán)有限狀態(tài)自動體(DAFSA),其空間小,做法是會壓縮相同分支。對于更大內(nèi)容,還可以做更進(jìn)一步的優(yōu)化,比如使用字母縮減的實(shí)現(xiàn)技術(shù),把原來的字符串重新解釋為較長的字符串;使用單鏈?zhǔn)搅斜?,?jié)點(diǎn)設(shè)計(jì)為由符號、子節(jié)點(diǎn)、下一個節(jié)點(diǎn)來表示;將字母表數(shù)組存儲為代表 ASCII 字母表的256位的位圖。
盡管 Trie 對于性能會做很多優(yōu)化,但是符號過多依然會增加性能消耗,對于動態(tài)庫導(dǎo)出的符號不宜太多,盡量保持公共符號少,私有符號集豐富。這樣維護(hù)起來也方便,版本兼容性也好,還能優(yōu)化動態(tài)加載程序到進(jìn)程的時間。
然后執(zhí)行 attribute 的 constructor 函數(shù)。舉個例子:
#include <stdio.h>
__attribute__((constructor))
static void prepare() {
printf("%s\n", "prepare");
}
__attribute__((destructor))
static void end() {
printf("%s\n", "end");
}
void showHeader() {
printf("%s\n", "header");
}
運(yùn)行結(jié)果:
ming@mingdeMacBook-Pro macho_demo % ./main "hi"
prepare
hi
end
運(yùn)行時初始化過程 分為:
- 加載類擴(kuò)展
- 加載 C++靜態(tài)對象
- 調(diào)用+load 函數(shù)
- 執(zhí)行 main 函數(shù)
- Application 初始化,到 applicationDidFinishLaunchingWithOptions 執(zhí)行完
- 初始化幀渲染,到 viewDidAppear 執(zhí)行完,用戶可見可操作。
過程概括起來如下圖:

也就是說對啟動階段的分析以 viewDidAppear 為截止。這次優(yōu)化之前已經(jīng)對 Application 初始化之前做過優(yōu)化,效果并不明顯,沒有本質(zhì)的提高,所以這次主要針對 Application 初始化到 viewDidAppear 這個階段各個性能多緯度進(jìn)行分析。多維度具體包含內(nèi)容如下圖:

工具的選擇其實(shí)目前看來是很多的,Apple 提供的 System Trace 會提供全面系統(tǒng)的行為,可以顯示底層系統(tǒng)線程和內(nèi)存調(diào)度情況,分析鎖、線程、內(nèi)存、系統(tǒng)調(diào)用等問題??偟膩碚f,通過 System Trace 你能清楚知道每時每刻 App 對系統(tǒng)資源使用情況。
System Trace 能查看線程的狀態(tài),可以了解高優(yōu)線程使用相對于 CPU 數(shù)量是否合理,可以看到線程在執(zhí)行、掛起、上下文切換、被打斷還是被搶占的情況。虛擬內(nèi)存使用產(chǎn)生的耗時也能看到,比如分配物理內(nèi)存,內(nèi)存解壓縮,無緩存時進(jìn)行緩存的耗時等。甚至是發(fā)熱情況也能看到。
System Trace 還提供手動打點(diǎn)進(jìn)行信息顯式,在你的代碼中 導(dǎo)入 sys/kdebug_signpost.h 后,配對 kdebug_signpost_start 和 kdebug_signpost_end 就可以了。這兩個方法有五個參數(shù),第一個是 id,最后一個是顏色,中間都是預(yù)留字段。
Xcode11開始 XCTest 還提供了測量性能的 Api。蘋果在2019年 WWDC 啟動優(yōu)化專題 Optimizing App Launch - WWDC 2019 - Videos - Apple Developer 上也介紹了 Instruments 里的最新模板 App launch 如何分析啟動性能。但是要想達(dá)到對啟動數(shù)據(jù)進(jìn)行留存取均值、Diff、過濾、關(guān)聯(lián)分析等自動化操作,App launch 目前還沒法做到。
主線程耗時
多個維度性能緯度分析中最重要,最終用戶體感到的是主線程耗時分析。對主線程方法耗時可以直接使用Messier - 簡單易用的Objective-C方法跟蹤工具 - everettjf - 首先很有趣 生成 trace json 進(jìn)行分析,或者參看這個代碼GCDFetchFeed/SMCallTraceCore.c at master · ming1016/GCDFetchFeed · GitHub,自己手動 hook objc_msgSend 生成一份Objective-C 方法耗時數(shù)據(jù)進(jìn)行分析。還有種插樁方式,可以解析 IR(加快編譯速度),然后在每個方法前后插入耗時統(tǒng)計(jì)函數(shù)。文章后面我會著重介紹如何開發(fā)工具進(jìn)一步分析這份數(shù)據(jù),以達(dá)到監(jiān)控啟動階段方法耗時的目的。
hook 所有的方法調(diào)用,對詳細(xì)分析時很有用,不過對于整個啟動時間影響很大,要想獲取啟動每個階段更準(zhǔn)確的時間消耗還需要依賴手動埋點(diǎn)。為了更好的分析啟動耗時問題,手動埋點(diǎn)也會埋的越來越多,也會影響啟動時間精確度,特別是當(dāng)團(tuán)隊(duì)很多,模塊很多時,問題會突出。但,每個團(tuán)隊(duì)在排查啟動耗時往往只會關(guān)注自己或相關(guān)某幾個模塊的分析,基于此,可以把不同模塊埋點(diǎn)分組,靈活組合,這樣就可以照顧到多種需求了。
CPU
為什么分析啟動慢除了分析主線程方法耗時外,還要分析其它緯度的性能呢?
我們先看看啟動慢的表現(xiàn),啟動慢意味著界面響應(yīng)慢、網(wǎng)絡(luò)慢(數(shù)據(jù)量大、請求數(shù)多)、CPU 超負(fù)荷降頻(并行任務(wù)多、運(yùn)算多),可以看出影響啟動的因素很多,還需要全面考慮。
對于 CPU 來說,WWDC 的 What’s New in Energy Debugging - WWDC 2018 - Videos - Apple Developer 里介紹了用 Energy Log 來查 CPU 耗電,當(dāng)前臺三分鐘或后臺一分鐘 CPU 線程連續(xù)占用80%以上就判定為耗電,同時記錄耗電線程堆棧供分析。還有一個 MetrickKit 專門用來收集電源和性能統(tǒng)計(jì)數(shù)據(jù),每24小時就會對收集的數(shù)據(jù)進(jìn)行匯總上報,Mattt 在 NShipster 網(wǎng)站上也發(fā)了篇文章MetricKit - NSHipster專門進(jìn)行了介紹。那么 CPU 的詳細(xì)使用情況如何獲取呢?也就是說哪個方法用了多少 CPU。
有好幾種獲取詳細(xì) CPU 使用情況的方法。線程是計(jì)算機(jī)資源調(diào)度和分配的基本單位。CPU 使用情況會提現(xiàn)到線程這樣的基本單位上。task_theads 的 act_list 數(shù)組包含所有線程,使用 thread_info 的接口可以返回線程的基本信息,這些信息定義在 thread_basic_info_t 結(jié)構(gòu)體中。這個結(jié)構(gòu)體內(nèi)的信息包含了線程運(yùn)行時間、運(yùn)行狀態(tài)以及調(diào)度優(yōu)先級,其中也包含了 CPU 使用信息 cpu_usage。獲取方式參看 objective c - Get detailed iOS CPU usage with different states - Stack Overflow。GT GitHub - Tencent/GT: GT (Great Tit) is a portable debugging tool for bug hunting and performance tuning on smartphones anytime and anywhere just as listening music with Walkman. GT can act as the Integrated Debug Environment by directly running on smartphones. 里也有獲取 CPU 的代碼。
整體 CPU 占用率可以通過 host_statistics 函數(shù)可以取到 host_cpu_load_info,其中 cpu_ticks 數(shù)組是 CPU 運(yùn)行的時鐘脈沖數(shù)量。通過 cpu_ticks 數(shù)組里的狀態(tài),可以分別獲取 CPU_STATE_USER、CPU_STATE_NICE、CPU_STATE_SYSTEM 這三個表示使用中的狀態(tài),除以整體 CPU 就可以取到 CPU 的占比。通過 NSProcessInfo 的 activeProcessorCount 還可以得到 CPU 的核數(shù)。線上數(shù)據(jù)分析時會發(fā)現(xiàn)相同機(jī)型和系統(tǒng)的手機(jī),性能表現(xiàn)卻截然不同,這是由于手機(jī)過熱或者電池?fù)p耗過大后系統(tǒng)降低了 CPU 頻率所致。所以如果取得 CPU 頻率后也可以針對那些降頻的手機(jī)來進(jìn)行針對性的優(yōu)化,以保證流暢體驗(yàn)。獲取方式可以參考 GitHub - zenny-chen/CPU-Dasher-for-iOS: CPU Dasher for iOS source code. It only supports ARMv7 and ARMv7s architectures.
內(nèi)存
要想獲取 App 真實(shí)的內(nèi)存使用情況可以參看 WebKit 的源碼,webkit/MemoryFootprintCocoa.cpp at 52bc6f0a96a062cb0eb76e9a81497183dc87c268 · WebKit/webkit · GitHub 。JetSam會判斷 App 使用內(nèi)存情況,超出閾值就會殺死 App,JetSam 獲取閾值的代碼在 darwin-xnu/kern_memorystatus.c at 0a798f6738bc1db01281fc08ae024145e84df927 · apple/darwin-xnu · GitHub。整個設(shè)備物理內(nèi)存大小可以通過 NSProcessInfo 的 physicalMemory 來獲取。
網(wǎng)絡(luò)
對于網(wǎng)絡(luò)監(jiān)控可以使用 Fishhook 這樣的工具 Hook 網(wǎng)絡(luò)底層庫 CFNetwork。網(wǎng)絡(luò)的情況比較復(fù)雜,所以需要定些和時間相關(guān)的關(guān)鍵的指標(biāo),指標(biāo)如下:
- DNS 時間
- SSL 時間
- 首包時間
- 響應(yīng)時間
有了這些指標(biāo)才能夠有助于更好的分析網(wǎng)絡(luò)問題。啟動階段的網(wǎng)絡(luò)請求是非常多的,所以 HTTP 的性能是非常要注意的。以下是 WWDC 網(wǎng)絡(luò)相關(guān)的 Session:
- Your App and Next Generation Networks - WWDC 2015 - Videos - Apple Developer
- Networking with NSURLSession - WWDC 2015 - Videos - Apple Developer
- Networking for the Modern Internet - WWDC 2016 - Videos - Apple Developer
- Advances in Networking, Part 1 - WWDC 2017 - Videos - Apple Developer
- Advances in Networking, Part 2 - WWDC 2017 - Videos - Apple Developer
- Optimizing Your App for Today’s Internet - WWDC 2018 - Videos - Apple Developer
I/O
對于 I/O 可以使用 Frida ? A world-class dynamic instrumentation framework | Inject JavaScript to explore native apps on Windows, macOS, GNU/Linux, iOS, Android, and QNX 這種動態(tài)二進(jìn)制插樁技術(shù),在程序運(yùn)行時去插入自定義代碼獲取 I/O 的耗時和處理的數(shù)據(jù)大小等數(shù)據(jù)。Frida 還能夠在其它平臺使用。
關(guān)于多維度分析更多的資料可以看看歷屆 WWDC 的介紹。下面我列下16年來 WWDC 關(guān)于啟動優(yōu)化的 Session,每場都很精彩。

- Using Time Profiler in Instruments - WWDC 2016 - Videos - Apple Developer
- Optimizing I/O for Performance and Battery Life - WWDC 2016 - Videos - Apple Developer
- Optimizing App Startup Time - WWDC 2016 - Videos - Apple Developer
- App Startup Time: Past, Present, and Future - WWDC 2017 - Videos - Apple Developer
- Practical Approaches to Great App Performance - WWDC 2018 - Videos - Apple Developer
- Optimizing App Launch - WWDC 2019 - Videos - Apple Developer
延后任務(wù)管理
經(jīng)過前面所說的對主線程耗時方法和各個緯度性能分析后,對于那些分析出來沒必要在啟動階段執(zhí)行的方法,可以做成按需或延后執(zhí)行。 任務(wù)延后的處理不能粗獷的一口氣在啟動完成后在主線程一起執(zhí)行,那樣用戶僅僅只是看到了頁面,依然沒法響應(yīng)操作。那該怎么做呢?套路一般是這樣,創(chuàng)建四個隊(duì)列,分別是:
- 異步串行隊(duì)列
- 異步并行隊(duì)列
- 閑時主線程串行隊(duì)列
- 閑時異步串行隊(duì)列

有依賴關(guān)系的任務(wù)可以放到異步串行隊(duì)列中執(zhí)行。異步并行隊(duì)列可以分組執(zhí)行,比如使用 dispatch_group,然后對每組任務(wù)數(shù)量進(jìn)行限制,避免 CPU、線程和內(nèi)存瞬時激增影響主線程用戶操作,定義有限數(shù)量的串行隊(duì)列,每個串行隊(duì)列做特定的事情,這樣也能夠避免性能消耗短時間突然暴漲引起無法響應(yīng)用戶操作。使用 dispatch_semaphore_t 在信號量阻塞主隊(duì)列時容易出現(xiàn)優(yōu)先級反轉(zhuǎn),需要減少使用,確保QoS傳播。可以用dispatch group 替代,性能一樣,功能不差。異步編程可以直接 GCD 接口來寫,也可以使用阿里的協(xié)程框架 coobjc coobjc。
作為一個開發(fā)者,有一個學(xué)習(xí)的氛圍跟一個交流圈子特別重要,這有個iOS交流群:642363427,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經(jīng)驗(yàn),討論技術(shù),iOS開發(fā)者一起交流學(xué)習(xí)成長!
閑時隊(duì)列實(shí)現(xiàn)方式是監(jiān)聽主線程 runloop 狀態(tài),在 kCFRunLoopBeforeWaiting 時開始執(zhí)行閑時隊(duì)列里的任務(wù),在 kCFRunLoopAfterWaiting 時停止。
優(yōu)化后如何保持?
攻易守難,就像剛到新團(tuán)隊(duì)時將包大小減少了48兆,但是一年多一直能夠守住除了決心還需要有手段。對于啟動優(yōu)化來說,將各個性能緯度通過監(jiān)控的方式盯住是必要的,但是發(fā)現(xiàn)問題后快速、便捷的定位到問題還是需要找些突破口。我的思路是將啟動階段方法耗時多的按照時間線一條一條排出來,每條包括方法名、方法層級、所屬類、所屬模塊、維護(hù)人??紤]到便捷性,最好還能方便的查看方法代碼內(nèi)容。
接下來我通過開發(fā)一個工具,跟你詳細(xì)說說怎么實(shí)現(xiàn)這樣的效果。設(shè)計(jì)最終希望展示內(nèi)容如下:

解析 json
如前面所說在輸出一份 Chrome trace 規(guī)范的方法耗時 json 后,先要解析這份數(shù)據(jù)。這份 json 數(shù)據(jù)類似下面的樣子:
{"name":"[SMVeilweaa]upVeilState:","cat":"catname","ph":"B","pid":2381,"tid":0,"ts":21},
{"name":"[SMVeilweaa]tatLaunchState:","cat":"catname","ph":"B","pid":2381,"tid":0,"ts":4557},
{"name":"[SMVeilweaa]tatTimeStamp:state:","cat":"catname","ph":"B","pid":2381,"tid":0,"ts":4686},
{"name":"[SMVeilweaa]tatTimeStamp:state:","cat":"catname","ph":"E","pid":2381,"tid":0,"ts":4727},
{"name":"[SMVeilweaa]tatLaunchState:","cat":"catname","ph":"E","pid":2381,"tid":0,"ts":5732},
{"name":"[SMVeilweaa]upVeilState:","cat":"catname","ph":"E","pid":2381,"tid":0,"ts":5815},
…
通過 Chrome 的 Trace-Viewer 可以生成一個火焰圖。其中 name 字段包含了類、方法和參數(shù)的信息,cat 字段可以加入其它性能數(shù)據(jù),ph 為 B 表示方法開始,為 E 表示方法結(jié)束,ts 字段表示。
json 分詞

讀取 json 文件
// 根據(jù)文件路徑返回文件內(nèi)容
public static func fileContent(path: String) -> String {
do {
return try String(contentsOfFile: path, encoding: String.Encoding.utf8)
} catch {
return “”
}
}
let bundlePath = Bundle.main.path(forResource: “startTrace”, ofType: “json”)
let jsonPath = bundlePath ?? “”
let jsonContent = FileHandle.fileContent(path: jsonPath)
jsonContent 就是 json 內(nèi)容字符串。寫一個字符切割函數(shù)將字符串按照自定義符號集來切割。
public func allTkFast(operaters:String) -> [Token] {
var nText = text.replacingOccurrences(of: “ “, with: “ starmingspace “)
nText = nText.replacingOccurrences(of: “\n”, with: “ starmingnewline “)
let scanner = Scanner(string: nText)
var tks = [Token]()
var set = CharacterSet()
set.insert(charactersIn: operaters)
set.formUnion(CharacterSet.whitespacesAndNewlines)
while !scanner.isAtEnd {
for operater in operaters {
let opStr = operater.description
if (scanner.scanString(opStr) != nil) {
tks.append(.id(opStr))
}
}
var result:NSString?
result = nil
if (scanner.scanUpToCharacters(from: set) != nil) {
let resultString = result! as String
if resultString == “starmingnewline” {
tks.append(.newLine)
} else if resultString == “starmingspace” {
tks.append(.space)
} else {
tks.append(.id(result! as String))
}
}
}
tks.append(.eof)
return tks
}
將切割的字符保存為 Token 結(jié)構(gòu)體的一個個 token。Token 結(jié)構(gòu)體定義如下:
public enum Token {
case eof
case newLine
case space
case comments(String) // 注釋
case constant(Constant) // float、int
case id(String) // string
case string(String) // 代碼中引號內(nèi)字符串
}
public enum Constant {
case string(String)
case integer(Int)
case float(Float)
case boolean(Bool)
}
代碼中的 eof 表示 token 是文件結(jié)束,newLine 是換行 token。Constant 是枚舉關(guān)聯(lián)值,通過枚舉關(guān)聯(lián)值可以使枚舉能夠具有更多層級。后面還需要將枚舉值進(jìn)行判等比較,所以還需要擴(kuò)展枚舉的 Equatable 協(xié)議實(shí)現(xiàn):
extension Token: Equatable {
public static func == (lhs: Token, rhs: Token) -> Bool {
switch (lhs, rhs) {
case (.eof, .eof):
return true
case (.newLine, .newLine):
return true
case (.space, .space):
return true
case let (.constant(left), .constant(right)):
return left == right
case let (.comments(left), .comments(right)):
return left == right
case let (.id(left), .id(right)):
return left == right
case let (.string(left), .string(right)):
return left == right
default:
return false
}
}
}
通用的 token 結(jié)構(gòu)解析完成。接下來就是設(shè)計(jì)一個 json 特有的 token 結(jié)構(gòu)。對于 json 來說換行和空格可以過濾掉,寫個函數(shù)過濾換行和空格的 token:
public func allTkFastWithoutNewLineAndWhitespace(operaters:String) -> [Token] {
let allToken = allTkFast(operaters: operaters)
let flAllToken = allToken.filter {
$0 != .newLine
}
let fwAllToken = flAllToken.filter {
$0 != .space
}
return fwAllToken
}
json 的操作符有:
{}[]”:,
所以 operaters 參數(shù)可以是這些操作符。完整的 Lexer 類代碼在 MethodTraceAnalyze/Lexer.swift。使用 Lexer 類的 allTkFastWithoutNewLineAndWhitespace 方法可以取得 token 集合。
JSONToken
為了轉(zhuǎn)成 json 的 token,我先設(shè)計(jì)一個 json token 的結(jié)構(gòu) JSONToken。
public struct JSONToken {
public let type: JSONTokenType
public let value: String
}
public enum JSONTokenType {
case startDic // {
case endDic // }
case startArray // [
case endArray // ]
case key // key
case value // value
}
根據(jù) json 的本身設(shè)計(jì),主要分為 key 和 value,另外還需要些符號類型,用來進(jìn)行進(jìn)一步的解析。解析過程的狀態(tài)設(shè)計(jì)為三種,用 State 枚舉表示:
private enum State {
case normal
case keyStart
case valueStart
}
在 normal 狀態(tài)下,會記錄操作符類型的 json token,當(dāng)遇到{符號后,下一個是“符號就會更改狀態(tài)為 keyStart。另一種情況就是在遇到,符號后,下一個是”符號也會更改狀態(tài)為 keyStart。
狀態(tài)更改成 valueStart 的條件是遇到:符號,當(dāng)下一個是“時進(jìn)入 valueStart 狀態(tài),如果不是“符號,就需要做區(qū)分,是{或者[時直接跳過:符號,然后記錄這兩個操作符。其它情況表示 value 不是字符而是數(shù)字,直接記錄為 json token 就可以了。完整 json token 的解析代碼見 MethodTraceAnalyze/ParseJSONTokens.swift。
JSONToken 集合目前還只是扁平態(tài),而 json 數(shù)據(jù)是有 key 和 value 的多級關(guān)系在的,比如 value 可能是字符串或數(shù)字,也可能是另一組 key value 結(jié)構(gòu)或者 value 的數(shù)組集合。所以下面還需要定義一個 JSONItem 結(jié)構(gòu)來容納多級關(guān)系。
JSONItem

JSONItem 的結(jié)構(gòu)體定義如下:
public struct JSONItem {
public var type: JSONItemType
public var value: String
public var kvs: [JSONItemKv]
public var array: [JSONItem]
}
// 類型
public enum JSONItemType {
case keyValue
case value
case array
}
// key value 結(jié)構(gòu)體
public struct JSONItemKv {
public var key: String
public var value: JSONItem
}
JSONItem 的類型分三種,key value、value 和 array 的,定義在 JSONItemType 枚舉中。分別對應(yīng)的三個存儲字段是 kvs,里面是 JSONItemKv 類型的集合;value 為字符串;array 是 JSONItem 的集合。
定義好了多層級的結(jié)構(gòu),就可以將 JSONToken 的集合進(jìn)行分析,轉(zhuǎn)到 JSONItem 結(jié)構(gòu)上。思路是在解析過程中碰到閉合符號時,將扁平的閉合區(qū)間內(nèi)的 JSONToken 放到集合里,通過遞歸函數(shù) recursiveTk 遞歸出多層級結(jié)構(gòu)出來。所以需要設(shè)置四個狀態(tài):
enum rState {
case normal
case startDic
case startArr
case startKey
}
當(dāng)碰到{符號進(jìn)入 startDic 狀態(tài),遇到[符號進(jìn)入 startKey 狀態(tài),遇到}和]符號時會結(jié)束這兩個狀態(tài)。在 startDic 或 startKey 狀態(tài)中時會收集過程中的 JSONToken 到 recursiveTkArr 集合里。這個分析完整代碼在這 MethodTraceAnalyze/ParseJSONItem.swift。
來一段簡單的 json 測試下:
{
“key1”: “value1”,
“key2”: 22,
“key3”: {
“subKey1”: “subValue1”,
“subKey2”: 40,
“subKey3”:[
{
“sub1Key1”: 10,
“sub1Key2”:{
“sub3Key1”: “sub3Value1”,
“sub3Key2”: “sub3Value2”
}
},
{
“sub1Key1”: 11,
“sub1Key2”: 15
}
],
“subKey4”: [
“value1”,
23,
“value2”
],
“subKey5”: 2
}
}
使用 ParseJSONItem 來解析
let jsonOPath = Bundle.main.path(forResource: “test”, ofType: “json”)
let jOrgPath = jsonOPath ?? “”
let jsonOContent = FileHandle.fileContent(path: jOrgPath)
let item = ParseJSONItem(input: jsonOContent).parse()
得到的 item 數(shù)據(jù)如下圖所示

可以看到,item 的結(jié)構(gòu)和前面的 json 結(jié)構(gòu)是一致的。
json 單測
為了保證后面對 json 的解析修改和完善對上面列的測試 case 解析結(jié)果不會有影響,可以寫個簡單測試類來做。這個類只需要做到將實(shí)際結(jié)果和預(yù)期值做比較,相等即可通過,不等即可提示并中斷,方便定位問題。因此傳入?yún)?shù)只需要有運(yùn)行結(jié)果、預(yù)期結(jié)果、描述就夠用了。我寫個 Test 協(xié)議,通過擴(kuò)展默認(rèn)實(shí)現(xiàn)一個比較的方法,以后需要單測的類遵循這個協(xié)議就可以使用和擴(kuò)展單測功能了。Test 協(xié)議具體代碼如下:
protocol Test {
static func cs(current:String, expect:String, des:String)
}
// compare string 對比兩個字符串值
extension Test {
static func cs(current:String, expect: String, des: String) {
if current == expect {
print(“? \(des) ok,符合預(yù)期值:\(expect)”)
} else {
let msg = “? \(des) fail,不符合預(yù)期值:\(expect)”
print(msg)
assertionFailure(msg)
}
}
}
寫個 TestJSON 遵循 Test 協(xié)議進(jìn)行單測。測試各個解析后的值,比如測試 item第一級 key value 配對數(shù)量可以這樣寫:
let arr = item.array[0].kvs
cs(current: “\(arr.count)”, expect: “3”, des: “all dic count”)
打印的結(jié)果就是:
? all dic count ok,符合預(yù)期值:3
完整單測代碼在這里:MethodTraceAnalyze/TestJSON.swift
解析 Launch Trace 的 json
前面說的 JSONItem 是通用的多層級 json 結(jié)構(gòu)體。對于啟動的 json,實(shí)際要表現(xiàn)的方法調(diào)用鏈和 json 的層級并不是對應(yīng)的。方法調(diào)用鏈?zhǔn)峭ㄟ^ ph 字段表示,B 表示方法開始,E 表示方法結(jié)束,中間會有其它方法調(diào)用的閉合,這些方法在調(diào)用鏈里可以被稱為調(diào)用方法的子方法。
為了能夠表現(xiàn)出這樣的調(diào)用鏈關(guān)系,我設(shè)計(jì)了下面的 LaunchItem 結(jié)構(gòu):

結(jié)構(gòu)體代碼如下:
public struct LaunchItem {
public let name: String // 調(diào)用方法
public var ph: String // B 代表開始、E 代表結(jié)束、BE 代表合并后的 Item、其它代表描述
public var ts: String // 時間戳,開始時間
public var cost: Int // 耗時 ms
public var times: Int // 執(zhí)行次數(shù)
public var subItem: [LaunchItem] // 子 item
public var parentItem:[LaunchItem] // 父 item
}
通過 ParseJSONTokens 類來獲取 JSONToken 的集合。
tks = ParseJSONTokens(input: input).parse()
找出 name、ph、ts 字段數(shù)據(jù)轉(zhuǎn)到 LaunchItem 結(jié)構(gòu)體中。這部分代碼實(shí)現(xiàn)在這里 MethodTraceAnalyze/ParseLaunchJSON.swift。
遍歷 LaunchItem 集合,完善 LaunchItem 的信息,先完善 LaunchItem 的 cost 和 subItem 的信息。在方法調(diào)用鏈同一級時依據(jù) ph 字段將相同方法 B 和 E 之間的 LaunchItem 都放到一個數(shù)組里,通過棧頂和棧底的 ts 字段值相減就能夠得到 cost 的值,也就是方法的耗時,代碼如下:
let b = itemArr[0]
let e = itemArr[itemArr.count - 1]
let cost = Int(e.ts)! - Int(b.ts)!
當(dāng)這個數(shù)組數(shù)量大于2,代表方法里還會調(diào)用其它的方法,通過遞歸將調(diào)用鏈中的子方法都取出來,并放到 subItem 里。
pItem.subItem.append(recusiveMethodTree(parentItem: rPItem, items: newItemArr))
代碼見MethodTraceAnalyze/LaunchJSON.swift里的 launchJSON 函數(shù)。
展示啟動方法鏈
前面通過 launchJSON 函數(shù)取到了方法調(diào)用鏈的根部 LaunchItem。使用 recusiveItemTree 函數(shù)遞歸這個根 LaunchItem ,可以輸出方法調(diào)用關(guān)系圖。很多工程在啟動階段會執(zhí)行大量方法,很多方法耗時很少,可以過濾那些小于10毫秒的方法,讓分析更加聚焦。

展示效果如上圖所示,完整代碼在 MethodTraceAnalyze/LaunchJSON.swift 里的 tree 函數(shù)里。圖中的階段切換,比如 T1到 T2的切換可以在 recusiveItemTree 函數(shù)中設(shè)置,對應(yīng)的處理代碼是:
// 獲取 T1 到 T5 階段信息,其中 updateLauncherState 函數(shù)名需要替換成自己階段切換的函數(shù)名,最多5個階段
if methodName == “updateLauncherState:” {
currentT += 1
if currentT > 5 {
currentT = 5
}
}
耗時的高低也做了顏色的區(qū)分。外部耗時指的是子方法以外系統(tǒng)或沒源碼的三方方法的耗時,規(guī)則是父方法調(diào)用的耗時減去其子方法總耗時。代碼如下:
// 獲取外部耗時
var sysCost = 0
if aItem.subItem.count > 0 {
for aSubItem in aItem.subItem {
sysCost += aSubItem.cost
}
}
sysCost = (aItem.cost - sysCost) / 1000
bundle、owner、業(yè)務(wù)線這三項(xiàng)需要根據(jù)自己工程情況來,如果工程使用的是 excel 做的記錄可以導(dǎo)出為 csv 格式文件,參考 LaunchJSON 類里的 loadSimpleKeyValueDicWithCsv 函數(shù)進(jìn)行 csv 數(shù)據(jù)讀取。如果數(shù)據(jù)是在服務(wù)端,輸出為 json 的話就更好辦了,使用前面寫的 ParseJSONItem 類就能夠進(jìn)行數(shù)據(jù)解析了,可以參考 LaunchJSON 類里的 parseBundleOwner 函數(shù)。展示示例里我先置為默認(rèn)的暫無了。
目前為止通過過濾耗時少的方法調(diào)用,可以更容易發(fā)現(xiàn)問題方法。但是,有些方法單次執(zhí)行耗時不多,但是會執(zhí)行很多次,累加耗時會大,這樣的情況也需要體現(xiàn)在展示頁面里。另外外部耗時高時或者碰到自己不了解的方法時,是需要到工程源碼里去搜索對應(yīng)的方法源碼進(jìn)行分析的,有的方法名很通用時還需要花大量時間去過濾無用信息。
因此接下來還需要做兩件事情,首先累加方法調(diào)用次數(shù)和耗時,體現(xiàn)在展示頁面中,另一個是從工程中獲取方法源碼能夠在展示頁面中進(jìn)行點(diǎn)擊顯示。
對于方法調(diào)用次數(shù)和總耗時的統(tǒng)計(jì)我寫在了 LaunchJSON 類的 allMethodAndSubMethods 函數(shù)里,思路就是遍歷所有的 LaunchItem,碰到相同的 item name 就對次數(shù)和耗時進(jìn)行累加。代碼如下:
let allItems = LaunchJSON.leaf(fileName: fileName, isGetAllItem: true)
var mergeDic = [String:LaunchItem]()
for item in allItems {
let mergeKey = item.name // 方法名為標(biāo)識
if mergeDic[mergeKey] != nil {
var newItem = mergeDic[mergeKey]
newItem?.cost += item.cost // 累加耗時
newItem?.times += 1 // 累加次數(shù)
mergeDic[mergeKey] = newItem
} else {
mergeDic[mergeKey] = item
}
}
展示時判斷次數(shù)大于1時,耗時大于0時展示出來。
var mergeStr = “”
if preMergeItemDic.keys.contains(“\(bundleName+className+methodName)”) {
//
let mItem = preMergeItemDic[“\(bundleName+className+methodName)”]
if mItem?.times ?? 0 > 1 && (mItem?.cost ?? 0) / 1000 > 0 {
mergeStr = “(總次數(shù)\(mItem?.times ?? 0)、總耗時\((mItem?.cost ?? 0) / 1000))”
}
}
展示的效果如下:

展示方法源碼
在頁面上展示源碼需要先解析 .xcworkspace 文件,通過 .xcworkspace文件取到工程里所有的 .xcodeproj 文件。分析 .xcodeproj 文件取到所有 .m 和.mm 源碼文件路徑,解析源碼,取到方法的源碼內(nèi)容進(jìn)行展示。
解析 .xcworkspace

打開.xcworkspace,可以看到這個包內(nèi)主要文件是 contents.xcworkspacedata。內(nèi)容是一個 xml:
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:GCDFetchFeed.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
所以下面需要對 xml 進(jìn)行分析。xml 的操作符有 <>=\”/?![],通過這些操作符能夠取到通用的 token 集合 tokens。
tokens = Lexer(input: input, type: .plain).allTkFast(operaters: “<>=\”/?![]”)
根據(jù) xml 的規(guī)則,將解析狀態(tài)分為 normal、startTag、cdata 三種。定義的枚舉為:
private enum State {
case normal
case startTag
case cdata
}
當(dāng)遇到<符號時,更改解析狀態(tài)為 startTag。如果<符號后面跟的是![CDATA[表示是 cdata 標(biāo)簽,狀態(tài)需要改成 cdata。實(shí)現(xiàn)代碼如下:
// <tagname …> 和 <![CDATA[
if currentState == .normal && currentToken == .id(“<“) {
// <![CDATA[
if peekTk() == .id(“!”) && peekTkStep(step: 2) == .id(“[“) && peekTkStep(step: 3) == .id(“CDATA”) && peekTkStep(step: 4) == .id(“[“) {
currentState = .cdata
advanceTk() // jump <
advanceTk() // jump !
advanceTk() // jump [
advanceTk() // jump CDATA
advanceTk() // jump [
return
}
// <tagname …>
if currentTokens.count > 0 {
addTagTokens(type: .value) // 結(jié)束一組
}
currentState = .startTag
advanceTk()
return
}
在 startTag 和 cdata 狀態(tài)時會將遇到的 token 裝到 currentTokens 里,在結(jié)束狀態(tài)時加入到 XMLTagTokens 這個結(jié)構(gòu)里記錄下來。XMLTagTokens 的定義如下:
public struct XMLTagTokens {
public let type: XMLTagTokensType
public let tokens: [Token]
}
currentTokens 會在狀態(tài)結(jié)束時記錄到 XMLTagTokens 的 tokens 里。startTag 會在>符號時結(jié)束。cdata 會在]]>時結(jié)束。這部分實(shí)現(xiàn)代碼見 MethodTraceAnalyze/ParseStandXMLTagTokens.swift 。
接下來對 XMLTagTokens 集合進(jìn)行進(jìn)一步分析,XML 的 tag 節(jié)點(diǎn)分為單標(biāo)簽比如 、開標(biāo)簽比如
、閉合標(biāo)簽比如
、標(biāo)簽值、xml 標(biāo)識說明,這五類。因此我定義了標(biāo)簽節(jié)點(diǎn)的類型枚舉 XMLTagNodeType:
public enum XMLTagNodeType {
case xml
case single // 單個標(biāo)簽
case start // 開標(biāo)簽 <p>
case value // 標(biāo)簽的值 <p>value</p>
case end // 閉合的標(biāo)簽 </p>
}
標(biāo)簽節(jié)點(diǎn)除了類型信息,還需要有屬性集合、標(biāo)簽名和標(biāo)簽值,結(jié)構(gòu)體定義為:
public struct XMLTagNode {
public let type: XMLTagNodeType
public let value: String // 標(biāo)簽值
public let name: String // 標(biāo)簽名
public let attributes: [XMLTagAttribute] // 標(biāo)簽屬性
}
解析 XML 標(biāo)簽節(jié)點(diǎn)相比較于 HTML 來說會簡化些,HTML的規(guī)則更加的復(fù)雜,以前使用狀態(tài)機(jī)根據(jù) W3C 標(biāo)準(zhǔn)HTML Standard專門解析過,狀態(tài)機(jī)比較適合于復(fù)雜的場景,具體代碼在這里 HTN/HTMLTokenizer.swift 。可以看到按照 W3C 的標(biāo)準(zhǔn),設(shè)計(jì)了一個 HTNStateType 狀態(tài)枚舉,狀態(tài)特別多。對于 XML 來說狀態(tài)會少些:
enum pTagState {
case start
case questionMark
case xml
case tagName
case attributeName
case equal
case attributeValue
case startForwardSlash
case endForwardSlash
case startDoubleQuotationMarks
case backSlash
case endDoubleQuotationMarks
}
XML 標(biāo)簽節(jié)點(diǎn)的解析我沒有用狀態(tài)機(jī),將解析結(jié)果記錄到了 XMLTagNode 結(jié)構(gòu)體中。標(biāo)簽節(jié)點(diǎn)解析過程代碼在這里 MethodTraceAnalyze/ParseStandXMLTags.swift 。標(biāo)簽節(jié)點(diǎn)解析完后還需要解決 XML 的層級問題,也就是標(biāo)簽包含標(biāo)簽的問題。
先定義一個結(jié)構(gòu)體 XMLNode,用來記錄 XML 的節(jié)點(diǎn)樹:

public struct XMLNode {
public let name: String
public let attributes: [XMLTagAttribute]
public var value: String
public var subNodes: [XMLNode]
}
其中 subNodes 是 XMLNode 的子節(jié)點(diǎn)集合,解析出 XMLNode 的思路是根據(jù)前面輸出的 XMLTagNode 的類型來分析,當(dāng)遇到類型是 start 到遇到相同 name 的 end 之間不斷收集 XMLTagNode 到 currentTagNodeArr 數(shù)組里,end 時將這個數(shù)組添加到 tagNodeArrs 里,然后開始收集下一組 start 和 end。關(guān)鍵代碼如下:
// 當(dāng)遇到.end 類型時將一組 XMLTagNode 加到 tagNodeArrs 里。然后重置。
if node.type == .end && node.name == currentTagName {
currentState = .end
currentTagNodeArr.append(node)
// 添加到一級
tagNodeArrs.append(currentTagNodeArr)
// 重置
currentTagNodeArr = [XMLTagNode]()
currentTagName = “”
continue
}
對于 xml 類型標(biāo)簽和 single 類型的會直接保存到 tagNodeArrs 里。接下來對 tagNodeArrs 這些由 XMLTagNode 組成的數(shù)組集進(jìn)行分析。如果 tagNodeArr 的數(shù)組數(shù)量是1時,表示這一層級的 tag 是 xml 或者單標(biāo)簽的情況比如<?xml version=”1.0” encoding=”UTF-8”?> 或 這種。數(shù)量是2時表示開閉標(biāo)簽里沒有其他的標(biāo)簽,類似
這種。當(dāng) tagNodeArr 的數(shù)量大于2時,可能有兩種情況,一種是 tagNode 為 value 類型比如
section value
,其他情況就是標(biāo)簽里會嵌套標(biāo)簽,需要遞歸調(diào)用 recusiveParseTagNodes 函數(shù)進(jìn)行下一級的解析。這部分邏輯在 recusiveParseTagNodes 函數(shù)里,相關(guān)代碼如下:
for tagNodeArr in tagNodeArrs {
if tagNodeArr.count == 1 {
// 只有一個的情況,即 xml 和 single
let aTagNode = tagNodeArr[0]
pNode.subNodes.append(tagNodeToNode(tagNode: aTagNode))
} else if tagNodeArr.count == 2 {
// 2個的情況,就是比如 <p></p>
let aTagNode = tagNodeArr[0] // 取 start 的信息
pNode.subNodes.append(tagNodeToNode(tagNode: aTagNode))
} else if tagNodeArr.count > 2 {
// 大于2個的情況
let startTagNode = tagNodeArr[0]
var startNode = tagNodeToNode(tagNode: startTagNode)
let secondTagNode = tagNodeArr[1]
// 判斷是否是 value 這種情況比如 <p>paragraph</p>
if secondTagNode.type == .value {
// 有 value 的處理
startNode.value = secondTagNode.value.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
pNode.subNodes.append(startNode)
} else {
// 有子標(biāo)簽的情況
// 遞歸得到結(jié)果
var newTagNodeArr = tagNodeArr
newTagNodeArr.remove(at: tagNodeArr.count - 1)
newTagNodeArr.remove(at: 0)
pNode.subNodes.append(recusiveParseTagNodes(parentNode: startNode, tagNodes: newTagNodeArr))
} // end else
} // end else if
} // end for
完成 xcworkspace 的 XML 解析,獲取 XML 的節(jié)點(diǎn)樹如下所示:

寫個單測,保證后面增加功能和更新優(yōu)化解析后不會影響結(jié)果。單測代碼在這里 MethodTraceAnalyze/TestXML.swift。
解析 .xcodeproj

通過 XML 的解析可以獲取 FileRef 節(jié)點(diǎn)內(nèi)容, xcodeproj 的文件路徑就在 FileRef 節(jié)點(diǎn)的 location 屬性里。每個 xcodeproj 文件里會有 project 工程的源碼文件。為了能夠獲取方法的源碼進(jìn)行展示,那么就先要取出所有 project 工程里包含的源文件的路徑。
取 xcodeproj 文件路徑的方式如下:
if aFile.fileName == “contents.xcworkspacedata” {
let root = ParseStandXML(input: aFile.content).parse()
let workspace = root.subNodes[1]
for fileRef in workspace.subNodes {
var fileRefPath = fileRef.attributes[0].value
fileRefPath.removeFirst(6)
// 判斷是相對路徑還是絕對路徑
let arr = fileRefPath.split(separator: “/“)
var projectPath = “”
if arr.count > 2 {
projectPath = “\(fileRefPath)/project.pbxproj”
} else {
projectPath = “/\(pathStr)/\(fileRefPath)/project.pbxproj”
}
// 讀取 project 文件內(nèi)容分析
allSourceFile += ParseXcodeprojSource(input: projectPath).parseAllFiles()
} // end for fileRef in workspace.subNodes
} // end for
如上面代碼所示,ParseXcodeprojSource 是專門用來解析 xcodeproj 的,parseAllFiles 方法根據(jù)解析的結(jié)果,取出所有 xcodeproj 包含的源碼文件。
xcodeproj 的文件內(nèi)容看起來大概是下面的樣子。

其實(shí)內(nèi)容還有很多,需要一個個解析出來。
分析后分詞的分割符號有 /*={};\”,() 這些,根據(jù)這些分割符號設(shè)計(jì)分詞的 token 類型 XcodeprojTokensType,XcodeprojTokensType 為枚舉包含下面十個類型:
public enum XcodeprojTokensType {
case codeComment // 注釋
case string
case id
case leftBrace // {
case rightBrace // }
case leftParenthesis // (
case rightParenthesis // )
case equal // =
case semicolon // ;
case comma // ,
}
codeComment、string、id 這些類型會由多個 token 組成,所以最好將 xcodeproj 的基礎(chǔ) token 設(shè)計(jì)為下面的樣子:
public struct XcodeprojTokens {
public let type: XcodeprojTokensType
public let tokens: [Token]
}
由 tokens 字段記錄多個 token。實(shí)現(xiàn)分詞代碼在這 MethodTraceAnalyze/ParseXcodeprojTokens.swift
xcodeproj 文件雖然不是 json,但是大小括號的規(guī)則和 json 還比較類似,大括號里的數(shù)據(jù)類似字典可以用 key、value 配對記錄,小括號數(shù)據(jù)類似數(shù)組,記錄 value 就可以。這樣可以設(shè)計(jì) xcodeproj 的節(jié)點(diǎn)類型為:
public enum XcodeprojNodeType {
case normal
case root // 根節(jié)點(diǎn)
case dicStart // {
case dicKey
case dicValue
case dicEnd // }
case arrStart // (
case arrValue
case arrEnd // )
}
如上面定義 XcodeprojNodeType 枚舉,其大括號內(nèi)數(shù)據(jù)的 key 類型為 dicKey,value 類型為 dicValue。小括號的 value 類型為 arrValue。節(jié)點(diǎn)設(shè)計(jì)為:
public struct XcodeprojNode {
public let type: XcodeprojNodeType
public let value: String
public let codeComment: String
public var subNodes: [XcodeprojNode]
}
解析代碼都在這里 MethodTraceAnalyze/ParseXcodeprojNode.swift 。
xcodeproj 也有層級,所以也需要構(gòu)建一個樹結(jié)構(gòu)。結(jié)構(gòu)代碼如下:
public struct XcodeprojTreeNode {
public var type: XcodeprojTreeNodeType
public var value: String
public var comment: String
public var kvs: [XcodeprojTreeNodeKv]
public var arr: [XcodeprojTreeNodeArrayValue]
}
public enum XcodeprojTreeNodeType {
case value
case keyValue
case array
}
public struct XcodeprojTreeNodeKey {
public var name: String
public var comment: String
}
public struct XcodeprojTreeNodeArrayValue {
public var name: String
public var comment: String
}
public struct XcodeprojTreeNodeKv {
public var key: XcodeprojTreeNodeKey
public var value: XcodeprojTreeNode
}
考慮到 xcodeproj 里的注釋很多,也都很有用,因此會多設(shè)計(jì)些結(jié)構(gòu)來保存值和注釋。思路是根據(jù) XcodeprojNode 的類型來判斷下一級是 key value 結(jié)構(gòu)還是 array 結(jié)構(gòu)。如果 XcodeprojNode 的類型是 XcodeprojNode 的類型是 dicStart 表示下級是 key value 結(jié)構(gòu)。如果類型是 arrStart 就是 array 結(jié)構(gòu)。當(dāng)碰到類型是 dicEnd 同時和最初 dicStart 是同級時,遞歸下一級樹結(jié)構(gòu)。而 arrEnd 不用遞歸,xcodeproj 里的 array 只有值類型的數(shù)據(jù)。生成節(jié)點(diǎn)樹結(jié)構(gòu)這部分代碼實(shí)現(xiàn)在這里 MethodTraceAnalyze/ParseXcodeprojTreeNode.swift
斷點(diǎn)看生成的結(jié)構(gòu)如下圖:

其中 section 內(nèi)容都在 objects 里

有了基本節(jié)點(diǎn)樹結(jié)構(gòu)以后就可以設(shè)計(jì) xcodeproj 里各個 section 的結(jié)構(gòu)。主要有一下的 section:
- PBXBuildFile:文件,最終會關(guān)聯(lián)到 PBXFileReference
- PBXContainerItemProxy:部署的元素
- PBXFileReference:各類文件,有源碼、資源、庫等文件
- PBXFrameworksBuildPhase:用于 framework 的構(gòu)建
- PBXGroup:文件夾,可嵌套,里面包含了文件與文件夾的關(guān)系
- PBXNativeTarget:Target 的設(shè)置
- PBXProject:Project 的設(shè)置,有編譯工程所需信息
- PBXResourcesBuildPhase:編譯資源文件,有 xib、storyboard、plist以及圖片等資源文件
- PBXSourcesBuildPhase:編譯源文件(.m)
- PBXTargetDependency: Taget 的依賴
- PBXVariantGroup:.storyboard 文件
- XCBuildConfiguration:Xcode 編譯配置,對應(yīng) Xcode 的 Build Setting 面板內(nèi)容
- XCConfigurationList:構(gòu)建配置相關(guān),包含項(xiàng)目文件和 target 文件
根據(jù) xcodeproj 的結(jié)構(gòu)規(guī)則設(shè)計(jì)結(jié)構(gòu)體:
// project.pbxproj 結(jié)構(gòu)
public struct Xcodeproj {
var archiveVersion = “”
var classes = [XcodeprojTreeNodeArrayValue]()
var objectVersion = “” // 區(qū)分 xcodeproj 不同協(xié)議版本
var rootObject = PBXValueWithComment(name: “”, value: “”)
var pbxBuildFile = [String:PBXBuildFile]()
var pbxContainerItemProxy = [String:PBXContainerItemProxy]()
var pbxFileReference = [String:PBXFileReference]()
var pbxFrameworksBuildPhase = [String:PBXFrameworksBuildPhase]()
var pbxGroup = [String:PBXGroup]()
var pbxNativeTarget = [String:PBXNativeTarget]()
var pbxProject = [String:PBXProject]()
var pbxResourcesBuildPhase = [String:PBXResourcesBuildPhase]()
var pbxSourcesBuildPhase = [String:PBXSourcesBuildPhase]()
var pbxTargetDependency = [String:PBXTargetDependency]()
var pbxVariantGroup = [String:PBXVariantGroup]()
var xcBuildConfiguration = [String:XCBuildConfiguration]()
var xcConfigurationList = [String:XCConfigurationList]()
init() {
}
}
具體每個字段集合元素的結(jié)構(gòu)體比如 PBXBuildFile 和 PBXFileReference 對應(yīng)的結(jié)構(gòu)體和 xcodeproj 的 section 結(jié)構(gòu)對應(yīng)上。然后使用 ParseXcodeprojTreeNode 解析的節(jié)點(diǎn)樹結(jié)構(gòu)生成最終的 Xcodeproj section 的結(jié)構(gòu)體。解析過程在這里 MethodTraceAnalyze/ParseXcodeprojSection.swift。
調(diào)試看到 Xcodeproj 的結(jié)構(gòu)如下:

對 xcodeproj 的解析也寫了單測來保證后期 MethodTraceAnalyze/TestXcodeproj.swift。

得到 section 結(jié)構(gòu) Xcodeproj 后,就可以開始分析所有源文件的路徑了。根據(jù)前面列出的 section 的說明,PBXGroup 包含了所有文件夾和文件的關(guān)系,Xcodeproj 的 pbxGroup 字段的 key 是文件夾,值是文件集合,因此可以設(shè)計(jì)一個結(jié)構(gòu)體 XcodeprojSourceNode 用來存儲文件夾和文件關(guān)系。XcodeprojSourceNode 結(jié)構(gòu)如下:
public struct XcodeprojSourceNode {
let fatherValue: String // 文件夾
let value: String // 文件的值
let name: String // 文件名
let type: String
}
通過遍歷 pbxGroup 可以將文件夾和文件對應(yīng)上,文件名可以通過 pbxGroup 的 value 到 PBXFileReference 里去取。代碼如下:
var nodes = [XcodeprojSourceNode]()
// 第一次找出所有文件和文件夾
for (k,v) in proj.pbxGroup {
guard v.children.count > 0 else {
continue
}
for child in v.children {
// 如果滿足條件表示是目錄
if proj.pbxGroup.keys.contains(child.value) {
continue
}
// 滿足條件是文件
if proj.pbxFileReference.keys.contains(child.value) {
guard let fileRefer = proj.pbxFileReference[child.value] else {
continue
}
nodes.append(XcodeprojSourceNode(fatherValue: k, value: child.value, name: fileRefer.path, type: fileRefer.lastKnownFileType))
}
} // end for children
} // end for group
接下來需要取得完整的文件路徑。通過 recusiveFatherPaths 函數(shù)獲取文件夾路徑。這里需要注意的是需要處理 ../ 這種文件夾路徑符,獲取完整路徑的實(shí)現(xiàn)代碼可以看這里 MethodTraceAnalyze/ParseXcodeprojSource.swift。
有了每個源文件的路徑,接下來就可以對這些源文件進(jìn)行解析了。
解析 .m .mm 文件

對 Objective-C 解析可以參考 LLVM,這里只需要找到每個方法對應(yīng)的源碼,所以自己也可以實(shí)現(xiàn)。分詞前先看看 LLVM 是怎么定義 token 的。定義文件在這里 https://opensource.apple.com/source/lldb/lldb-69/llvm/tools/clang/include/clang/Basic/TokenKinds.def 。根據(jù)這個定義我設(shè)計(jì)了 token 的結(jié)構(gòu)體,主體部分如下:
// 切割符號 [](){}.&=*+-<>~!/%^|?:;,#@
public enum OCTK {
case unknown // 不是 token
case eof // 文件結(jié)束
case eod // 行結(jié)束
case codeCompletion // Code completion marker
case cxxDefaultargEnd // C++ default argument end marker
case comment // 注釋
case identifier // 比如 abcde123
case numericConstant(OCTkNumericConstant) // 整型、浮點(diǎn) 0x123,解釋計(jì)算時用,分析代碼時可不用
case charConstant // ‘a(chǎn)’
case stringLiteral // “foo”
case wideStringLiteral // L”foo”
case angleStringLiteral // <foo> 待處理需要考慮作為小于符號的問題
// 標(biāo)準(zhǔn)定義部分
// 標(biāo)點(diǎn)符號
case punctuators(OCTkPunctuators)
// 關(guān)鍵字
case keyword(OCTKKeyword)
// @關(guān)鍵字
case atKeyword(OCTKAtKeyword)
}
完整的定義在這里 MethodTraceAnalyze/ParseOCTokensDefine.swift。分詞過程可以參看 LLVM 的實(shí)現(xiàn) clang: lib/Lex/Lexer.cpp Source File。我在處理分詞時主要是按照分隔符一一對應(yīng)處理,針對代碼注釋和字符串進(jìn)行了特殊處理,一個注釋一個 token,一個完整字符串一個 token。我分詞實(shí)現(xiàn)代碼 MethodTraceAnalyze/ParseOCTokens.swift。
作為一個開發(fā)者,有一個學(xué)習(xí)的氛圍跟一個交流圈子特別重要,這有個iOS交流群:642363427,不管你是小白還是大牛歡迎入駐 ,分享BAT,阿里面試題、面試經(jīng)驗(yàn),討論技術(shù),iOS開發(fā)者一起交流學(xué)習(xí)成長!
由于只要取到類名和方法里的源碼,所以語法分析時,只需要對類定義和方法定義做解析就可以,語法樹中節(jié)點(diǎn)設(shè)計(jì):
// OC 語法樹節(jié)點(diǎn)
public struct OCNode {
public var type: OCNodeType
public var subNodes: [OCNode]
public var identifier: String // 標(biāo)識
public var lineRange: (Int,Int) // 行范圍
public var source: String // 對應(yīng)代碼
}
// 節(jié)點(diǎn)類型
public enum OCNodeType {
case `default`
case root
case `import`
case `class`
case method
}
其中 lineRange 記錄了方法所在文件的行范圍,這樣就能夠從文件中取出代碼,并記錄在 source 字段中。
解析語法樹需要先定義好解析過程的不同狀態(tài):
private enum RState {
case normal
case eod // 換行
case methodStart // 方法開始
case methodReturnEnd // 方法返回類型結(jié)束
case methodNameEnd // 方法名結(jié)束
case methodParamStart // 方法參數(shù)開始
case methodContentStart // 方法內(nèi)容開始
case methodParamTypeStart // 方法參數(shù)類型開始
case methodParamTypeEnd // 方法參數(shù)類型結(jié)束
case methodParamEnd // 方法參數(shù)結(jié)束
case methodParamNameEnd // 方法參數(shù)名結(jié)束
case at // @
case atImplementation // @implementation
case normalBlock // oc方法外部的 block {},用于 c 方法
}
完整解析出方法所屬類、方法行范圍的代碼在這里 MethodTraceAnalyze/ParseOCNodes.swift
解析 .m 和 .mm 文件,一個一個串行解的話,對于大工程,每次解的速度很難接受,所以采用并行方式去讀取解析多個文件,經(jīng)過測試,發(fā)現(xiàn)每組在60個以上時能夠最大利用我機(jī)器(2.5 GHz 雙核Intel Core i7)的 CPU,內(nèi)存占用只有60M,一萬多.m文件的工程大概2分半能解完。分組并行的代碼實(shí)現(xiàn)如下:
let allPath = XcodeProjectParse.allSourceFileInWorkspace(path: workspacePath)
var allNodes = [OCNode]()
let groupCount = 60 // 一組容納個數(shù)
let groupTotal = allPath.count/groupCount + 1
var groups = [[String]]()
for I in 0..<groupTotal {
var group = [String]()
for j in I*groupCount..<(I+1)*groupCount {
if j < allPath.count {
group.append(allPath[j])
}
}
if group.count > 0 {
groups.append(group)
}
}
for group in groups {
let dispatchGroup = DispatchGroup()
for node in group {
dispatchGroup.enter()
let queue = DispatchQueue.global()
queue.async {
let ocContent = FileHandle.fileContent(path: node)
let node = ParseOCNodes(input: ocContent).parse()
for aNode in node.subNodes {
allNodes.append(aNode)
}
dispatchGroup.leave()
} // end queue async
} // end for
dispatchGroup.wait()
} // end for
使用的是 dispatch group 的 wait,保證并行的一組完成再進(jìn)入下一組。

現(xiàn)在有了每個方法對應(yīng)的源碼,接下來就可以和前面 trace 的方法對應(yīng)上。頁面展示只需要寫段 js 就能夠控制點(diǎn)擊時展示對應(yīng)方法的源碼。
頁面展示
在進(jìn)行 HTML 頁面展示前,需要將代碼里的換行和空格替換成 HTML 里的對應(yīng)的 和 。
let allNodes = ParseOC.ocNodes(workspacePath: “/Users/ming/Downloads/GCDFetchFeed/GCDFetchFeed/GCDFetchFeed.xcworkspace”)
var sourceDic = [String:String]()
for aNode in allNodes {
sourceDic[aNode.identifier] = aNode.source.replacingOccurrences(of: “\n”, with: “</br>”).replacingOccurrences(of: “ “, with: “ ”)
}
用 p 標(biāo)簽作為源碼展示的標(biāo)簽,方法執(zhí)行順序的編號加方法名作為 p 標(biāo)簽的 id,然后用 display: none; 將 p 標(biāo)簽隱藏。方法名用 a 標(biāo)簽,click 屬性執(zhí)行一段 js 代碼,當(dāng) a 標(biāo)簽點(diǎn)擊時能夠顯示方法對應(yīng)的代碼。這段 js 代碼如下:
function sourceShowHidden(sourceIdName) {
var sourceCode = document.getElementById(sourceIdName);
sourceCode.style.display = “block”;
}
最終效果如下圖:

將動態(tài)分析和靜態(tài)分析進(jìn)行了結(jié)合,后面可以通過不同版本進(jìn)行對比,發(fā)現(xiàn)哪些方法的代碼實(shí)現(xiàn)改變了,能展示在頁面上。還可以進(jìn)一步靜態(tài)分析出哪些方法會調(diào)用到 I/O 函數(shù)、起新線程、新隊(duì)列等,然后展示到頁面上,方便分析。
讀到最后,可以看到這個方法分析工具并沒有用任何一個輪子,其實(shí)有些是可以使用現(xiàn)有輪子的,比如 json、xml、xcodeproj、Objective-C 語法分析等,之所有沒有用是因?yàn)椴煌喿邮褂玫恼Z言和技術(shù)區(qū)別較大,當(dāng)格式更新時如果使用的單個輪子沒有更新會影響整個工具。開發(fā)這個工具主要工作是在解析上,所以使用自有解析技術(shù)也能夠讓所做的功能更聚焦,不做沒用的功能,減少代碼維護(hù)量,所要解析格式更新后,也能夠自主去更新解析方式。更重要的一點(diǎn)是可以親手接觸下這些格式的語法設(shè)計(jì)。
結(jié)語
今天說了下啟動優(yōu)化的技術(shù)手段,總的說,對啟動進(jìn)行優(yōu)化的決心重要程度是遠(yuǎn)大于技術(shù)手段的,決定著是否能夠優(yōu)化的更多。技術(shù)手段有很多,我覺得手段的好壞區(qū)別只是在效率上,最差的情況全用手動一個個去查耗時也是能夠解題的。
最近看了魯迅的一段話,很有感觸,分享一下:
我們好像都是愛生病的人
苦的很
我的一生
好像是在不斷生病和罵人中就過去多半了
我三十歲不到,牙齒就掉光了
滿口義齒
我戒酒
吃魚肝油
以望延長我的生命
倒不盡是為了我的愛人
大半是為了我的敵人
我自己知道的,我并不大度
說到幸福
只得面向過去
或者面向除了墳?zāi)挂酝夂翢o任何希望的將來
每個戰(zhàn)士都是如此
我們活在這樣的地方
我們活在這樣的時代

查看原文
如果您覺得還不錯,麻煩在文末 “點(diǎn)個贊” 或者 評論 “Mark”,謝謝您的支持