高性能iOS應用開發 - 核心優化

此篇博客是《高性能iOS應用開發》一書第二部分“核心優化”的讀書筆記,主要包括“內存管理”、“能耗(電量消耗)”、“并發編程”這三方面。

1. 內存管理

iPhone 和 iPad 設備的內存資源非常有限。如果某個應用的內存使用量超過了單個進程的上限,那么它就會被操作系統終止使用。正是由于這個原因,成功的內存管理在 iOS 應用的實現過程中扮演著核心的角色。

與(基于垃圾回收的)Java 運行時不同,Objective-C 和 Swift 的 iOS 運行時使用引用計數。使用引用計數的負面影響在于,如果開發人員不夠小心,那么可能會出現重復的內存釋放和循環引用的情況。

因此,理解 iOS 的內存管理是十分重要的。

1.1 內存消耗

內存消耗指的是應用消耗的 RAM。

iOS 的虛擬內存模型并不包含交換內存,與桌面應用不同,這意味著磁盤不會被用來分頁內存。最終的結果是應用只能使用有限的 RAM。這些 RAM 的使用者不僅包括在前臺運行的應用,還包括操作系統服務,甚至還包括其他應用所執行的后臺任務。

應用中的內存消耗分為兩部分:棧大小和堆大小。

1.1.1 棧大小

應用中新創建的每個線程都有專用的棧空間,該空間由保留的內存和初始提交的內存組成。棧可以在線程存在期間自由使用。線程的最大棧空間很小,這就決定了以下的限制。

  • 可被遞歸調用的最大方法數。每個方法都有其自己的棧幀,并會消耗整體的棧空間。
  • 一個方法中最多可以使用的變量個數。所有的變量都會載入方法的棧幀中,并消耗一定的棧空間。
  • 視圖層級中可以嵌入的最大視圖深度。渲染復合視圖將在整個視圖層級樹中遞歸地調用 layoutSubViews 和 drawRect 方法。如果層級過深,可能會導致棧溢出。

1.1.2 堆大小

每個進程的所有線程共享同一個堆。一個應用可以使用的堆大小通常遠遠小于設備的 RAM 值。

應用并不能控制分配給它的堆。只有操作系統才能管理堆。

使用 NSString、載入圖片、創建或使用 JSON/XML 數據、使用視圖等都會消耗大量的堆內存。如果你的應用大量使用圖片(與 Flickr 和 Instagram 應用類似),那么你需要格外關注平均值和峰值內存使用的最小化。

保持應用的內存需求總是處于 RAM 的較低占比是一個非常好的主意。雖然沒有強制規定,但強烈建議使用量不要超過 80%~85%,要給操作系統的核心服務留下足夠多的內存。不要忽視 didReceiveMemoryWarning 信號。

1.2 內存管理模型

內存管理模型基于持有關系的概念。當一個對象創建于某個方法的內部時,那該方法就持有這個對象了。如果一個對象正處于被持有狀態,那它占用的內存就不能被回收。

一旦與某個對象相關的任務全部完成,那么就是放棄了持有關系。這一過程沒有轉移持有關系,而是分別增加或減少了持有者的數量。當持有者的數量降為零時,對象會被釋放。

這種持有關系計數通常被正式稱為引用計數。

1.3 自動釋放池塊

自動釋放池塊是允許你放棄對一個對象的持有關系、但可避免它立即被回收的一個工具。當從方法返回對象時,這種功能非常有用。

它還能確保在塊內創建的對象會在塊完成時被回收。這在創建了多個對象的場景中非常有用。本地的塊可以用來盡早地釋放其中的對象,從而使內存用量保持在較低的水平。

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

以上代碼是 main.m 中的 @autoreleasepool 塊,塊中收到過 autorelease 消息的所有對象都會在 autoreleasepool 塊結束時收到 release 消息。更加重要的是,每個 autorelease 調用都會發送一個 release 消息。這意味著如果一個對象收到了不止一次的 autorelease 消息,那它也會多次收到 release 消息。這一點很棒,因為這能保證對象的引用計數下降到使用 autoreleasepool 塊之前的值。如果計數為 0,則對象將被回收,從而保持較低的內存使用率。

看了 main 方法的代碼后,你會發現整個應用都在一個 autoreleasepool 塊中,這意味著所有的 autorelease 對象最后都會被回收,不會導致內存泄漏。

1.4 自動引用計數

ARC 是一種編譯器特性。它評估了對象在代碼中的生命周期,并在編譯時自動注入適合的內存管理調用。編譯器還會生成適合的 dealloc 方法。這意味著與跟蹤內存使用(如確保對象被及時回收了)有關的最大難題被解決了。

  • ARC的規則
    • 不能實現或調用 retain、release、autorelease 或 retainCount 方法。這一限制不僅針對對象,對選擇器同樣有效。因此,[obj release]或@selector(retain)是編譯時的錯誤。
    • 可以實現 dealloc 方法,但不能調用它們。不僅不能調用其他對象的 dealloc 方法,也不能調用超類。[super dealloc] 是編譯時的錯誤。但你仍然可以對 Core Foundation 類型的對象調用 CFRetain、CFRelease 等相關方法。
    • 不能調用 NSAllocateObject 和 NSDeallocateObject 方法。應使用 alloc 方法創建對象, 運行時負責回收對象。
    • 不能在 C 語言的結構體內使用對象指針。
    • 不能在 id 類型和 void * 類型之間自動轉換。如果需要,那么你必須做顯示轉換。
    • 不能使用 NSAutoreleasePool,要替換使用 autoreleasepool 塊。
    • 不能使用 NSZone 內存區域。
    • 屬性的訪問器名稱不能以 new 開頭,以確保與 MRC 的互操作性。

1.5 引用類型

ARC 帶來了新的引用類型:弱引用。深入理解這些引用類型對內存管理非常重要。支持的類型包括以下兩種。

  • 強引用。強引用是默認的引用類型。被強引用指向的內存不會被釋放。強引用會對引用計數加 1,從而擴展對象的生命周期。
  • 弱引用。弱引用是一種特殊的引用類型。它不會增加引用計數,因而不會擴展對象的生命周期。在啟用了 ARC 的 Objective-C 編程中,弱引用格外重要。

1.5.1 變量限定符

ARC 為變量供了四種生命周期限定符。

  • __strong 這是默認的限定符,無需顯示引入。只要有強引用指向,對象就會長時間駐留在內存中。可以將 __strong 理解為 retain 調用的 ARC 版本。
  • __weak 這表明引用不會保持被引用對象的存活。當沒有強引用指向對象時,弱引用會被置為 nil。可將 __weak 看作是 assign 操作符的 ARC 版本,只是對象被回收時,__weak 具有安全性——指針將自動被設置為 nil。
  • __unsafe_unretained__weak 類似,只是當沒有強引用指向對象時,__unsafe_unretained 不會被置為 nil。可將其看作 assign 操作符的 ARC 版本。
  • __autoreleasing__autoreleasing 用于由引用使用id *傳遞的消息參數。它預期了autorelease方法會在傳遞參數的方法中被調用。

1.5.2 屬性限定符

屬性聲明有兩個新的持有關系限定符:strong 和 weak。此外,assign 限定符的語義也被更新了。一言以蔽之,現在共有六個限定符。

  • strong。默認符,指定了 __strong 關系。
  • weak。指定了 __weak 關系。
  • assign。這不是新的限定符,但其含義發生了改變。在 ARC 之前,assign 是默認的持有關系限 定符。在啟用 ARC 之后,assign 表示了 __unsafe_unretained 關系。
  • copy。暗指了 __strong 關系。此外,它還暗示了 setter 中的復制語義的常規行為。
  • retain。指定了 __strong 關系。
  • unsafe_unretained。指定了 __unsafe_unretained 關系。

1.6 僵尸對象

僵尸對象是用于捕捉內存錯誤的調試功能。

通常情況下,當引用計數降為 0 時對象會立即被釋放,但這使得調試變得困難。如果開啟了僵尸對象,那么對象就不會立即釋放內存,而是被標記為僵尸。任何試圖對其進行訪問的行為都會被日志記錄,因而你可以在對象的生命周期中跟蹤對象在代碼中被使用的位置。

NSZombieEnabled 是一個環境變量,可以控制 Core Foundation 的運行時是否將使用僵尸對象。不應長期保留 NSZombieEnabled,因為默認情況下不會有對象被真正析構,這會導致應用使用大量的內存。特別說明一點,在發布的構建包中一定要禁用 NSZombieEnabled。

要想設置 NSZombieEnabled 環境變量,需要進入 Product → Scheme → Edit Scheme。選擇 左側的 Run,然后在右側選取 Diagnostics 標簽頁。選中 Zombie Objects 選項,如下圖:

1.7 循環引用

引用計數的最大陷阱在于,它不能處理環狀的引用關系,即 Objective-C 的循環引用。

1.7.1 避免循環引用的規則

  • 對象不應該持有它的父對象,應該用 weak 引用指向它的父對象。
  • 作為必然的結果,一個層級體系中的子對象應該保留祖先對象。
  • 連接對象不應持有它們的目標對象。目標對象的角色是持有者。連接對象包括以下幾種。
    • 使用委托的對象。委托應該被當作目標對象,即持有者。
    • 包含目標和 action 的對象,這是由上一條規則推理得到的。例如,UIButton 會調用它的目標對象上的 action 方法。按鈕不應該保留它的目標。
    • 觀察者模式中被觀察的對象。觀察者就是持有者,并會觀察發生在被觀察對象上的變化。
  • 使用專用的銷毀方法中斷循環引用。雙向鏈表中存在循環引用,環形鏈表中也存在循環引用。在這類情況下,一旦明確對象不會再被使用時(當鏈表的表頭超出作用范圍),你要編寫代碼以打破鏈表的鏈接。創建一個方法切斷其自身與鏈表中下一個節點的鏈接。通過訪問者模式遞歸地執行這一過程,從而避免無限遞歸。

1.7.2 循環引用的常見場景

大把的常見場景會導致循環引用。例如,使用線程、計時器、簡單的塊方法或委托都可能會導致循環引用。接下來我們將逐步探索這些場景,并給出避免循環引用的步驟。

1. 委托

委托很可能是引入循環引用的最常見的地方。在應用啟動時,從服務器獲取最新的數據并更新 UI 是常見的事情。當用戶點擊刷新按鈕時也會觸發類似的刷新邏輯。

解決方案是在委托中建立對操作的強引用,并在操作中建立對委托的弱引用。

2. block

與不正確地使用委托對象導致的問題類似,在使用 block 時,捕獲外部變量也是導致循環引用的原因。

解決方案是通過弱引用獲得強引用,類似于 __weak typeof(self) weakSelf = self;

3. 線程與計時器

不正確地使用 NSThread 和 NSTimer 對象也可能會導致循環引用。運行異步操作的典型步驟如下。

  • 如果沒有編寫更高級的代碼來管理自定義的隊列,則在全局隊列上使用 dispatch_async 方法。
  • 在需要的時間和地點用 NSThread 開啟異步執行。
  • 使用 NSTimer 周期性地執行一段代碼。

解決方案:NSTimer 在主線程中不會造成循環引用,但是子線程會造成循環引用,問題應該是出在子線程問題上。在定時器釋放時必須要調用 invalidate 方法,這個方法會做一些釋放 self、block、RunLoop 等釋放資源的工作,而且釋放 RunLoop 只能釋放和定時器同一個線程的 RunLoop。

1.7.3 觀察者

1. 鍵-值觀察

Objective-C 允許用 addObserver:forKeyPath:options:context: 方法在任何 NSObject 子類的 對象上添加觀察者。觀察者會通過 observeValueForKeyPath:ofObject:change:context: 方法得到通知。removeObserver:forKeyPath:context: 方法用于解除注冊或移除觀察者。這就是眾所周知的鍵 - 值觀察。

這是一個極為有用的特性,尤其是在以調試為目的跟蹤某些共享于應用多個部分(如用戶接口、業務邏輯、持久化以及網絡)的對象時。

鍵 - 值觀察在雙向數據綁定中也非常有用。視圖可以關聯委托來響應那些會導致模型更新的用戶交互。鍵 - 值觀察可以用于反向的綁定,以便在模型發生變化時更新 UI。

這意味著觀察者需要有足夠長的生命周期才能夠持續地監控變化。你需要額外關注觀察者的生命周期,而且要持續到所觀察的內存被廢棄之后。

當你為目標對象添加鍵 - 值觀察者時,目標對象的生命周期至少應該和觀察者一樣長,因為只有這樣才有可能從目標對象移除觀察者。這可能會導致目標對象的生命周期比預期要長,也是你需要額外小心的地方。

2. 通知中心

一個對象可以注冊為通知中心(NSNotificationCenter 對象)的觀察者,并接收 NSNotification 對象。與鍵 - 值觀察者相似,通知中心不會對觀察者持有強引用。這意味著開發人員得到了解放,無需為觀察者的析構過早或過晚而操心。

1.8 對象壽命與泄漏

對象在內存中活動的時間越長,內存不能被清理的可能性就越大。所以應當盡可能地避免出現長壽命的對象。當然,你需要保留代碼中關鍵操作對象的引用,為的是不必每次都浪費時間來創建它們。盡量在使用這些對象時完成對它們的引用。

長壽命對象的常見形式是單例。日志器是典型的例子——只創建一次,從不銷毀。

另一個方案是使用全局變量。全局變量在程序開發中是可怕的東西。

要想合理地使用全局變量,必須滿足以下條件:

  • 沒有被其他對象所持有;
  • 不是常量;
  • 整個應用中只有一個,而不是每個組件一個。

如果某個變量不符合這些要求,那么它不應該被用作全局變量。

復雜的對象圖使得回收內存的機會變得更少,同時增加了應用因內存耗盡而崩潰的風險。如果主線程總是被迫等待子線程的操作(如網絡或數據庫存取),那么應用的響應性能會變得很差。

1.9 單例

單例模式是限制一個類只初始化一個對象的一種設計模式。在實踐中,初始化常常在應用啟動不久后執行,而且這些對象不會被銷毀。

讓一個對象有著與應用一樣長的生命周期可不是什么好主意。如果這個對象是其他對象的源頭(如一個服務定位器),若定位器的實現不正確則有可能造成內存風險。

毫無疑問,單例是必要的。但單例的實現對其使用方式有重要影響。

在充分討論單例引入的問題之前,我們不妨先更好地理解單例,了解一下為什么確實需要使用單例。

單例極為有用,尤其是在某個系統確定只需要一個對象實例時。應該在以下情形中使用單例:

  • 隊列操作(如日志和埋點)
  • 訪問共享資源(如緩存)
  • 資源池(如線程池或連接池)

一旦創建,單例會一直存活到應用關閉。日志器、埋點服務以及緩存都是使用單例的合理場景。

更重要的是,單例通常會在應用啟動時進行初始化,打算使用單例的組件需要等它們準備得當。這會增加應用的啟動時間。

你可以使用以下的指導原則。

  • 盡可能地避免使用單例。
  • 識別需要內存的部分,如用于埋點的內存緩沖區(在尚未將數據同步到服務器前使用)。尋求減少內存的方法。注意,你需要將減少內存與其他事情做權衡。減小緩沖區意味著更多的服務器通信。
  • 盡量避免對象級的屬性,因為它們會與對象共存亡。盡量使用本地變量。

1.10 最佳實踐

通過遵循這些最佳實踐,你將很大程度上避免許多麻煩,如內存泄漏、循環引用和較大內
存消耗。

  • 避免大量的單例。具體來說,不要出現上帝對象(如職責特別多或狀態信息特別多的對象)。這是一個反模式,指代一種常見解決方案的設計模式,但很快產生了不良效果。日志器、埋點服務和任務隊列這樣的輔助單例都是很不錯的,但全局狀態對象不可取。
  • 對子對象使用 __strong。
  • 對父對象使用 __weak。
  • 對使引用圖閉合的對象(如委托)使用 __weak。
  • 對數值屬性(NSInteger、SEL、CGFloat 等)而言,使用 assign 限定符。
  • 對于塊屬性,使用 copy 限定符。
  • 當聲明使用NSError ** 參數的方法時,需要使用 __autoreleasing,并要注意用正確的 語法: NSError * __autoreleasing *
  • 避免在塊內直接引用外部的變量。在塊外面將它們 weakify,并在塊內再將它們 strongify。 參見 libextobjc 庫 來了解 @weakify 和 @strongify。
  • 進行必要清理時遵循以下準則:
    • 銷毀計時器
    • 移除觀察者(具體來說,移除對通知的注冊)
    • 解除回調(具體來說,將強引用的委托設置為 nil)

2. 能耗

設備中的每個硬件模塊都會消耗電量。電量的最大消費者是 CPU,但這只是系統的一個方面。一個編寫良好的應用需要謹慎地使用電能。用戶往往會刪除耗電量大的應用。

除 CPU 外,耗電量高、值得關注的硬件模塊還包括:網絡硬件、藍牙、GPS、麥克風、加 速計、攝像頭、揚聲器和屏幕。

2.1 CPU

不論用戶是否正在直接使用,CPU 都是應用所使用的主要硬件。在后臺操作和處理推送通知時,應用仍會消耗 CPU 資源。

應用計算得越多,消耗的電量就越多。在完成相同的基本操作時,老一代的設備會消耗更多的電量。計算量的消耗取決于不同的因素。

  • 對數據的處理(例如,對文本進行格式化)。
  • 待處理的數據大小——更大的顯示屏允許軟件在單個視圖中展示更多的信息,但這也意味著要處理更多的數據。
  • 處理數據的算法和數據結構。
  • 執行更新的次數,尤其是在數據更新后,觸發應用的狀態或 UI 進行更新(應用收到的推送通知也會導致數據更新,如果此時用戶正在使用應用,你還需要更新 UI)。

沒有單一規則可以減少設備中的執行次數。很多規則都取決于操作的本質。以下是一些可以在應用中投入使用的最佳實踐。

  • 針對不同的情況選擇優化的算法。例如,當你在排序時,如果列表少于 43 個實例,則插入排序優于歸并排序,但實例多于 286 個時,應當使用快速排序。要優先使用雙樞軸快速排序而不是傳統的單樞軸快速排序。
  • 如果應用從服務器接收數據,盡量減少需要在客戶端進行的處理例如,如果一段文字需要在客戶端進行渲染,盡可能在服務器將數據清理干凈。
  • 優化靜態編譯(ahead-of-time,AOT)處理。動態編譯(just-in-time,JIT)處理的缺點在于它會強制用戶等待操作完成。但是激進的 AOT 處理則會導致計算資源的浪費。需要根據應用和設備選擇精確定量的 AOT 處理。
  • 分析電量消耗。測量目標用戶的所有設備上的電量消耗。找到高能耗的區域并想辦法降低能耗。

2.2 網絡

智能的網絡訪問管理可以讓應用響應得更快,并有助于延長電池壽命。在無法訪問網絡時,應當推遲后續的網絡請求,直到網絡連接恢復為止。

此外,應避免在沒有連接 WiFi 的情況下進行高帶寬消耗的操作,比如視頻流。眾所周知,蜂窩無線系統(LTE、4G、3G 等)對電量的消耗遠大于 WiFi 信號。根源在于 LTE 設備基于多輸入、多輸出技術,使用多個并發信號以維護兩端的 LTE 鏈接。類似地,所有的蜂窩數據連接都會定期掃描以尋找更強的信號。

因此,我們需要:

  • 在進行任何網絡操作之前,先檢查合適的網絡連接是否可用;
  • 持續監視網絡的可用性,并在連接狀態發生變化時給予適當的反饋。

2.3 定位管理器和GPS

了解定位服務包括 GPS(或 GLONASS)和 WiFi 硬件這一點很重要,同時要知道定位服務需要大量的電量。

使用 GPS 計算坐標需要確定兩點信息。

  • 時間鎖
    • 每個 GPS 衛星每毫秒廣播唯一一個 1023 位隨機數,因而數據傳播速率是 1.024Mbit/s。 GPS 的接收芯片必須正確地與衛星的時間鎖槽對齊。
  • 頻率鎖
    • GPS 接收器必須計算由接收器與衛星的相對運動導致的多普勒偏移帶來的信號誤差。

計算坐標會不斷地使用 CPU 和 GPS 的硬件資源,因此它們會迅速地消耗電池電量。

2.3.1 最佳的初始化

CLLocationManager的常用操作和屬性

// 開始用戶定位
- (void)startUpdatingLocation;
// 停止用戶定位
- (void) stopUpdatingLocation;

說明:當調用了 startUpdatingLocation 方法后,就開始不斷地定位用戶的位置,中途會頻繁地調用代理的下面方法

- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations;

在調用 startUpdatingLocation 方法時,兩個參數起著非常重要的作用。

  • distanceFilter
    • 只要設備的移動超過了最小距離,距離過濾器就會導致管理器對委托對象的 locationManager:didUpdateLocations: 事件通知發生變化。該距離使用公制單位(米)。這并不會有助于減少 GPS 接收器的使用,但會影響應用的處理速度,從而直接減少 CPU 的使用。
  • desiredAccuracy
    • 精度參數的使用直接影響了使用天線的個數,進而影響了對電池的消耗。精度級別的選取取決于應用的具體用途。按照降序排列,精度由以下常量定義。
      • kCLLocationAccuracyBestForNavigation 用于導航的最佳精度級別。
      • kCLLocationAccuracyBest 設備可能達到的最佳精度級別。
      • kCLLocationAccuracyNearestTenMeters 精度接近 10 米。如果對用戶所走的每一米并不感興趣,不妨使用這個值(例如,可 在測量大塊距離時使用)。
      • kCLLocationAccuracyHundredMeters 精度接近 100 米。
      • kCLLocationAccuracyKilometer 精度在千米范圍。這在粗略測量兩個距離數百千米的興趣點時非常有用。
      • kCLLocationAccuracyThreeKilometers 精度在 3 千米范圍。在距離真的很遠時使用這個值。

2.3.2 關閉無關緊要的特性

判斷何時需要跟蹤位置的變化。在需要跟蹤時調用 startUpdatingLocation 方法,無需跟蹤時調用 stopUpdatingLocation 方法。

假設用戶需要用一個消息類的應用與朋友分享位置。如果該應用只是發送城市的名稱,則只需要一次性地獲取位置信息,然后就可以通過調用 stopUpdatingLocation 關閉位置跟蹤。

2.3.3 只在必要時使用網絡

為了提高電量的使用效率,iOS 總是盡可能地保持無線網絡關閉。當應用需要建立網絡連接時,iOS 會利用這個機會向后臺應用分享網絡會話,以便一些低優先級的事件能夠被處理,如推送通知、收取電子郵件等。

關鍵在于每當應用建立網絡連接時,網絡硬件都會在連接完成后多維持幾秒的活動時間。每次集中的網絡通信都會消耗大量的電量。

要想減輕這個問題帶來的危害,你的軟件需要有所保留地使用網絡。應該定期集中短暫地使用網絡,而不是持續地保持著活動的數據流。只有這樣,網絡硬件才有機會被關閉。

2.3.4 后臺定位服務

CLLocationManager 提供了一個替代的方法來監聽位置的更新。startMonitoringSigni-ficantLocationChanges 可以幫助你在更遠的距離跟蹤運動。精確的值由內部決定,且與 distanceFilter 無關。

使用這一模式可以在應用進入后臺后繼續跟蹤運動。(除非應用是導航類應用,且你想在鎖屏期間也獲得很好的細節。)典型的做法是在應用進入后臺時執行 startMonitoringSigni-ficantLocationChanges 方法,而當應用回到前臺時執行 startUpdatingLocation。

2.3.5 NSTimer、NSThread和定位服務

當應用位于后臺時,任何定時器或線程都會掛起。但如果你在應用位于后臺狀態時申請了定位,那么應用會在每次收到更新后被短暫喚醒。在此期間,線程和計時器都會被喚醒。

可怕之處在于,如果你在這段時間做了任何網絡操作,則會啟動所有相關的天線(如 WiFi 和 LTE/4G/3G)。

想要控制這種狀況往往非常棘手。最佳的選擇是使用 NSURLSession 類。

2.4 屏幕

屏幕非常耗電。屏幕越大就越費電。當然,如果你的應用在前臺運行且與用戶進行交互,則勢必會使用屏幕并消耗電量。

然而,仍然有一些方案可以優化屏幕的使用。

2.4.1 動畫

你可以遵守一個簡單的規則:當應用在前臺時使用動畫,一旦應用進入后臺則立即暫停動 畫。通常來說,你可以通過監聽 UIApplicationWillResignActiveNotification 或 UIApplic ationDidEnterBackgroundNotification 的通知事件來暫停或停止動畫,也可以通過監聽 UI ApplicationDidBecomeActiveNotification 的通知事件來恢復動畫。

2.4.2 視頻播放

在視頻播放期間,最好強制保持屏幕常亮。可以使用 UIApplication 對象的 idleTimerDisabled 屬性來實現這個目的。一旦設置為 YES,它會阻止屏幕休眠,從而實現常亮。與動畫類似,你可以通過響應應用的通知來釋放和獲取鎖。

2.5 其他硬件

當應用進入后臺時,應該釋放對這些硬件的鎖定:

  • 藍牙
  • 相機
  • 揚聲器,除非應用是音樂類的
  • 麥克風

我們并不會在這里討論這些硬件的特性,但是基本規則是一致的——只有當應用處于前臺時才與這些硬件進行交互,應用處于后臺時應停止交互。

揚聲器和無線藍牙可能是例外。如果你正在開發音樂、收音機或其他的音頻類應用,則需要在應用進入后臺后繼續使用揚聲器。不要讓屏幕僅僅為音頻播放的目的而保持常亮。類似地,若應用還有未完成的數據傳輸,則需要在應用進入后臺后持續使用無線藍牙,例如,與其他設備傳輸文件。

2.6 電池電量與代碼感知

一個智能的應用會考慮到電池的電量和自身的狀態,從而決定是否要真正執行資源密集消耗型的操作。另外一個有價值的點是對充電的判斷,確定設備是否處于充電狀態。

使用 UIDevice 實例可以獲取 batteryLevel 和 batteryState(充電狀態)。

當剩余電量較低時提示用戶,并請求用戶授權執行電源密集型的操作——當然,只在用戶同意的前提下執行。總是用一個指示符顯示長時間任務的進度,包括設備上即將完成的計算或者只是下載一些內容。向用戶提供完成進度的估算,以幫助他們決定是否需要為設備充電。

2.7 分析電量使用

利用 Xcode Instruments 的 Energy Log。

  • 打開手機設置,點擊 "開發者",選中 Logging。
  • iOS 設置中的 Instruments 勾選 Energy,并點擊startRecording。然后打開你的 APP 跑起來。操作五分鐘左右 (具體看你的需要) ,再進入手機設置點擊 stopRecording。
  • 接著,把 iOS 設備連接 Xcode,并打開 Instruments 中的 Energy Log(Xcode --> Open Developer Tool --> Instruments --> Energy Log),點擊工具欄中 Import Logged Data from Device。導入我們 iOS 性能優化中能耗的數據。
  • Instruments 中可以看到你的 APP 的功耗。

2.8 最佳實踐

以下的最佳實踐可以確保對電量的謹慎使用。遵循以下要點,應用可以實現對電量的高效使用。

  • 最小化硬件使用。換句話說,盡可能晚地與硬件打交道,并且一旦完成任務立即結束使用。
  • 在進行密集型任務前,檢查電池電量和充電狀態。
  • 在電量低時,提示用戶是否確定要執行任務,并在用戶同意后再執行。
  • 或提供設置的選項,允許用戶定義電量的閾值,以便在執行密集型操作前提示用戶。

3. 并發編程

3.1 線程

線程是運行時執行的一組指令序列。

每個進程至少應包含一個線程。在 iOS 中,進程啟動時的主要線程通常被稱作主線程。所有的 UI 元素都需要在主線程中創建和管理。與用戶交互相關的所有中斷最終都會分發到 UI 線程,處理代碼會在這些地方執行——IBAction 方法的代碼都會在主線程中執行。

Cocoa 編程不允許其他線程更新 UI 元素。這意味著,無論何時應用在后臺線程執行了耗時操作,比如網絡或其他處理,代碼都必須將上下文切換到主線程再更新 UI——例如,進度條指示任務進度或標簽展示處理結果。

3.2 線程開銷

雖然應用有多個線程看起來非常贊,但每個線程都有一定的開銷,從而影響到應用的性能。線程不僅僅有創建時的時間開銷,還會消耗內核的內存,即應用的內存空間。

3.2.1 內核數據結構

每個線程大約消耗 1KB 的內核內存空間。這塊內存用于存儲與線程有關的數據結構和屬性。這塊內存是聯動內存(wired memory),無法被分頁。

3.2.2 棧空間

主線程的棧空間大小為 1M,而且無法修改。所有的二級線程默認分配 512KB 的棧空間。注意,完整的棧并不會立即被創建出來。實際的棧空間大小會隨著使用而增長。因此,即使主線程有 1MB 的棧空間,某個時間點的實際棧空間很可能要小很多。

在線程啟動前,棧空間的大小可以被改變。棧空間的最小值是 16KB,而且其數值必須是 4KB 的倍數。

3.2.3 創建耗時

創建線程后啟動線程的耗時區間為 5~100 毫秒,平均大約在 29 毫秒。這是很大的時間開銷,若在應用啟動時開啟多個線程,則尤為明顯。

線程的啟動時間之所以如此之長,是因為多次的上下文切換所帶來的開銷。

3.3 GCD

GCD 提供的功能列表。

  • 任務或分發隊列,允許主線程中的執行、并行執行和串行執行。
  • 分發組,實現對一組任務執行情況的跟蹤,而與這些任務所基于的隊列無關。
  • 信號量。
  • 屏障,允許在并行分發隊列中創建同步的點。
  • 分發對象和管理源,實現更為底層的管理和監控。
  • 異步 I/O,使用文件描述符或管道。

GCD 同樣解決了線程的創建與管理。它幫助我們跟蹤應用中線程的總數,且不會造成任何的泄漏。

大多數情況下,應用單獨使用 GCD 就可以很好地工作,但仍有特定的情況需要考慮使用 NSThread 或 NSOperationQueue。當應用中有多個長耗時的任務需要并行執行時,最好對線程的創建過程加以控制。如果代碼執行的時間過長,很有可能達到線程的限制 64 個,即 GCD 的線程池上限。 應該避免浪費地使用 dispatch_async 和 dispatch_sync,因為那會導致應用 崩潰 4。雖然 64 個線程對移動應用來說是個很高的合理值,但不加控制的應 用遲早會超出這個限制。

關于 GCD 線程池上限,可以參考這個文檔:stackoverflow.com:number-of-threads-created-by-gcd

3.4 操作與隊列

操作和操作隊列是 iOS 編程中和任務管理有關的又一個重要概念。

NSOperation 封裝了一個任務以及和任務相關的數據和代碼,而 NSOperationQueue 以先入先出的順序控制了一個或多個這類任務的執行。

NSOperation 和 NSOperationQueue 都提供控制線程個數的能力。可用 maxConcurrentOpera-tionCount 屬性控制隊列的個數,也可以控制每個隊列的線程個數。

以下是對 NSThread、NSOperationQueue 和 GCD API 的一個快速比較。

  • GCD

    • 抽象程度最高。
    • 兩種隊列開箱即用:main 和 global。
    • 可以創建更多的隊列(使用 dispatch_queue_create)。
    • 可以請求獨占訪問(使用 dispatch_barrier_sync 和 dispatch_barrier_async)。
    • 基于線程管理。
    • 硬性限制創建 64 個線程。
  • NSOperationQueue

    • 無默認隊列。
    • 應用管理自己創建的隊列。
    • 隊列是優先級隊列。
    • 操作可以有不同的優先級(使用 queuePriority 屬性)。
    • 使用 cancel 消息可以取消操作。注意,cancel 僅僅是個標記。如果操作已經開始執行,則可能會繼續執行下去。
    • 可以等待某個操作執行完畢(使用 waitUntilFinished 消息)。
  • NSThread

    • 低級別構造,最大化控制。
    • 應用創建并管理線程。
    • 應用創建并管理線程池。
    • 應用啟動線程。
    • 線程可以擁有優先級,操作系統會根據優先級調度它們的執行。
    • 無直接 API 用于等待線程完成。需要使用互斥量(如 NSLock)和自定義代碼。

3.5 線程安全的代碼

3.5.1 原子屬性

原子屬性是實現應用狀態線程安全的一個良好開始。如果一個屬性是 atomic,則修改和讀取肯定都是原子的。

這一點很重要,因為這樣可以阻止兩個線程同時更新一個值,反之則有可能導致錯誤的狀態。正在修改屬性的線程必須處理完畢后,其他線程才能開始處理。

所有的屬性默認都是原子性的。作為最佳實踐,在需要時應該顯式地使用 atomic。否則使 用 nonatomic 標記屬性。

因為原子屬性存在開銷,所以過度使用它們并不明智。例如,如果能夠保證某個屬性在任何時刻都不會被多個線程訪問,那最好還是將其標記為 nonatomic。

3.5.2 鎖

鎖是進入臨界區的基礎構件。atomic 屬性和 @synchronized 塊是為了實現便捷實用的高級
別抽象。

以下是三種可用的鎖。

  • NSLock

    • 這是一種低級別的鎖。一旦獲取了鎖,執行則進入臨界區,且不會允許超過一個線程并行執行。釋放鎖則標記著臨界區的結束。
    • NSLock 必須在鎖定的線程中進行解鎖。
  • NSRecursiveLock

    • NSRecursiveLock 允許在被解鎖前鎖定多次。如果解鎖的次數與鎖定的次數相匹配,則 認為鎖被釋放,其他線程可以獲取鎖。
  • NSCondition

    • 有些情況需要協調線程之間的執行。例如,一個線程可能需要等待其他線程返回結果。NSCondition 可以原子性地釋放鎖,從而使得其他等待的線程可以獲取鎖,而初始的線程繼續等待。一個線程會等待釋放鎖的條件變量。另一個線程會通知條件變量釋放該鎖,并喚醒等待中的線程。

3.5.3 將讀寫鎖應用于并發讀寫

有這么一個情況:如果有多個線程試圖讀取一個屬性,同步的代碼在同一時刻只允許單個線程進行訪問。使用上文提到的 atomic 屬性會拖慢應用的性能。

讀寫鎖允許并行訪問只讀操作,而寫操作需要互斥訪問。這意味著多個線程可以并行地讀取數據,但是修改數據時需要一個互斥鎖。

GCD 屏障允許在并行分發隊列上創建一個同步的點。當遇到屏障時,GCD 會延遲執行提交的代碼塊,直到隊列中所有在屏障之前提交的代碼塊都執行完畢。隨后,通過屏障提交的代碼塊會單獨地執行。我們將這個代碼塊稱為屏障塊。待其完成后,隊列會按照原有行為繼續執行。

要想實現這一行為,我們需要遵循以下步驟。

  • 創建一個并行隊列。
  • 在這個隊列上使用 dispatch_sync 執行所有的讀操作。
  • 在相同的隊列上使用 dispatch_barrier_sync 執行所有的寫操作。

3.5.4 使用不可變實體

如果需要訪問一個正在修改的狀態,那將會怎么樣呢?例如,如果緩存被清空,但因為用戶執行了一個交互,其中部分狀態要求立即被使用,情況將會是怎樣的呢?是否存在更有效的機制以管理狀態,而不是多個組件試圖同時更新狀態?

你的團隊應該遵循以下的最佳實踐。

  • 使用不可變實體。
  • 通過更新子系統提供支持。
  • 允許觀察者接收有關數據變化的通知。

3.5.5 異步優于同步

要想實現線程安全、不死鎖且易于維護的代碼,強烈建議使用異步風格。能放到異步處理的,就放到異步。


相關文章:高性能iOS應用開發 - iOS性能

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,646評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,595評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,560評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,035評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,814評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,224評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,301評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,444評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,988評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,804評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,998評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,544評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,237評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,665評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,927評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,706評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,993評論 2 374