高性能iOS應(yīng)用開發(fā) - 核心優(yōu)化

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

1. 內(nèi)存管理

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

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

因此,理解 iOS 的內(nèi)存管理是十分重要的。

1.1 內(nèi)存消耗

內(nèi)存消耗指的是應(yīng)用消耗的 RAM。

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

應(yīng)用中的內(nèi)存消耗分為兩部分:棧大小和堆大小。

1.1.1 棧大小

應(yīng)用中新創(chuàng)建的每個線程都有專用的??臻g,該空間由保留的內(nèi)存和初始提交的內(nèi)存組成。棧可以在線程存在期間自由使用。線程的最大??臻g很小,這就決定了以下的限制。

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

1.1.2 堆大小

每個進(jìn)程的所有線程共享同一個堆。一個應(yīng)用可以使用的堆大小通常遠(yuǎn)遠(yuǎn)小于設(shè)備的 RAM 值。

應(yīng)用并不能控制分配給它的堆。只有操作系統(tǒng)才能管理堆。

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

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

1.2 內(nèi)存管理模型

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

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

這種持有關(guān)系計(jì)數(shù)通常被正式稱為引用計(jì)數(shù)。

1.3 自動釋放池塊

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

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

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 塊結(jié)束時收到 release 消息。更加重要的是,每個 autorelease 調(diào)用都會發(fā)送一個 release 消息。這意味著如果一個對象收到了不止一次的 autorelease 消息,那它也會多次收到 release 消息。這一點(diǎn)很棒,因?yàn)檫@能保證對象的引用計(jì)數(shù)下降到使用 autoreleasepool 塊之前的值。如果計(jì)數(shù)為 0,則對象將被回收,從而保持較低的內(nèi)存使用率。

看了 main 方法的代碼后,你會發(fā)現(xiàn)整個應(yīng)用都在一個 autoreleasepool 塊中,這意味著所有的 autorelease 對象最后都會被回收,不會導(dǎo)致內(nèi)存泄漏。

1.4 自動引用計(jì)數(shù)

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

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

1.5 引用類型

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

  • 強(qiáng)引用。強(qiáng)引用是默認(rèn)的引用類型。被強(qiáng)引用指向的內(nèi)存不會被釋放。強(qiáng)引用會對引用計(jì)數(shù)加 1,從而擴(kuò)展對象的生命周期。
  • 弱引用。弱引用是一種特殊的引用類型。它不會增加引用計(jì)數(shù),因而不會擴(kuò)展對象的生命周期。在啟用了 ARC 的 Objective-C 編程中,弱引用格外重要。

1.5.1 變量限定符

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

  • __strong 這是默認(rèn)的限定符,無需顯示引入。只要有強(qiáng)引用指向,對象就會長時間駐留在內(nèi)存中??梢詫?__strong 理解為 retain 調(diào)用的 ARC 版本。
  • __weak 這表明引用不會保持被引用對象的存活。當(dāng)沒有強(qiáng)引用指向?qū)ο髸r,弱引用會被置為 nil??蓪?__weak 看作是 assign 操作符的 ARC 版本,只是對象被回收時,__weak 具有安全性——指針將自動被設(shè)置為 nil。
  • __unsafe_unretained__weak 類似,只是當(dāng)沒有強(qiáng)引用指向?qū)ο髸r,__unsafe_unretained 不會被置為 nil??蓪⑵淇醋?assign 操作符的 ARC 版本。
  • __autoreleasing。__autoreleasing 用于由引用使用id *傳遞的消息參數(shù)。它預(yù)期了autorelease方法會在傳遞參數(shù)的方法中被調(diào)用。

1.5.2 屬性限定符

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

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

1.6 僵尸對象

僵尸對象是用于捕捉內(nèi)存錯誤的調(diào)試功能。

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

NSZombieEnabled 是一個環(huán)境變量,可以控制 Core Foundation 的運(yùn)行時是否將使用僵尸對象。不應(yīng)長期保留 NSZombieEnabled,因?yàn)槟J(rèn)情況下不會有對象被真正析構(gòu),這會導(dǎo)致應(yīng)用使用大量的內(nèi)存。特別說明一點(diǎn),在發(fā)布的構(gòu)建包中一定要禁用 NSZombieEnabled。

要想設(shè)置 NSZombieEnabled 環(huán)境變量,需要進(jìn)入 Product → Scheme → Edit Scheme。選擇 左側(cè)的 Run,然后在右側(cè)選取 Diagnostics 標(biāo)簽頁。選中 Zombie Objects 選項(xiàng),如下圖:

1.7 循環(huán)引用

引用計(jì)數(shù)的最大陷阱在于,它不能處理環(huán)狀的引用關(guān)系,即 Objective-C 的循環(huán)引用。

1.7.1 避免循環(huán)引用的規(guī)則

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

1.7.2 循環(huán)引用的常見場景

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

1. 委托

委托很可能是引入循環(huán)引用的最常見的地方。在應(yīng)用啟動時,從服務(wù)器獲取最新的數(shù)據(jù)并更新 UI 是常見的事情。當(dāng)用戶點(diǎn)擊刷新按鈕時也會觸發(fā)類似的刷新邏輯。

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

2. block

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

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

3. 線程與計(jì)時器

不正確地使用 NSThread 和 NSTimer 對象也可能會導(dǎo)致循環(huán)引用。運(yùn)行異步操作的典型步驟如下。

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

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

1.7.3 觀察者

1. 鍵-值觀察

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

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

鍵 - 值觀察在雙向數(shù)據(jù)綁定中也非常有用。視圖可以關(guān)聯(lián)委托來響應(yīng)那些會導(dǎo)致模型更新的用戶交互。鍵 - 值觀察可以用于反向的綁定,以便在模型發(fā)生變化時更新 UI。

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

當(dāng)你為目標(biāo)對象添加鍵 - 值觀察者時,目標(biāo)對象的生命周期至少應(yīng)該和觀察者一樣長,因?yàn)橹挥羞@樣才有可能從目標(biāo)對象移除觀察者。這可能會導(dǎo)致目標(biāo)對象的生命周期比預(yù)期要長,也是你需要額外小心的地方。

2. 通知中心

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

1.8 對象壽命與泄漏

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

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

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

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

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

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

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

1.9 單例

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

讓一個對象有著與應(yīng)用一樣長的生命周期可不是什么好主意。如果這個對象是其他對象的源頭(如一個服務(wù)定位器),若定位器的實(shí)現(xiàn)不正確則有可能造成內(nèi)存風(fēng)險。

毫無疑問,單例是必要的。但單例的實(shí)現(xiàn)對其使用方式有重要影響。

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

單例極為有用,尤其是在某個系統(tǒng)確定只需要一個對象實(shí)例時。應(yīng)該在以下情形中使用單例:

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

一旦創(chuàng)建,單例會一直存活到應(yīng)用關(guān)閉。日志器、埋點(diǎn)服務(wù)以及緩存都是使用單例的合理場景。

更重要的是,單例通常會在應(yīng)用啟動時進(jìn)行初始化,打算使用單例的組件需要等它們準(zhǔn)備得當(dāng)。這會增加應(yīng)用的啟動時間。

你可以使用以下的指導(dǎo)原則。

  • 盡可能地避免使用單例。
  • 識別需要內(nèi)存的部分,如用于埋點(diǎn)的內(nèi)存緩沖區(qū)(在尚未將數(shù)據(jù)同步到服務(wù)器前使用)。尋求減少內(nèi)存的方法。注意,你需要將減少內(nèi)存與其他事情做權(quán)衡。減小緩沖區(qū)意味著更多的服務(wù)器通信。
  • 盡量避免對象級的屬性,因?yàn)樗鼈儠c對象共存亡。盡量使用本地變量。

1.10 最佳實(shí)踐

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

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

2. 能耗

設(shè)備中的每個硬件模塊都會消耗電量。電量的最大消費(fèi)者是 CPU,但這只是系統(tǒng)的一個方面。一個編寫良好的應(yīng)用需要謹(jǐn)慎地使用電能。用戶往往會刪除耗電量大的應(yīng)用。

除 CPU 外,耗電量高、值得關(guān)注的硬件模塊還包括:網(wǎng)絡(luò)硬件、藍(lán)牙、GPS、麥克風(fēng)、加 速計(jì)、攝像頭、揚(yáng)聲器和屏幕。

2.1 CPU

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

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

  • 對數(shù)據(jù)的處理(例如,對文本進(jìn)行格式化)。
  • 待處理的數(shù)據(jù)大小——更大的顯示屏允許軟件在單個視圖中展示更多的信息,但這也意味著要處理更多的數(shù)據(jù)。
  • 處理數(shù)據(jù)的算法和數(shù)據(jù)結(jié)構(gòu)。
  • 執(zhí)行更新的次數(shù),尤其是在數(shù)據(jù)更新后,觸發(fā)應(yīng)用的狀態(tài)或 UI 進(jìn)行更新(應(yīng)用收到的推送通知也會導(dǎo)致數(shù)據(jù)更新,如果此時用戶正在使用應(yīng)用,你還需要更新 UI)。

沒有單一規(guī)則可以減少設(shè)備中的執(zhí)行次數(shù)。很多規(guī)則都取決于操作的本質(zhì)。以下是一些可以在應(yīng)用中投入使用的最佳實(shí)踐。

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

2.2 網(wǎng)絡(luò)

智能的網(wǎng)絡(luò)訪問管理可以讓應(yīng)用響應(yīng)得更快,并有助于延長電池壽命。在無法訪問網(wǎng)絡(luò)時,應(yīng)當(dāng)推遲后續(xù)的網(wǎng)絡(luò)請求,直到網(wǎng)絡(luò)連接恢復(fù)為止。

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

因此,我們需要:

  • 在進(jìn)行任何網(wǎng)絡(luò)操作之前,先檢查合適的網(wǎng)絡(luò)連接是否可用;
  • 持續(xù)監(jiān)視網(wǎng)絡(luò)的可用性,并在連接狀態(tài)發(fā)生變化時給予適當(dāng)?shù)姆答仭?/li>

2.3 定位管理器和GPS

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

使用 GPS 計(jì)算坐標(biāo)需要確定兩點(diǎn)信息。

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

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

2.3.1 最佳的初始化

CLLocationManager的常用操作和屬性

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

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

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

在調(diào)用 startUpdatingLocation 方法時,兩個參數(shù)起著非常重要的作用。

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

2.3.2 關(guān)閉無關(guān)緊要的特性

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

假設(shè)用戶需要用一個消息類的應(yīng)用與朋友分享位置。如果該應(yīng)用只是發(fā)送城市的名稱,則只需要一次性地獲取位置信息,然后就可以通過調(diào)用 stopUpdatingLocation 關(guān)閉位置跟蹤。

2.3.3 只在必要時使用網(wǎng)絡(luò)

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

關(guān)鍵在于每當(dāng)應(yīng)用建立網(wǎng)絡(luò)連接時,網(wǎng)絡(luò)硬件都會在連接完成后多維持幾秒的活動時間。每次集中的網(wǎng)絡(luò)通信都會消耗大量的電量。

要想減輕這個問題帶來的危害,你的軟件需要有所保留地使用網(wǎng)絡(luò)。應(yīng)該定期集中短暫地使用網(wǎng)絡(luò),而不是持續(xù)地保持著活動的數(shù)據(jù)流。只有這樣,網(wǎng)絡(luò)硬件才有機(jī)會被關(guān)閉。

2.3.4 后臺定位服務(wù)

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

使用這一模式可以在應(yīng)用進(jìn)入后臺后繼續(xù)跟蹤運(yùn)動。(除非應(yīng)用是導(dǎo)航類應(yīng)用,且你想在鎖屏期間也獲得很好的細(xì)節(jié)。)典型的做法是在應(yīng)用進(jìn)入后臺時執(zhí)行 startMonitoringSigni-ficantLocationChanges 方法,而當(dāng)應(yīng)用回到前臺時執(zhí)行 startUpdatingLocation。

2.3.5 NSTimer、NSThread和定位服務(wù)

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

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

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

2.4 屏幕

屏幕非常耗電。屏幕越大就越費(fèi)電。當(dāng)然,如果你的應(yīng)用在前臺運(yùn)行且與用戶進(jìn)行交互,則勢必會使用屏幕并消耗電量。

然而,仍然有一些方案可以優(yōu)化屏幕的使用。

2.4.1 動畫

你可以遵守一個簡單的規(guī)則:當(dāng)應(yīng)用在前臺時使用動畫,一旦應(yīng)用進(jìn)入后臺則立即暫停動 畫。通常來說,你可以通過監(jiān)聽 UIApplicationWillResignActiveNotification 或 UIApplic ationDidEnterBackgroundNotification 的通知事件來暫?;蛲V箘赢嫞部梢酝ㄟ^監(jiān)聽 UI ApplicationDidBecomeActiveNotification 的通知事件來恢復(fù)動畫。

2.4.2 視頻播放

在視頻播放期間,最好強(qiáng)制保持屏幕常亮??梢允褂?UIApplication 對象的 idleTimerDisabled 屬性來實(shí)現(xiàn)這個目的。一旦設(shè)置為 YES,它會阻止屏幕休眠,從而實(shí)現(xiàn)常亮。與動畫類似,你可以通過響應(yīng)應(yīng)用的通知來釋放和獲取鎖。

2.5 其他硬件

當(dāng)應(yīng)用進(jìn)入后臺時,應(yīng)該釋放對這些硬件的鎖定:

  • 藍(lán)牙
  • 相機(jī)
  • 揚(yáng)聲器,除非應(yīng)用是音樂類的
  • 麥克風(fēng)

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

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

2.6 電池電量與代碼感知

一個智能的應(yīng)用會考慮到電池的電量和自身的狀態(tài),從而決定是否要真正執(zhí)行資源密集消耗型的操作。另外一個有價值的點(diǎn)是對充電的判斷,確定設(shè)備是否處于充電狀態(tài)。

使用 UIDevice 實(shí)例可以獲取 batteryLevel 和 batteryState(充電狀態(tài))。

當(dāng)剩余電量較低時提示用戶,并請求用戶授權(quán)執(zhí)行電源密集型的操作——當(dāng)然,只在用戶同意的前提下執(zhí)行??偸怯靡粋€指示符顯示長時間任務(wù)的進(jìn)度,包括設(shè)備上即將完成的計(jì)算或者只是下載一些內(nèi)容。向用戶提供完成進(jìn)度的估算,以幫助他們決定是否需要為設(shè)備充電。

2.7 分析電量使用

利用 Xcode Instruments 的 Energy Log。

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

2.8 最佳實(shí)踐

以下的最佳實(shí)踐可以確保對電量的謹(jǐn)慎使用。遵循以下要點(diǎn),應(yīng)用可以實(shí)現(xiàn)對電量的高效使用。

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

3. 并發(fā)編程

3.1 線程

線程是運(yùn)行時執(zhí)行的一組指令序列。

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

Cocoa 編程不允許其他線程更新 UI 元素。這意味著,無論何時應(yīng)用在后臺線程執(zhí)行了耗時操作,比如網(wǎng)絡(luò)或其他處理,代碼都必須將上下文切換到主線程再更新 UI——例如,進(jìn)度條指示任務(wù)進(jìn)度或標(biāo)簽展示處理結(jié)果。

3.2 線程開銷

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

3.2.1 內(nèi)核數(shù)據(jù)結(jié)構(gòu)

每個線程大約消耗 1KB 的內(nèi)核內(nèi)存空間。這塊內(nèi)存用于存儲與線程有關(guān)的數(shù)據(jù)結(jié)構(gòu)和屬性。這塊內(nèi)存是聯(lián)動內(nèi)存(wired memory),無法被分頁。

3.2.2 ??臻g

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

在線程啟動前,??臻g的大小可以被改變。??臻g的最小值是 16KB,而且其數(shù)值必須是 4KB 的倍數(shù)。

3.2.3 創(chuàng)建耗時

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

線程的啟動時間之所以如此之長,是因?yàn)槎啻蔚纳舷挛那袚Q所帶來的開銷。

3.3 GCD

GCD 提供的功能列表。

  • 任務(wù)或分發(fā)隊(duì)列,允許主線程中的執(zhí)行、并行執(zhí)行和串行執(zhí)行。
  • 分發(fā)組,實(shí)現(xiàn)對一組任務(wù)執(zhí)行情況的跟蹤,而與這些任務(wù)所基于的隊(duì)列無關(guān)。
  • 信號量。
  • 屏障,允許在并行分發(fā)隊(duì)列中創(chuàng)建同步的點(diǎn)。
  • 分發(fā)對象和管理源,實(shí)現(xiàn)更為底層的管理和監(jiān)控。
  • 異步 I/O,使用文件描述符或管道。

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

大多數(shù)情況下,應(yīng)用單獨(dú)使用 GCD 就可以很好地工作,但仍有特定的情況需要考慮使用 NSThread 或 NSOperationQueue。當(dāng)應(yīng)用中有多個長耗時的任務(wù)需要并行執(zhí)行時,最好對線程的創(chuàng)建過程加以控制。如果代碼執(zhí)行的時間過長,很有可能達(dá)到線程的限制 64 個,即 GCD 的線程池上限。 應(yīng)該避免浪費(fèi)地使用 dispatch_async 和 dispatch_sync,因?yàn)槟菚?dǎo)致應(yīng)用 崩潰 4。雖然 64 個線程對移動應(yīng)用來說是個很高的合理值,但不加控制的應(yīng) 用遲早會超出這個限制。

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

3.4 操作與隊(duì)列

操作和操作隊(duì)列是 iOS 編程中和任務(wù)管理有關(guān)的又一個重要概念。

NSOperation 封裝了一個任務(wù)以及和任務(wù)相關(guān)的數(shù)據(jù)和代碼,而 NSOperationQueue 以先入先出的順序控制了一個或多個這類任務(wù)的執(zhí)行。

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

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

  • GCD

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

    • 無默認(rèn)隊(duì)列。
    • 應(yīng)用管理自己創(chuàng)建的隊(duì)列。
    • 隊(duì)列是優(yōu)先級隊(duì)列。
    • 操作可以有不同的優(yōu)先級(使用 queuePriority 屬性)。
    • 使用 cancel 消息可以取消操作。注意,cancel 僅僅是個標(biāo)記。如果操作已經(jīng)開始執(zhí)行,則可能會繼續(xù)執(zhí)行下去。
    • 可以等待某個操作執(zhí)行完畢(使用 waitUntilFinished 消息)。
  • NSThread

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

3.5 線程安全的代碼

3.5.1 原子屬性

原子屬性是實(shí)現(xiàn)應(yīng)用狀態(tài)線程安全的一個良好開始。如果一個屬性是 atomic,則修改和讀取肯定都是原子的。

這一點(diǎn)很重要,因?yàn)檫@樣可以阻止兩個線程同時更新一個值,反之則有可能導(dǎo)致錯誤的狀態(tài)。正在修改屬性的線程必須處理完畢后,其他線程才能開始處理。

所有的屬性默認(rèn)都是原子性的。作為最佳實(shí)踐,在需要時應(yīng)該顯式地使用 atomic。否則使 用 nonatomic 標(biāo)記屬性。

因?yàn)樵訉傩源嬖陂_銷,所以過度使用它們并不明智。例如,如果能夠保證某個屬性在任何時刻都不會被多個線程訪問,那最好還是將其標(biāo)記為 nonatomic。

3.5.2 鎖

鎖是進(jìn)入臨界區(qū)的基礎(chǔ)構(gòu)件。atomic 屬性和 @synchronized 塊是為了實(shí)現(xiàn)便捷實(shí)用的高級
別抽象。

以下是三種可用的鎖。

  • NSLock

    • 這是一種低級別的鎖。一旦獲取了鎖,執(zhí)行則進(jìn)入臨界區(qū),且不會允許超過一個線程并行執(zhí)行。釋放鎖則標(biāo)記著臨界區(qū)的結(jié)束。
    • NSLock 必須在鎖定的線程中進(jìn)行解鎖。
  • NSRecursiveLock

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

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

3.5.3 將讀寫鎖應(yīng)用于并發(fā)讀寫

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

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

GCD 屏障允許在并行分發(fā)隊(duì)列上創(chuàng)建一個同步的點(diǎn)。當(dāng)遇到屏障時,GCD 會延遲執(zhí)行提交的代碼塊,直到隊(duì)列中所有在屏障之前提交的代碼塊都執(zhí)行完畢。隨后,通過屏障提交的代碼塊會單獨(dú)地執(zhí)行。我們將這個代碼塊稱為屏障塊。待其完成后,隊(duì)列會按照原有行為繼續(xù)執(zhí)行。

要想實(shí)現(xiàn)這一行為,我們需要遵循以下步驟。

  • 創(chuàng)建一個并行隊(duì)列。
  • 在這個隊(duì)列上使用 dispatch_sync 執(zhí)行所有的讀操作。
  • 在相同的隊(duì)列上使用 dispatch_barrier_sync 執(zhí)行所有的寫操作。

3.5.4 使用不可變實(shí)體

如果需要訪問一個正在修改的狀態(tài),那將會怎么樣呢?例如,如果緩存被清空,但因?yàn)橛脩魣?zhí)行了一個交互,其中部分狀態(tài)要求立即被使用,情況將會是怎樣的呢?是否存在更有效的機(jī)制以管理狀態(tài),而不是多個組件試圖同時更新狀態(tài)?

你的團(tuán)隊(duì)?wèi)?yīng)該遵循以下的最佳實(shí)踐。

  • 使用不可變實(shí)體。
  • 通過更新子系統(tǒng)提供支持。
  • 允許觀察者接收有關(guān)數(shù)據(jù)變化的通知。

3.5.5 異步優(yōu)于同步

要想實(shí)現(xiàn)線程安全、不死鎖且易于維護(hù)的代碼,強(qiáng)烈建議使用異步風(fēng)格。能放到異步處理的,就放到異步。


相關(guān)文章:高性能iOS應(yīng)用開發(fā) - iOS性能

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容