世界上還有些頑固的人,拒絕使用如物件導向程式設計 (Object-Oriented Programming)(特別是繼承 (Inheritance) 與多型 (Polymorphism))、協定 (Protocol) 和協定導向程式設計 (Protocol-Oriented Programming)(特別是組合 (Composition))、泛型 (Generics) 與閉包 (Closures) 等技術。在意識層級上,這些頑固的人拒絕這些技術的原因,是因為他們認為這些技術會造成「巨大」的效能消耗。在潛意識中,這些頑固的人並不了解這些技術。所以,我們應該相信 Swift 的編譯器設計師,已經想出改善這些高階技術使用的最佳方式。我們亦發現有很多像 Xcode Instruments 這樣的工具,可以幫助我們找出這些技術中不太好的實作內容,並讓我們用自己的方式去改善。
這些高階的功能總是需要付出些代價,但是我會說在大多數情況下,最佳化的語言編譯器、強化的作業系統、以及更快的硬體(像是 SSD 以及多核心處理器),都能夠彌補這些代價。當然,偶爾會有幾個進階程式碼功能無法獲得回報的例子,也就是程式碼寫得不好或是不用在預期目的上。
大部分的時候,這些進階功能為軟體開發節省了大量的時間與資源,因為它通常都可以讓我們用更少程式碼做更多事情,而且團隊成員閱讀大家語義清晰的程式碼時,就可以節省時間與資源了。加上程式碼變得可讀且邏輯良好,所以可以輕易地被擴充、重用、調整、和維護。
但無人完美,即使我也不是完美的。軟體會越來越複雜,使用者也會不斷要求更多、更好、更快的功能,所以問題仍會發生。當 App 變得太慢,大家就會直接刪掉。
我們不再單單使用 print
語句和斷點,來找出我們的 App 如此慢的原因。請拋棄那些石器時代所謂「最佳化」的技術分享,現在我們轉向使用 Xocde 中已內建的免費好工具。
使用 Instruments 來偵測效能問題
要偵測 App 的效能問題、並決定如何改善效能時,Xcode Instruments 的 Time Profiler 模版就是我們的最佳起點。根據 Apple 的說法,Time Profiler 模版是「對系統 CPU 上的執行流程,進行低耗能並以時間為基礎的採樣」。
我們將會使用 Time Profiler 來分析我的範例 App 程式碼的效能。請先從 GitHub 上下載檔案。簡單來說,Time Profiler 會收集關於你 App 在執行時的資訊、判定你每個函式執行時所花的時間、以及指出每個函式使用時的 CPU 週期百分比;它也會收集 iOS SDK 函式的相同資料。在每個樣本點,CPU 週期的百分比(從 0% 到 100% )會隨時間顯示在稱為 “timeline” 或 “track” 的圖表上。圖表上會有多個 track,最上層的 track 會合計從所有獨立執行緒和 CPU 核心 track 的全部資料。Time Profiler 是一個非常強大的工具,可以即時顯示你 App 的效能,從而反映 CPU 使用率的激增或停歇。我們將會透過範例來實際操作。
你的分析環境
在開始之前,有一點我們要先了解:當在剖析你的程式碼時,特別是在比較相同問題的不同解決方法時,你必須注意你是在比較相同的東西,就像蘋果跟香蕉是無法比較的。換句話說,如果你剖析一個演算法並且試著把它最佳化,你就應該使用有相同配置的相同實體裝置(或是模擬器)。
當我比較範例 App 的 showEmployee()
與 show()
函式的效能時,我會在同一台 iPhone 8、以相同的配置來執行和分析。當準備用 iPhone 8 來分析時,我會先關閉所有執行中的 App。因為我要分析的程式碼不需要任何形式的連線,所以我把 WiFi、藍芽、行動網路都關閉。當然,如果你要分析網路連線的程式碼,像是與 REST API 的串接,那麼你就需要開啟 WiFi 或是行動網路。
這邊的關鍵是減少(盡可能消除)各種可能會導致 Time Profiler 分析結果產生偏差的因素。想像一下,如果你正在分析演算法,而你的 iPhone 啟動了 App 背景更新,那麼你的分析結果就可能會不正確 ── 因為人為的影響而導致你誤認為演算法比實際上慢。再想像一下,如果在 iPhone 沒有執行背景更新時,分析一個演算法;又在 iPhone 有執行背景更新時,分析另一個類似的演算法,這樣比較兩者的結果(資料集)會有意義嗎?
使用 Instruments 的 Time Profiler 模版
一起來看看我的範例專案 “Optimizing Swift Code” 的分析結果吧!在 Xcode 中打開專案,點擊並按住 Build,然後點擊 current scheme 按鈕,直至看到一個向下的箭頭。點擊向下的箭頭,然後在選單中點選 Profile,就像這樣:
一個 Choose a profiling template for: 對話框將會開啟,選擇 Time Profiler 模版然後點擊 Choose:
Time Profiler 模版將會開啟,並準備採樣及繪出 “Optimizing Swift Code” App 的效能資料。我會把下面這個畫面稱為「地圖」:
我已經按著工作流程順序,用數字標記了需要分析 App 效能的步驟。我也會在下文繼續參考這些數字標記。
要開始分析,請按記錄按鈕 ── 也就是我在地圖寫著 “1 – Start/Stop” 的按鈕:
當你覺得採樣了足夠的資料,按下 “2 – Pause” 按鈕,或是 “1 – Start/Stop” 按鈕。現在是時候來檢驗分析結果(收集的樣本資料)了。因為我正透過 GCD 在背景執行程式碼,所以可以往下捲動來看每個執行緒及核心的活動。
分析你的資料
是時候來分析記錄下來的資料了。你可以點擊 “3 – Timeline or “track”” 上的活動區塊,或是選取 timeline 的某個範圍來開始分析。記住你不能只看最上面合計的 track 資料,還要在檢查每個執行緒或核心:
當你在點擊一個時間/資料樣本、或是選取一個範圍時,該時間帶所執行的相對應程式碼,會顯示在標註為 “4 – Heaviest Stack Trace”、”5.1 – Stack trace of method names (tree)” 與 “5.2 – Measures of CPU used” 的地方。你應該知道:函式會被推入或是彈出堆疊;當函式呼叫其他函式時,會建構一個堆疊追蹤;以及 “Heaviest Stack Trace” 意味著高 CPU 使用率(循環)。
– “Heaviest Stack Trace” 面板裡的函式;及/或
– 最上層合計的 track 裡、或是在執行緒和核心 track 中,又粗又高的藍色活動記錄。
我想詳細看看 timeline 上的兩個區塊。為什麼呢?因為他們顯示出不尋常的 CPU 活動:
選擇了一個時間帶 “Region 1” 後,我首先在 “4 – Heaviest Stack Trace” 的尋找有一個人物圖示在旁、而我認識的方法。人物圖示代表我的程式碼,而其他的圖示則表示 iOS 的呼叫。”4 – Heaviest Stack Trace” 顯示了高 CPU 使用率的程式碼。
在這個案例中,我期望可以在啟動時看到某些我寫的函式。我在 “4 – Heaviest Stack Trace” 裡點擊我其中一個方法的名稱,然後它的堆疊追蹤和標記(函式/類別/結構名字)就會顯示在 “5.1 – Stack trace of method names (tree)” 與 “5.2 – Measures of CPU used” 中:
注意看所有的細節。我可以打開一個標記(函式)的名字,來看到被它呼叫過的東西。如果你進入 “Optimizing Swift Code” 專案,你會看到在 App 啟動時呼叫了 Organization.createEmployees()
方法。看一下專案檔案 Organization.swift
,你會找到以下這些程式碼:
import Foundation class Organization { var employees: [Employee] = [] func createEmployees() { for count in 1...count { let employeeID = "employee" + String(count) let age = Int(arc4random_uniform(47)) + 18 let employee = Employee(firstName: "first"+String(count), lastName: "last"+String(count), age: age, streetAddress: String(count)+" Main St", zip: "90210", employeeID: employeeID) employees.append(employee) print("Employee \(count) created.") } // end while } // end func createEmployees() } // end class Organization
但是⋯⋯ 我其實不需要切換到 Xcode 來看程式碼。
在 “5.1 – Stack trace of method names (tree)” 和 “5.2 – Measures of CPU used” 中,雙擊 Organization.createEmployees()
,就會跳到程式碼的部分:
你可以看到 Instruments 提供效能標記(像是標記了 “47x” 的黃色箭頭),但是假如你想要更深入的細節,像是組合碼 (Assembly Code) 的話,你可以點擊 Show side-by-side source/disassembly view 按鈕:
看看所有資訊,你甚至會看到 ARC 記憶體管理的實際運用,像是 retain 與 release 的陳述。請注意 ARC 對效能會有額外負擔,所以當使用類別(參考型別)時,你可能要小心地架構你的程式碼,並盡可能最佳化它。
現在你已經對 Instruments 的 Time Profiler 模版的運作有了基本概念,讓我們來看看如何最佳化一些 Swift 程式碼。
最佳化 Swift 程式碼
那些忙碌的 Swift 編譯器工程師已經盡了力,讓 Swift 更快更輕量。回顧 Swift 2.0,當中已經有很多機會讓開發者使用這個語言來最佳化程式碼。隨著 Swift 4.2 的釋出,編譯器已經非常優化 ── 但是這無法避免大家寫出糟糕的程式碼。開發者仍需要合理地最佳化程式碼,特別是那些有複雜關連、大量資料、及大量計算的程式碼。
動態調度 (Dynamic Dispatch)
讓我們來看看一個包含動態調度的例子。我希望你在閱讀這篇文章時可以先在某些段落中暫停一下,看一下這篇 Apple Developer 網誌中非常棒的文章“Increasing Performance by Reducing Dynamic Dispatch”(透過減少動態調度來提升效能)。現在,無論你是使用 Swift 還是 Objective-C 開發,你都應該很了解什麼是動態調度。
我的範例程式碼並非創新,它也不必如此。我將會用簡單的範例,讓你可以了解這個語言的概念和最佳化工具。
還記得在 Time Profiler 中的 “Region 2” 嗎?
來看看當我選擇 Timeline 內的一個範圍的時, Instruments 展示了怎麼樣的資訊:
嗯⋯⋯ 我看到了 ViewController.startButtonTapped(_:)
,但我沒看到感興趣的東西。(因為這是個簡單範例,我在背景使用 GCD 來執行程式碼,因此 iOS 使用了很多執行緒/核心來維持我的 App。)
我比較 showEmployee()
與 show()
這兩個方法的效能。你可以在 “Optimizing Swift Code” 範例專案的 ViewController.swift
檔案中看到:
@IBAction func startButtonTapped(_ sender: Any) { while true { DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async { for count in 0...count-1 { // 0...999999 //let employeeInfo = self.organization.employees[count].showEmployee() let employeeInfo = self.organization.employees[count].show() DispatchQueue.main.async { self.newEmployeesTextView.text += employeeInfo print("\(employeeInfo)\n") } } // end for count in 0...count-1 } // end DispatchQueue.global } // end while true } // end func startButtonTapped
還記得地圖中的 “6 – Find symbols” 嗎?我執行了多個分析,部分是來分析 showEmployee()
,部分則是來分析 show()
的,因為我不會盡信一個資料樣本,所以我執行了多次分析來確認我的想法。我會從研究中提出兩個代表性的樣本。(請留意,我在此步驟切換到了淺色模式。)
以下就是關於 Employee.show()
效能的部分:
以下就是關於 Employee.showEmployee()
的效能部分:
我發現 Employee.showEmployee()
比 Employee.show()
快大約 2.5 倍。我知道你會問:節省少於一毫秒的時間真的算是最佳化嗎?在這個 App 中,答案是否定的。但如果你在一些 App 上重複執行一個呼叫到百萬次的話,減少動態調度來最佳化就可能是值得的。
所以為什麼 Employee.showEmployee()
會比較快的呢?讓我們來看看程式碼。首先,看一下 People.swift
檔案:
import Foundation class Person { /* final var firstName: String final var lastName: String final var age: Int final var streetAddress: String final var zip: String */ var firstName: String var lastName: String var age: Int var streetAddress: String var zip: String init(firstName: String, lastName: String, age: Int, streetAddress: String, zip: String) { self.firstName = firstName self.lastName = lastName self.age = age self.streetAddress = streetAddress self.zip = zip } func show() -> String { let line = """ First name: \(firstName) Last name: \(lastName) Age: \(age) Street Address: \(streetAddress) Zip Code: \(zip) """ return line } } // end class Person
注意上面的 show()
方法。它在 Employees.swift
中被覆寫,像這樣:
import Foundation class Employee: Person { var employeeID: String var retirementAge: Int = 60 init(firstName: String, lastName: String, age: Int, streetAddress: String, zip: String, employeeID: String) { self.employeeID = employeeID super.init(firstName: firstName, lastName: lastName, age: age, streetAddress: streetAddress, zip: zip) } func isRetirementReady() -> Bool { if age >= retirementAge { return true } else { return false } } override func show() -> String { var line = super.show() line += "Employee ID: \(employeeID)\n" line += "Retirement Age: \(retirementAge)" return line } final func showEmployee() -> String { let line = """ First name: \(firstName) Last name: \(lastName) Age: \(age) Street Address: \(streetAddress) Zip Code: \(zip) Employee ID: \(employeeID) Zip Code: \(zip) """ return line } } // end class Employee
請注意,這裡我是刻意以誇張的做法來作說明。我不只以 showEmployee()
的形式重寫了整個 show()
方法,也將 showEmployee()
標記為 final
,讓它「允許編譯器安全地忽略動態調度。」
全模組最佳化 (Whole Module Optimization)
還記得先前我說過 Swift 編譯器變得非常聰明嗎?因為知道這點,所以我之後討論的最佳化功能都是已經預設開啟的。即便如此,我相信了解 Swift 編譯器對程式碼所做的事情對你是有幫助的。
看一下 Xcode 10 在 Swift Compiler – Code Generation 底下預設的 Build Settings:
請注意 Compilation Mode 在 Debug 是設定為 Incremental 的,而 Optimization Level 在 Debug 則是設定為 No Optimization,這些設定可以加速 Build 的時間。你應該很清楚為何以 No Optimization 執行時,速度會比較快。Incremental Build 表示只重新編譯自上次編譯以來有更改過的檔案,並計算更新過的相依項目。最後,Swift 編譯器「能夠在你的機器上以多核平行運算的方式編譯許多檔案。」
Compilation Mode 在 Release 是設定為 Whole Module,而 Optimization Level 在 Release 則是被設為 Optimize for Speed,這讓你要公開發佈 App 時加快速度。
在 “Optimizing Swift Code” 範例專案裡,我把程式碼分解為許多邏輯模組。
為客戶分析這個項目時,我發現全模組最佳化加快了公開釋出的速度。事實上,我在先前的段落中用來加強效能的改變是在 Debug 中完成的。當我在 Release 移除 final 關鍵字時,我的分析結果似乎證實了 Swift 編譯器會將這個改變視為最佳化。
全模組最佳化有一個語言特色,是可以對另一種語言產生附加效果。我們將會在下個段落談談這個。
泛型 (Generic)
想像一下像我的範例 App 中包含的泛型函式。你會在 Generics.swift
找到它:
func exists<T: Equatable>(item:T, inArray:[T]) -> Bool { var index:Int = 0 var found = false while (index < inArray.count && found == false) { if item == inArray[index] { found = true } else { index = index + 1 } } if found { return true } else { return false } } // func exists
編譯器看到這個函式,然後理解到「它必須能夠處理任何型別、遵循 Equatable 的 “T”」。想想泛型中涉及的複雜性,你需要撰寫可以處理任何型別的程式碼。
Swift 已經支援一種稱為 Generic Specialization 的最佳化,但原來全模組最佳化也能夠最佳化泛型的部份。為什麼呢?
我知道這影片有點舊,但你看完後就會理解當中的原因。
我試著分別打開和關閉全模組最佳化來分析專案來比較結果,證明了在範例專案中,全模組最佳化會加快呼叫 exists
泛型函式;這就是學習這些強大工具的方式。
簡易最佳化
假設你正在設計一個資料密集 (Data-Intensive) 的 App,而你需要選擇最快的資料結構在程式碼中使用。你可以前往像是這樣的網站,看看時間複雜度 (Big-O complexity) 圖表、回顧一下資料結構、並查看他們操作的時間需求,然後選擇能夠平衡你的需求和速度的資料結構。
ARC 記憶體管理
ARC 記憶體管理會對效能造成負擔,但是我讀過許多科學與商業的期刊文章,在參考型別與參考語義是否比數值型別與數值語義慢方面都沒有結論。
在我的工作中,只要在開發中使用最佳實作,我還是沒能夠證明通常哪一個會比另外一個快。
在撰寫這篇文章時,我實際上撰寫了整個 Swift 專案,以兩種方法執行同樣的工作來比較:1) 完全使用參考型別與語義;以及 2) 完全使用數值型別與語義。使用 Time Profiler 分析多次之後,我發現兩者並沒有任何明顯的差異。
不過,ARC 被誤用時的確會降低效能,例如是建立了太多參考、或是明明可以使用數值型別時,卻用了大量參照型別。我們必須要在兩者之間取得平衡。試想一下,如果我們以數值來取代大量參考的話,當我們需要不斷更改與複製那些數值時,後果又會怎樣呢?
總結
我鼓勵你試著使用範例專案裡的 Swift 程式碼和 Swift Compiler – Code Generation 設定,然後練習一下使用 Time Profiler 來分析並盡可能最佳化程式碼。如果我的程式碼不適合,你也可以用自己的程式碼。但請記得使用 Time Profiler 練習、練習、再練習。
你需要練習到能夠找出程式碼中的瓶頸,並最佳化程式碼。如果你之前沒有用過 Instruments 的話,可能會感到有點吃力。我已經用了這個模版很多年了,有時還是會覺得吃力。但把 Instruments 放著不用的話就有點可惜了,畢竟它提供了那麼多的方法,讓你更容易解決問題。比起只用 print
印出一些狀況或斷點來解決 bug,並且最佳化程式碼,Instruments 來得更加方便易用。
請不要因為幾次不好的經驗、或是聽說過它們「耗效能」,就放棄像是物件導向程式語言、協定與協定導向程式語言、泛型與閉包等工具。如果你用得正確,這些工具真的可以減少程式碼的數量,並為你的 App 加快速度。這些工具真的可以提升程式碼的可維護性、可讀性、及可重用性。
請記住,你有很多很好的 Xcode 工具,多多善用它們吧!