前言
最近高德地圖APP完成了一次啓動優化專項,超預期將雙端啓動的耗時都下降了65%以上,iOS在iPhone7上速度達到了400毫秒之內。就像產品們用後說的,快到不習慣。算一下天天爲用戶省下的時間,仍是蠻有成就感的,本文作個小結。html
(文中配圖均爲多才多藝的技術哥哥手繪)ios
啓動階段性能多維度分析git
要優化,首先要作到的是對啓動階段的各個性能緯度作分析,包括主線程耗時、CPU、內存、I/O、網絡。這樣才能更加全面的掌握啓動階段的開銷,找出不合理的方法調用。github
啓動越快,更多的方法調用就應該作成按需執行,將啓動壓力分攤,只留下那些啓動後方法都會依賴的方法和庫的初始化,好比網絡庫、Crash庫等。而剩下那些須要預加載的功能能夠放到啓動階段後再執行。web
啓動有哪幾種類型,有哪些階段呢?編程
啓動類型分爲:json
分析階段通常都是針對Cold類型進行分析,目的就是要讓測試環境穩定。爲了穩定測試環境,有時還須要找些穩定的機型,對於iOS來講iPhone7性能中等,穩定性也不錯就很適合,Android的Vivo系列也相對穩定,華爲和小米系列數據波動就比較大。swift
除了機型外,控制測試機溫度也很重要,一旦溫度太高系統還會降頻執行,影響測試數據。有時候還會設置飛行模式採用Mock網絡請求的方式來減小不穩定的網絡影響測試數據。最好是重啓後退iCloud帳號,放置一段時間再測,更加準確些。數組
瞭解啓動階段的目的就是聚焦範圍,從用戶體驗上來肯定哪一個階段要快,以便可以讓用戶可視和響應用戶操做的時間更快。xcode
簡單來講iOS啓動分爲加載Mach-O和運行時初始化過程,加載Mach-O會先判斷加載的文件是否是Mach-O,經過文件第一個字節,也叫魔數來判斷,當是下面四種時能夠斷定是Mach-O文件:
Mach-O主要分爲:
肯定是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
運行時初始化過程分爲:
也就是說對啓動階段的分析以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啓動優化專題:
也介紹了Instruments裏的最新模板App launch如何分析啓動性能。可是要想達到對啓動數據進行留存取均值、Diff、過濾、關聯分析等自動化操做,App launch目前還無法作到。
下面針對主線程耗時、CPU、網絡、內存、I/O 等多維度進行分析:
多個緯度性能分析中最重要、最終用戶體感到的是主線程耗時分析。對主線程方法耗時能夠直接使用Massier,這是everettjf開發的一個Objective-C方法跟蹤工具:
生成trace json進行分析,或者參看這個代碼
GCDFetchFeed/SMCallTraceCore.c at master · ming1016/GCDFetchFeed · GitHub
本身手動hook objc_msgSend生成一份Objective-C方法耗時數據進行分析。還有種插樁方式,能夠解析IR(加快編譯速度),而後在每一個方法先後插入耗時統計函數。
文章後面我會着重介紹如何開發工具進一步分析這份數據,以達到監控啓動階段方法耗時的目的。
hook全部的方法調用,對詳細分析時頗有用,不過對於整個啓動時間影響很大,要想獲取啓動每一個階段更準確的時間消耗還須要依賴手動埋點。
爲了更好的分析啓動耗時問題,手動埋點也會埋的愈來愈多,也會影響啓動時間精確度,特別是當團隊不少,模塊不少時,問題會突出。可是每一個團隊在排查啓動耗時每每只會關注本身或相關某幾個模塊的分析,基於此,能夠把不一樣模塊埋點分組,靈活組合,這樣就能夠照顧到多種需求了。
爲何分析啓動慢除了分析主線程方法耗時外,還要分析其它緯度的性能呢?
咱們先看看啓動慢的表現,啓動慢意味着界面響應慢、網絡慢(數據量大、請求數多)、CPU超負荷降頻(並行任務多、運算多),能夠看出影響啓動的因素不少,還須要全面考慮。
對於CPU來講,WWDC的
What’s New in Energy Debugging - WWDC 2018 - Videos - Apple Developer
介紹了用Energy Log來查CPU耗電,當前臺三分鐘或後臺一分鐘CPU線程連續佔用80%以上就斷定爲耗電,同時記錄耗電線程堆棧供分析。還有一個MetrickKit專門用來收集電源和性能統計數據,每24小時就會對收集的數據進行彙總上報,Mattt在NShipster網站上也發了篇文章專門進行介紹:
那麼,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
也有獲取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...
要想獲取APP真實的內存使用狀況能夠查看WebKit的源碼:
JetSam會判斷APP使用內存狀況,超出閾值就會殺死APP,JetSam獲取閾值的代碼在這裏:
整個設備物理內存大小能夠經過NSProcessInfo的physicalMemory來獲取。
對於網絡監控可使用Fishhook這樣的工具Hook網絡底層庫CFNetwork。網絡的狀況比較複雜,因此須要定些和時間相關的關鍵的指標,指標以下:
有了這些指標纔可以有助於更好的分析網絡問題。啓動階段的網絡請求是很是多的,因此HTTP的性能是很是要注意的。如下是WWDC網絡相關的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還可以在其它平臺使用。
關於多維度分析更多的資料能夠看看歷屆WWDC的介紹。下面我列下16年來 WWDC關於啓動優化的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
延後任務管理
通過前面所說的對主線程耗時方法和各個緯度性能分析後,對於那些分析出來不必在啓動階段執行的方法,能夠作成按需或延後執行。
任務延後的處理不能粗獷的一口氣在啓動完成後在主線程一塊兒執行,那樣用戶僅僅只是看到了頁面,依然無法響應操做。那該怎麼作呢?套路通常是這樣,建立四個隊列,分別是:
有依賴關係的任務能夠放到異步串行隊列中執行。異步並行隊列能夠分組執行,好比使用dispatch_group,而後對每組任務數量進行限制,避免CPU、線程和內存瞬時激增影響主線程用戶操做,定義有限數量的串行隊列,每一個串行隊列作特定的事情,這樣也可以避免性能消耗短期忽然暴漲引發沒法響應用戶操做。使用dispatch_semaphore_t在信號量阻塞主隊列時容易出現優先級反轉,須要減小使用,確保QoS傳播。能夠用dispatch group替代,性能同樣,功能不差。異步編程能夠直接GCD接口來寫,也可使用阿里的協程框架
coobjc GitHub - alibaba/coobjc
閒時隊列實現方式是監聽主線程runloop狀態,在kCFRunLoopBeforeWaiting時開始執行閒時隊列裏的任務,在kCFRunLoopAfterWaiting時中止。
優化後如何保持?
攻易守難,就像剛到新團隊時將包大小減小了48兆,可是一年多一直可以守住,除了決心還須要有手段。對於啓動優化來講,將各個性能緯度經過監控的方式盯住是必要的,可是發現問題後快速、便捷的定位到問題仍是須要找些突破口。個人思路是將啓動階段方法耗時多的按照時間線一條一條排出來,每條包括方法名、方法層級、所屬類、所屬模塊、維護人。考慮到便捷性,最好還能方便的查看方法代碼內容。
接下來我經過開發一個工具,詳細介紹下怎麼實現這樣的效果。
如前面所說在輸出一份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:
獲得section結構Xcodeproj後,就能夠開始分析全部源文件的路徑了。根據前面列出的section的說明,PBXGroup包含了全部文件夾和文件的關係,Xcodeproj的pbxGroup字段的key是文件夾,值是文件集合,所以能夠設計一個結構體XcodeprojSourceNode用來存儲文件夾和文件關係。
接下來須要取得完整的文件路徑。經過recusiveFatherPaths函數獲取文件夾路徑。這裏須要注意的是須要處理 ../ 這種文件夾路徑符。
解析.m .mm文件
對Objective-C解析能夠參考LLVM,這裏只須要找到每一個方法對應的源碼,因此本身也能夠實現。分詞前先看看LLVM是怎麼定義token的。定義文件在這裏:
根據這個定義我設計了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
分詞過程能夠參看LLVM的實現:
clang: lib/Lex/Lexer.cpp Source File
我在處理分詞時主要是按照分隔符一一對應處理,針對代碼註釋和字符串進行了特殊處理,一個註釋一個token,一個完整字符串一個token。我分詞實現代碼:
MethodTraceAnalyze/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
解析.m和.mm文件,一個一個串行解的話,對於大工程,每次解的速度很難接受,因此採用並行方式去讀取解析多個文件。通過測試,發現每組在60個以上時可以最大利用我機器(2.5 GHz雙核Intel Core i7)的CPU,內存佔用只有60M,一萬多.m文件的工程大概2分半能解完。
使用的是dispatch group的wait,保證並行的一組完成再進入下一組。
如今有了每一個方法對應的源碼,接下來就能夠和前面trace的方法對應上。頁面展現只須要寫段js就可以控制點擊時展現對應方法的源碼。
頁面展現
在進行HTML頁面展現前,須要將代碼裏的換行和空格替換成HTML裏的對應的和 。
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標籤做爲源碼展現的標籤,方法執行順序的編號加方法名做爲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語法分析等,之因此沒有用是由於不一樣輪子使用的語言和技術區別較大,當格式更新時若是使用的單個輪子沒有更新會影響整個工具。開發這個工具主要工做是在解析上,因此使用自有解析技術也可以讓所作的功能更聚焦,不作沒用的功能,減小代碼維護量,所要解析格式更新後,也可以自主去更新解析方式。更重要的一點是能夠親手接觸下這些格式的語法設計。
結語
本文小結了啓動優化的技術手段,總的來講,對啓動進行優化的決心的重要程度是遠大於技術手段的,決定着是否可以優化的更多。技術手段有不少,我以爲手段的好壞區別只是在效率上,最差的狀況全用手動一個個去查耗時也是可以解題的。