對於每位 iOS 開發者來說,程式碼效能是個避不開的話題。隨著專案的擴大和功能的增多,沒經過認真除錯和優化的程式碼,要麼任性地卡頓執行,要麼低調地崩潰了之……結果呢,大家用著不高興,開發者也不開心。
其實要破這個局面並不難,只要在 Xcode 自帶的監控除錯工具 Instruments 上花點功夫,讓大程式碼流暢執行也不是神話。Instruments 提供了很多功能,我會重點介紹一下我最常用的三大類:
- Time Profiler:分析程式碼的執行時間,找出導致程式變慢的原因。
- Allocations:監測記憶體使用/分配情況
迅速膨脹的記憶體可以很快讓程式斃命,所以要多加防範。 - Leaks:找到引發記憶體洩漏的起點
即使有 ARC(自動引用計數)記憶體管理機制,但在現實中物件之間引用複雜,迴圈引用導致的記憶體洩漏仍然難以避免,所以關鍵時刻還要自力更生。
針對這三方面的測試,在 GitHub 上,有個演示應用,來幫助大家更直觀地瞭解這些工具的使用方法。好,進入正題。
Time Profiler
時間都去哪兒啦? Time Profiler 可以回答。它會按照設定的時間間隔(預設 1 毫秒)來跟蹤每一執行緒的堆疊資訊(stack trace),並通過比較時間間隔之間的堆疊狀態,來推算出某個方法執行了多久,給出一個近似值。
在演示應用頭一項「Time Profiler: System Methods」中,我用插入排序(Insertion Sort)和氣泡排序(Bubble Sort)兩種演算法來做效能比較,下面是 Swift 程式碼:
/* 引用自:http://waynewbishop.com/swift/sorting-algorithms/ */
func insertionSort() {
var x, y, key: Int
for (x = 0; x < numberList.count; x++) {
key = numberList[x]
for (y = x; y > -1; y--) {
if key < numberList[y] {
numberList.removeAtIndex(y + 1)
numberList.insert(key, atIndex: y)
}
}
}
}
func bubbleSort() {
var x, y, z, passes, key : Int
for (x = 0; x < numberList.count; ++x) {
passes = (numberList.count - 1) - x;
for (y = 0; y < passes; y++) {
key = numberList[y]
if (key > numberList[y + 1]) {
z = numberList[y + 1]
numberList[y + 1] = key
numberList[y] = z
}
}
}
}
這段程式碼主要是對陣列的新增和刪除,兩種方法執行起來耗時不多,但後臺發生的系統動作卻多得讓人眼暈。
可以發現,程式碼用到了很多間接依賴,這些都是支撐程式碼執行的系統庫檔案。因為處理大資料集比較消耗系統資源,所以要儘可能地把繁重的操作放到後臺去做,上面的程式碼就走的後臺執行緒。在上圖的 Call Tree 中可以看到,被呼叫的堆疊名是 dispatch_worker_thread3。如果把它放到主執行緒去執行,程式肯定會掛起。不信你註釋掉 dispatch_async 呼叫看一下。
再來個圖片載入的例子。
這兒有三種圖片載入方法:
- loadSlowImage1:從指定 URL 下載一張圖片(載入速度慢)
- loadImage2:從本地資源庫載入一張圖片(注意:沒用系統快取)
- loadFastImage3:從系統快取中載入一張圖片(載入速度快)
我們來看看 Time Profiler 算出的結果是不是跟預想的一樣。
進入演示應用第二項「Time Profiler: Our Methods」,點選「Reload」十次來重複載入圖片,這樣能產生足夠的資料來分析。然後在 Time Profiler 圖表中通過拖拉滑鼠選中要放大檢視的區域,從 Call Tree 中雙擊呼叫了 .reload 方法那一行(上圖中加亮選中那一行),就會跳轉到對應的程式碼行,所用時間也標註出來了。
看到誰最花時間了吧。雖然程式碼沒什麼可優化的地方,但大家應該認識到快取能發揮的作用。所以即使有時還得呼叫 loadSlowImage,多數情況下把圖片快取下來,還是能省些資源佔用。
此外,我想再說說 Call Tree 的選項設定。
這些選項預設是不選的,但把它們勾選上可以幫你更快定位到關鍵的程式碼上,往往這也是問題的源頭。
- Separate by Thread:按執行緒分開做分析,這樣更容易揪出那些吃資源的問題執行緒。特別是對於主執行緒,它要處理和渲染所有的介面資料,一旦受到阻塞,程式必然卡頓或停止響應。
- Invert Call Tree:反向輸出呼叫樹。把呼叫層級最深的方法顯示在最上面,更容易找到最耗時的操作。
- Hide Missing Symbols:隱藏缺失符號。如果 dSYM 檔案或其他系統架構缺失,列表中會出現很多奇怪的十六進位制的數值,用此選項把這些干擾元素遮蔽掉,讓列表迴歸清爽。
- Hide System Libraries:隱藏系統庫檔案。過濾掉各種系統呼叫,只顯示自己的程式碼呼叫。
- Flattern Recursion:拼合遞迴。將同一遞迴函式產生的多條堆疊(因為遞迴函式會呼叫自己)合併為一條。
- Top Functions:找到最耗時的函式或方法。
需要新增其他工具的話:
Allocations
我們經常需要從伺服器下載大量圖片,特別是開發照片類的應用。但往往稍不注意,記憶體使用就會暴增,所以得保證把這些圖片快取下來以便重複使用。下面來看看演示程式中記憶體分配的例子。
從圖中可以看到,每次點選「Reload」重新載入圖片時,記憶體都會出現使用峰值。應用先分配大量記憶體來替換原有圖片,然後再釋放掉這部分記憶體,可想而知這樣的操作效率高不了,而且如果要下載更大的檔案,呃,局面大概會失控吧。
看一下堆疊列表第四行,ImageIO_PNG_Data 裡有 9 張處於活動狀態的圖片,佔用了12.38 MB 記憶體,這些都是沒被系統釋放或快取的記憶體,所以導致堆記憶體分配升高。接下來再看看使用快取後的效果。
使用了快取庫(Swift Haneke)後,點「Reload」五次,這回在 Allocations 列表中卻看不到 ImageIO_PNG_Data 物件了,這說明它是空的,沒有任何影象資料。同時,All Heap Allocations 的大小已從剛才的 14.61 MB 降到了 2.51 MB。Anonymous VM(匿名虛擬記憶體)是系統為程式預留的、可能會立即被重複使用的一部分可用記憶體。要防止程式崩潰,就別讓堆的尺寸增長太快。
還有就是,例子用的是非同步方式來載入圖片,這樣用不著等到所有圖片下載完才能在介面中顯示。大多數影象快取庫都會把載入工作放到後臺,以避免延長主執行緒的響應週期。
Leaks
儘管 Apple 推出的 ARC 可以有效防範記憶體洩漏,但出問題的機率還是會有,Swift 也不例外。鑑於篇幅有限,本文就不涉及記憶體和 ARC 的工作原理了,具體可以參考官方文件。我會用程式碼來觸發記憶體洩漏。
首先從最底層上說,當兩個物件相互建立了強引用(strong reference),當一個物件被釋放,另一個物件由於是強引用的關係不允許被釋放,此時 ARC 無法確定沒被釋放的物件到底還有沒有用,於是就導致了記憶體洩漏。
要解決這個問題,可以將其中的一個物件中變數設為 weak,不讓它出現在保留週期中。很多開發者在管理 view controller 時常會在記憶體洩漏上中招,以為換了新的 controller,老的 controller 就被釋放回收了,其實還沒。這樣程式碼一多,就會造成很多物件都沒被釋放。所以用這個工具把整個應用跑一遍,把那些斷鏈的強引用清理乾淨,會大有裨益。
除了上述這三類工具,Instruments 還有很多實用的工具,推薦大家根據自己的關注點,花些時間去學學。比如:
- Core Data:監測讀取、快取未命中、儲存等操作,能直觀顯示是否儲存次數遠超實際需要。
- Cocoa Layout:觀察約束變化,找出佈局程式碼的問題所在。
- Network:跟蹤 TCP / IP和 UDP / IP 連線。
- Automations:建立和編輯測試指令碼來自動化 iOS 應用的使用者介面測試。
最後小總結下。如果應用跑得挺痛快,沒出現啥調皮行為,大可把它忽略,等到問題來了再做優化。對於新手來說,花些時間瞭解 Instruments 的功能,多除錯多積累經驗,這樣做出來的應用在使用者體驗上肯定錯不了。