高德APP啟動耗時剖析與優化實踐(iOS篇)

高德技術發表於2020-04-20

前言
最近高德地圖APP完成了一次啟動優化專項,超預期將雙端啟動的耗時都降低了65%以上,iOS在iPhone7上速度達到了400毫秒以內。就像產品們用後說的,快到不習慣。算一下每天為使用者省下的時間,還是蠻有成就感的,本文做個小結。

(文中配圖均為多才多藝的技術哥哥手繪)

 

啟動階段效能多維度分析

要優化,首先要做到的是對啟動階段的各個效能緯度做分析,包括主執行緒耗時、CPU、記憶體、I/O、網路。這樣才能更加全面的掌握啟動階段的開銷,找出不合理的方法呼叫。

啟動越快,更多的方法呼叫就應該做成按需執行,將啟動壓力分攤,只留下那些啟動後方法都會依賴的方法和庫的初始化,比如網路庫、Crash庫等。而剩下那些需要預載入的功能可以放到啟動階段後再執行。

啟動有哪幾種型別,有哪些階段呢?

啟動型別分為:

  • Cold:APP重啟後啟動,不在記憶體裡也沒有程式存在。
  • Warm:APP最近結束後再啟動,有部分在記憶體但沒有程式存在。
  • Resume:APP沒結束,只是暫停,全在記憶體中,程式也存在。

分析階段一般都是針對Cold型別進行分析,目的就是要讓測試環境穩定。為了穩定測試環境,有時還需要找些穩定的機型,對於iOS來說iPhone7效能中等,穩定性也不錯就很適合,Android的Vivo系列也相對穩定,華為和小米系列資料波動就比較大。

除了機型外,控制測試機溫度也很重要,一旦溫度過高系統還會降頻執行,影響測試資料。有時候還會設定飛航模式採用Mock網路請求的方式來減少不穩定的網路影響測試資料。最好是重啟後退iCloud賬號,放置一段時間再測,更加準確些。

瞭解啟動階段的目的就是聚焦範圍,從使用者體驗上來確定哪個階段要快,以便能夠讓使用者可視和響應使用者操作的時間更快。

簡單來說iOS啟動分為載入Mach-O和執行時初始化過程,載入Mach-O會先判斷載入的檔案是不是Mach-O,通過檔案第一個位元組,也叫魔數來判斷,當是下面四種時可以判定是Mach-O檔案:

  • 0xfeedface對應的loader.h裡的巨集是MH_MAGIC
  • 0xfeedfact巨集是MH_MAGIC_64
  • NXSwapInt(MH_MAGIC)巨集MH_GIGAM
  • NXSwapInt(MH_MAGIC_64)巨集MH_GIGAM_64

Mach-O主要分為:

  • 中間物件檔案(MH_OBJECT)
  • 可執行二進位制(MH_EXECUTE)
  • VM 共享庫檔案(MH_FVMLIB)
  • Crash 產生的Core檔案(MH_CORE)
  • preload(MH_PRELOAD)
  • 動態共享庫(MH_DYLIB)
  • 動態連結器(MH_DYLINKER)
  • 靜態連結檔案(MH_DYLIB_STUB)符號檔案和除錯資訊(MH_DSYM)這幾種。

確定是Mach-O後,核心會fork一個程式,execve開始載入。檢查Mach-O Header。隨後載入dyld和程式到Load Command地址空間。通過 dyld_stub_binder開始執行dyld,dyld會進行rebase、binding、lazy binding、匯出符號,也可以通過DYLD_INSERT_LIBRARIES進行hook。

dyld_stub_binder給偏移量到dyld解釋特殊位元組碼Segment中,也就是真實地址,把真實地址寫入到la_symbol_ptr裡,跳轉時通過stub的jump指令跳轉到真實地址。dyld載入所有依賴庫,將動態庫匯出的trie結構符號執行符號繫結,也就是non lazybinding,繫結解析其他模組功能和資料引用過程,就是匯入符號。

Trie也叫數字樹或字首樹,是一種搜尋樹。查詢複雜度O(m),m是字串的長度。和雜湊表相比,雜湊最差複雜度是O(N),一般都是 O(1),用 O(m)時間評估 hash。雜湊缺點是會分配一大塊記憶體,內容越多所佔記憶體越大。Trie不僅查詢快,插入和刪除都很快,適合儲存預測性文字或自動完成詞典。

為了進一步優化所佔空間,可以將Trie這種樹形的確定性有限自動機壓縮成確定性非迴圈有限狀態自動體(DAFSA),其空間小,做法是會壓縮相同分支。

對於更大內容,還可以做更進一步的優化,比如使用字母縮減的實現技術,把原來的字串重新解釋為較長的字串;使用單鏈式列表,節點設計為由符號、子節點、下一個節點來表示;將字母表陣列儲存為代表ASCII字母表的256位的點陣圖。

儘管Trie對於效能會做很多優化,但是符號過多依然會增加效能消耗,對於動態庫匯出的符號不宜太多,儘量保持公共符號少,私有符號集豐富。這樣維護起來也方便,版本相容性也好,還能優化動態載入程式到程式的時間。

然後執行attribute的constructor函式。舉個例子:

#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");
}

執行結果:

ming@mingdeMacBook-Pro macho_demo % ./main "hi"
prepare
hi
end

執行時初始化過程分為:

  • 載入類擴充套件。
  • 載入C++靜態物件。
  • 呼叫+load函式。
  • 執行main函式。
  • Application初始化,到applicationDidFinishLaunchingWithOptions執行完。
  • 初始化幀渲染,到viewDidAppear執行完,使用者可見可操作。

也就是說對啟動階段的分析以viewDidAppear為截止。這次優化之前已經對Application初始化之前做過優化,效果並不明顯,沒有本質的提高,所以這次主要針對Application初始化到viewDidAppear這個階段各個效能多緯度進行分析。

工具的選擇其實目前看來是很多的,Apple提供的System Trace會提供全面系統的行為,可以顯示底層系統執行緒和記憶體排程情況,分析鎖、執行緒、記憶體、系統呼叫等問題。總的來說,通過System Trace能清楚知道每時每刻APP對系統資源的使用情況。

System Trace能檢視執行緒的狀態,可以瞭解高優執行緒使用相對於CPU數量是否合理,可以看到執行緒在執行、掛起、上下文切換、被打斷還是被搶佔的情況。虛擬記憶體使用產生的耗時也能看到,比如分配實體記憶體,記憶體解壓縮,無快取時進行快取的耗時等。甚至是發熱情況也能看到。

System Trace還提供手動打點進行資訊顯式,在你的程式碼中匯入sys/kdebug_signpost.h後,配對kdebug_signpost_start和kdebug_signpost_end就可以了。這兩個方法有五個引數,第一個是id,最後一個是顏色,中間都是預留欄位。

Xcode11開始XCTest還提供了測量效能的Api。蘋果在2019年WWDC啟動優化專題:

https://developer.apple.com/videos/play/wwdc2019/423/

也介紹了Instruments裡的最新模板App launch如何分析啟動效能。但是要想達到對啟動資料進行留存取均值、Diff、過濾、關聯分析等自動化操作,App launch目前還沒法做到。

下面針對主執行緒耗時、CPU、網路、記憶體、I/O 等多維度進行分析:

  • 主執行緒耗時

多個緯度效能分析中最重要、終端使用者體感到的是主執行緒耗時分析。對主執行緒方法耗時可以直接使用Massier,這是everettjf開發的一個Objective-C方法跟蹤工具:

https://everettjf.github.io/2019/05/06/messier/

生成trace json進行分析,或者參看這個程式碼

GCDFetchFeed/SMCallTraceCore.c at master · ming1016/GCDFetchFeed · GitHub

https://github.com/ming1016/GCDFetchFeed/blob/master/GCDFetchFeed/GCDFetchFeed/Lib/SMLagMonitor/SMCallTraceCore.c

自己手動hook objc_msgSend生成一份Objective-C方法耗時資料進行分析。還有種插樁方式,可以解析IR(加快編譯速度),然後在每個方法前後插入耗時統計函式。

文章後面我會著重介紹如何開發工具進一步分析這份資料,以達到監控啟動階段方法耗時的目的。

hook所有的方法呼叫,對詳細分析時很有用,不過對於整個啟動時間影響很大,要想獲取啟動每個階段更準確的時間消耗還需要依賴手動埋點。

為了更好的分析啟動耗時問題,手動埋點也會埋的越來越多,也會影響啟動時間精確度,特別是當團隊很多,模組很多時,問題會突出。但是每個團隊在排查啟動耗時往往只會關注自己或相關某幾個模組的分析,基於此,可以把不同模組埋點分組,靈活組合,這樣就可以照顧到多種需求了。

  • CPU

為什麼分析啟動慢除了分析主執行緒方法耗時外,還要分析其它緯度的效能呢?

我們先看看啟動慢的表現,啟動慢意味著介面響應慢、網路慢(資料量大、請求數多)、CPU超負荷降頻(並行任務多、運算多),可以看出影響啟動的因素很多,還需要全面考慮。

對於CPU來說,WWDC的

What’s New in Energy Debugging - WWDC 2018 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2018/228/

介紹了用Energy Log來查CPU耗電,當前臺三分鐘或後臺一分鐘CPU執行緒連續佔用80%以上就判定為耗電,同時記錄耗電執行緒堆疊供分析。還有一個MetrickKit專門用來收集電源和效能統計資料,每24小時就會對收集的資料進行彙總上報,Mattt在NShipster網站上也發了篇文章專門進行介紹:

https://nshipster.com/metrickit/

那麼,CPU的詳細使用情況如何獲取呢?也就是說哪個方法用了多少CPU。

有好幾種獲取詳細CPU使用情況的方法。執行緒是計算機資源排程和分配的基本單位。CPU使用情況會提現到執行緒這樣的基本單位上。task_theads的act_list陣列包含所有執行緒,使用thread_info的介面可以返回執行緒的基本資訊,這些資訊定義在thread_basic_info_t結構體中。這個結構體內的資訊包含了執行緒執行時間、執行狀態以及排程優先順序,其中也包含了CPU使用資訊cpu_usage。

獲取方式參看:

objective c - Get detailed iOS CPU usage with different states - Stack Overflow

https://stackoverflow.com/questions/43866416/get-detailed-ios-cpu-usage-with-different-states

GT GitHub - Tencent/GT

https://github.com/Tencent/GT

也有獲取CPU的程式碼。

整體CPU佔用率可以通過host_statistics函式取到host_cpu_load_info,其中cpu_ticks陣列是CPU執行的時鐘脈衝數量。通過cpu_ticks陣列裡的狀態,可以分別獲取CPU_STATE_USER、CPU_STATE_NICE、CPU_STATE_SYSTEM這三個表示使用中的狀態,除以整體CPU就可以取到CPU的佔比。

通過NSProcessInfo的activeProcessorCount還可以得到CPU的核數。線上資料分析時會發現相同機型和系統的手機,效能表現卻截然不同,這是由於手機過熱或者電池損耗過大後系統降低了CPU頻率所致。

所以,如果取得CPU頻率後也可以針對那些降頻的手機來進行鍼對性的優化,以保證流暢體驗。獲取方式可以參考:

https://github.com/zenny-chen/CPU-Dasher-for-iOS

  • 記憶體

要想獲取APP真實的記憶體使用情況可以參看WebKit的原始碼:

https://github.com/WebKit/webkit/blob/52bc6f0a96a062cb0eb76e9a81497183dc87c268/Source/WTF/wtf/cocoa/MemoryFootprintCocoa.cpp

JetSam會判斷APP使用記憶體情況,超出閾值就會殺死APP,JetSam獲取閾值的程式碼在這裡:

https://github.com/apple/darwin-xnu/blob/0a798f6738bc1db01281fc08ae024145e84df927/bsd/kern/kern_memorystatus.c

整個裝置實體記憶體大小可以通過NSProcessInfo的physicalMemory來獲取。

  • 網路

對於網路監控可以使用Fishhook這樣的工具Hook網路底層庫CFNetwork。網路的情況比較複雜,所以需要定些和時間相關的關鍵的指標,指標如下:

  • DNS時間
  • SSL時間
  • 首包時間
  • 響應時間

有了這些指標才能夠有助於更好的分析網路問題。啟動階段的網路請求是非常多的,所以HTTP的效能是非常要注意的。以下是WWDC網路相關的Session:

Your App and Next Generation Networks - WWDC 2015 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2015/719/

Networking with NSURLSession - WWDC 2015 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2015/711/

Networking for the Modern Internet - WWDC 2016 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2016/714/

Advances in Networking, Part 1 - WWDC 2017 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2017/707/

Advances in Networking, Part 2 - WWDC 2017 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2017/709/

Optimizing Your App for Today’s Internet - WWDC 2018 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2018/714/

  • 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

https://www.frida.re/

這種動態二進位制插樁技術,在程式執行時去插入自定義程式碼獲取I/O的耗時和處理的資料大小等資料。Frida還能夠在其它平臺使用。

關於多維度分析更多的資料可以看看歷屆WWDC的介紹。下面我列下16年來 WWDC關於啟動優化的Session,每場都很精彩。

Using Time Profiler in Instruments - WWDC 2016 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2016/418/

Optimizing I/O for Performance and Battery Life - WWDC 2016 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2016/719/

Optimizing App Startup Time - WWDC 2016 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2016/406/

App Startup Time: Past, Present, and Future - WWDC 2017 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2017/413/

Practical Approaches to Great App Performance - WWDC 2018 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2018/407/

Optimizing App Launch - WWDC 2019 - Videos - Apple Developer

https://developer.apple.com/videos/play/wwdc2019/423/

延後任務管理

經過前面所說的對主執行緒耗時方法和各個緯度效能分析後,對於那些分析出來沒必要在啟動階段執行的方法,可以做成按需或延後執行。

任務延後的處理不能粗獷的一口氣在啟動完成後在主執行緒一起執行,那樣使用者僅僅只是看到了頁面,依然沒法響應操作。那該怎麼做呢?套路一般是這樣,建立四個佇列,分別是:

  • 非同步序列佇列
  • 非同步並行佇列
  • 閒時主執行緒序列佇列
  • 閒時非同步序列佇列

有依賴關係的任務可以放到非同步序列佇列中執行。非同步並行佇列可以分組執行,比如使用dispatch_group,然後對每組任務數量進行限制,避免CPU、執行緒和記憶體瞬時激增影響主執行緒使用者操作,定義有限數量的序列佇列,每個序列佇列做特定的事情,這樣也能夠避免效能消耗短時間突然暴漲引起無法響應使用者操作。使用dispatch_semaphore_t在訊號量阻塞主佇列時容易出現優先順序反轉,需要減少使用,確保QoS傳播。可以用dispatch group替代,效能一樣,功能不差。非同步程式設計可以直接GCD介面來寫,也可以使用阿里的協程框架

coobjc GitHub - alibaba/coobjc

https://github.com/alibaba/coobjc

閒時佇列實現方式是監聽主執行緒runloop狀態,在kCFRunLoopBeforeWaiting時開始執行閒時佇列裡的任務,在kCFRunLoopAfterWaiting時停止。

優化後如何保持?

攻易守難,就像剛到新團隊時將包大小減少了48兆,但是一年多一直能夠守住,除了決心還需要有手段。對於啟動優化來說,將各個效能緯度通過監控的方式盯住是必要的,但是發現問題後快速、便捷的定位到問題還是需要找些突破口。我的思路是將啟動階段方法耗時多的按照時間線一條一條排出來,每條包括方法名、方法層級、所屬類、所屬模組、維護人。考慮到便捷性,最好還能方便的檢視方法程式碼內容。

接下來我通過開發一個工具,詳細介紹下怎麼實現這樣的效果。

  • 解析json

如前面所說在輸出一份Chrome trace規範的方法耗時json後,先要解析這份資料。這份json資料類似下面的樣子:

{"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欄位包含了類、方法和引數的資訊,cat欄位可以加入其它效能資料,ph為B表示方法開始,為E表示方法結束,ts欄位表示。

很多工程在啟動階段會執行大量方法,很多方法耗時很少,可以過濾那些小於10毫秒的方法,讓分析更加聚焦。

耗時的高低也做了顏色的區分。外部耗時指的是子方法以外系統或沒原始碼的三方方法的耗時,規則是父方法呼叫的耗時減去其子方法總耗時。

目前為止通過過濾耗時少的方法呼叫,可以更容易發現問題方法。但是,有些方法單次執行耗時不多,但是會執行很多次,累加耗時會大,這樣的情況也需要體現在展示頁面裡。另外外部耗時高時或者碰到自己不瞭解的方法時,是需要到工程原始碼裡去搜尋對應的方法原始碼進行分析的,有的方法名很通用時還需要花大量時間去過濾無用資訊。

因此接下來還需要做兩件事情,首先累加方法呼叫次數和耗時,體現在展示頁面中,另一個是從工程中獲取方法原始碼能夠在展示頁面中進行點選顯示。

完整思路如下圖:

  • 展示方法原始碼

在頁面上展示原始碼需要先解析.xcworkspace檔案,通過.xcworkspace檔案取到工程裡所有的.xcodeproj檔案。分析.xcodeproj檔案取到所有.m和.mm原始碼檔案路徑,解析原始碼,取到方法的原始碼內容進行展示。

解析.xcworkspace

開.xcworkspace,可以看到這個包內主要檔案是contents.xcworkspacedata。內容是一個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>

解析.xcodeproj

通過XML的解析可以獲取FileRef節點內容,xcodeproj的檔案路徑就在FileRef節點的location屬性裡。每個xcodeproj檔案裡會有project工程的原始碼檔案。為了能夠獲取方法的原始碼進行展示,那麼就先要取出所有project工程裡包含的原始檔的路徑。

xcodeproj的檔案內容看起來大概是下面的樣子。

 

其實內容還有很多,需要一個個解析出來。

考慮到xcodeproj裡的註釋很多,也都很有用,因此會多設計些結構來儲存值和註釋。思路是根據XcodeprojNode的型別來判斷下一級是key value結構還是array結構。如果XcodeprojNode的型別是dicStart表示下級是key value結構。如果型別是arrStart就是array結構。當碰到型別是dicEnd,同時和最初dicStart是同級時,遞迴下一級樹結構。而arrEnd不用遞迴,xcodeproj裡的array只有值型別的資料。

有了基本節點樹結構以後就可以設計xcodeproj裡各個section的結構。主要有以下的section:

  • PBXBuildFile:檔案,最終會關聯到PBXFileReference。

  • PBXContainerItemProxy:部署的元素。

  • PBXFileReference:各類檔案,有原始碼、資源、庫等檔案。

  • PBXFrameworksBuildPhase:用於framework的構建。

  • PBXGroup:資料夾,可巢狀,裡面包含了檔案與資料夾的關係。

  • PBXNativeTarget:Target的設定。

  • PBXProject:Project的設定,有編譯工程所需資訊。

  • PBXResourcesBuildPhase:編譯資原始檔,有xib、storyboard、plist以及圖片等資原始檔。

  • PBXSourcesBuildPhase:編譯原始檔(.m)。

  • PBXTargetDependency:Taget的依賴。

  • PBXVariantGroup:.storyboard檔案。

  • XCBuildConfiguration:Xcode編譯配置,對應Xcode的Build Setting皮膚內容。

  • XCConfigurationList:構建配置相關,包含專案檔案和target檔案。

得到section結構Xcodeproj後,就可以開始分析所有原始檔的路徑了。根據前面列出的section的說明,PBXGroup包含了所有資料夾和檔案的關係,Xcodeproj的pbxGroup欄位的key是資料夾,值是檔案集合,因此可以設計一個結構體XcodeprojSourceNode用來儲存資料夾和檔案關係。

接下來需要取得完整的檔案路徑。通過recusiveFatherPaths函式獲取資料夾路徑。這裡需要注意的是需要處理 ../ 這種資料夾路徑符。

解析.m .mm檔案

對Objective-C解析可以參考LLVM,這裡只需要找到每個方法對應的原始碼,所以自己也可以實現。分詞前先看看LLVM是怎麼定義token的。定義檔案在這裡:

https://opensource.apple.com/source/lldb/lldb-69/llvm/tools/clang/include/clang/Basic/TokenKinds.def

根據這個定義我設計了token的結構體,主體部分如下:

// 切割符號 [](){}.&=*+-<>~!/%^|?:;,#@
public enum OCTK {
    case unknown // 不是 token
    case eof // 檔案結束
    case eod // 行結束
    case codeCompletion // Code completion marker
    case cxxDefaultargEnd // C++ default argument end marker
    case comment // 註釋
    case identifier // 比如 abcde123
    case numericConstant(OCTkNumericConstant) // 整型、浮點 0x123,解釋計算時用,分析程式碼時可不用
    case charConstant // ‘a’
    case stringLiteral // “foo”
    case wideStringLiteral // L”foo”
    case angleStringLiteral // <foo> 待處理需要考慮作為小於符號的問題

    // 標準定義部分
    // 標點符號
    case punctuators(OCTkPunctuators)

    //  關鍵字
    case keyword(OCTKKeyword)

    // @關鍵字
    case atKeyword(OCTKAtKeyword)
}

完整的定義在這裡:

MethodTraceAnalyze/ParseOCTokensDefine.swift

https://github.com/ming1016/MethodTraceAnalyze/blob/master/MethodTraceAnalyze/OC/ParseOCTokensDefine.swift

分詞過程可以參看LLVM的實現:

clang: lib/Lex/Lexer.cpp Source File

http://clang.llvm.org/doxygen/Lexer_8cpp_source.html

我在處理分詞時主要是按照分隔符一一對應處理,針對程式碼註釋和字串進行了特殊處理,一個註釋一個token,一個完整字串一個token。我分詞實現程式碼:

MethodTraceAnalyze/ParseOCTokens.swift

https://github.com/ming1016/MethodTraceAnalyze/blob/master/MethodTraceAnalyze/OC/ParseOCTokens.swift

由於只要取到類名和方法裡的原始碼,所以語法分析時,只需要對類定義和方法定義做解析就可以,語法樹中節點設計:

// OC 語法樹節點
public struct OCNode {
    public var type: OCNodeType
    public var subNodes: [OCNode]
    public var identifier: String   // 標識
    public var lineRange: (Int,Int) // 行範圍
    public var source: String       // 對應程式碼
}
// 節點型別
public enum OCNodeType {
    case `default`
    case root
    case `import`
    case `class`
    case method
}

其中lineRange記錄了方法所在檔案的行範圍,這樣就能夠從檔案中取出程式碼,並記錄在source欄位中。

解析語法樹需要先定義好解析過程的不同狀態:

private enum RState {
    case normal
    case eod                   // 換行
    case methodStart           // 方法開始
    case methodReturnEnd       // 方法返回型別結束
    case methodNameEnd         // 方法名結束
    case methodParamStart      // 方法引數開始
    case methodContentStart    // 方法內容開始
    case methodParamTypeStart  // 方法引數型別開始
    case methodParamTypeEnd    // 方法引數型別結束
    case methodParamEnd        // 方法引數結束
    case methodParamNameEnd    // 方法引數名結束

    case at                    // @
    case atImplementation      // @implementation

    case normalBlock           // oc方法外部的 block {},用於 c 方法
}

完整解析出方法所屬類、方法行範圍的程式碼在這裡:

MethodTraceAnalyze/ParseOCNodes.swift

https://github.com/ming1016/MethodTraceAnalyze/blob/master/MethodTraceAnalyze/OC/ParseOCNodes.swift

解析.m和.mm檔案,一個一個序列解的話,對於大工程,每次解的速度很難接受,所以採用並行方式去讀取解析多個檔案。經過測試,發現每組在60個以上時能夠最大利用我機器(2.5 GHz雙核Intel Core i7)的CPU,記憶體佔用只有60M,一萬多.m檔案的工程大概2分半能解完。

使用的是dispatch group的wait,保證並行的一組完成再進入下一組。

現在有了每個方法對應的原始碼,接下來就可以和前面trace的方法對應上。頁面展示只需要寫段js就能夠控制點選時展示對應方法的原始碼。

頁面展示

在進行HTML頁面展示前,需要將程式碼裡的換行和空格替換成HTML裡的對應的和&nbsp;。

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: “&nbsp;”)
}

用p標籤作為原始碼展示的標籤,方法執行順序的編號加方法名作為p標籤的id,然後用display: none; 將p標籤隱藏。方法名用a標籤,click屬性執行一段js程式碼,當a標籤點選時能夠顯示方法對應的程式碼。這段js程式碼如下:

function sourceShowHidden(sourceIdName) {
    var sourceCode = document.getElementById(sourceIdName);
    sourceCode.style.display = “block”;
}

最終效果如下圖:

將動態分析和靜態分析進行了結合,後面可以通過不同版本進行對比,發現哪些方法的程式碼實現改變了,能展示在頁面上。還可以進一步靜態分析出哪些方法會呼叫到I/O函式、起新執行緒、新佇列等,然後展示到頁面上,方便分析。

讀到最後,可以看到這個方法分析工具並沒有用任何一個輪子,其實有些是可以使用現有輪子的,比如json、xml、xcodeproj、Objective-C語法分析等,之所以沒有用是因為不同輪子使用的語言和技術區別較大,當格式更新時如果使用的單個輪子沒有更新會影響整個工具。開發這個工具主要工作是在解析上,所以使用自有解析技術也能夠讓所做的功能更聚焦,不做沒用的功能,減少程式碼維護量,所要解析格式更新後,也能夠自主去更新解析方式。更重要的一點是可以親手接觸下這些格式的語法設計。

結語

本文小結了啟動優化的技術手段,總的來說,對啟動進行優化的決心的重要程度是遠大於技術手段的,決定著是否能夠優化的更多。技術手段有很多,我覺得手段的好壞區別只是在效率上,最差的情況全用手動一個個去查耗時也是能夠解題的。

相關文章