iOS 保持界面流暢的技巧

聲明:這篇文字轉(zhuǎn)自https://blog.ibireme.com/2015/11/12/smooth_user_interfaces_for_ios/

這篇文章會(huì)非常詳細(xì)的分析 iOS 界面構(gòu)建中的各種性能問(wèn)題以及對(duì)應(yīng)的解決思路,同時(shí)給出一個(gè)開(kāi)源的微博列表實(shí)現(xiàn),通過(guò)實(shí)際的代碼展示如何構(gòu)建流暢的交互。

Index

演示項(xiàng)目

屏幕顯示圖像的原理

卡頓產(chǎn)生的原因和解決方案

CPU 資源消耗原因和解決方案

GPU 資源消耗原因和解決方案

AsyncDisplayKit

ASDK 的由來(lái)

ASDK 的資料

ASDK 的基本原理

ASDK 的圖層預(yù)合成

ASDK 異步并發(fā)操作

Runloop 任務(wù)分發(fā)

微博 Demo 性能優(yōu)化技巧

預(yù)排版

預(yù)渲染

異步繪制

全局并發(fā)控制

更高效的異步圖片加載

其他可以改進(jìn)的地方

如何評(píng)測(cè)界面的流暢度

演示項(xiàng)目

在開(kāi)始技術(shù)討論前,你可以先下載我寫(xiě)的 Demo 跑到真機(jī)上體驗(yàn)一下:https://github.com/ibireme/YYKit。 Demo 里包含一個(gè)微博的 Feed 列表、發(fā)布視圖,還包含一個(gè) Twitter 的 Feed 列表。為了公平起見(jiàn),所有界面和交互我都從官方應(yīng)用原封不動(dòng)的抄了過(guò)來(lái),數(shù)據(jù)也都是從官方應(yīng)用抓取的。你也可以自己抓取數(shù)據(jù)替換掉 Demo 中的數(shù)據(jù),方便進(jìn)行對(duì)比。盡管官方應(yīng)用背后的功能更多更為復(fù)雜,但不至于會(huì)帶來(lái)太大的交互性能差異。

這個(gè) Demo 最低可以運(yùn)行在 iOS 6 上,所以你可以把它跑到老設(shè)備上體驗(yàn)一下。在我的測(cè)試中,即使在 iPhone 4S 或者 iPad 3 上,Demo 列表在快速滑動(dòng)時(shí)仍然能保持 50~60 FPS 的流暢交互,而其他諸如微博、朋友圈等應(yīng)用的列表視圖在滑動(dòng)時(shí)已經(jīng)有很?chē)?yán)重的卡頓了。

微博的 Demo 有大約四千行代碼,Twitter 的只有兩千行左右代碼,第三方庫(kù)只用到了 YYKit,文件數(shù)量比較少,方便查看。好了,下面是正文。

屏幕顯示圖像的原理

首先從過(guò)去的 CRT 顯示器原理說(shuō)起。CRT 的電子槍按照上面方式,從上到下一行行掃描,掃描完成后顯示器就呈現(xiàn)一幀畫(huà)面,隨后電子槍回到初始位置繼續(xù)下一次掃描。為了把顯示器的顯示過(guò)程和系統(tǒng)的視頻控制器進(jìn)行同步,顯示器(或者其他硬件)會(huì)用硬件時(shí)鐘產(chǎn)生一系列的定時(shí)信號(hào)。當(dāng)電子槍換到新的一行,準(zhǔn)備進(jìn)行掃描時(shí),顯示器會(huì)發(fā)出一個(gè)水平同步信號(hào)(horizonal synchronization),簡(jiǎn)稱 HSync;而當(dāng)一幀畫(huà)面繪制完成后,電子槍回復(fù)到原位,準(zhǔn)備畫(huà)下一幀前,顯示器會(huì)發(fā)出一個(gè)垂直同步信號(hào)(vertical synchronization),簡(jiǎn)稱 VSync。顯示器通常以固定頻率進(jìn)行刷新,這個(gè)刷新率就是 VSync 信號(hào)產(chǎn)生的頻率。盡管現(xiàn)在的設(shè)備大都是液晶顯示屏了,但原理仍然沒(méi)有變。

通常來(lái)說(shuō),計(jì)算機(jī)系統(tǒng)中 CPU、GPU、顯示器是以上面這種方式協(xié)同工作的。CPU 計(jì)算好顯示內(nèi)容提交到 GPU,GPU 渲染完成后將渲染結(jié)果放入幀緩沖區(qū),隨后視頻控制器會(huì)按照 VSync 信號(hào)逐行讀取幀緩沖區(qū)的數(shù)據(jù),經(jīng)過(guò)可能的數(shù)模轉(zhuǎn)換傳遞給顯示器顯示。

在最簡(jiǎn)單的情況下,幀緩沖區(qū)只有一個(gè),這時(shí)幀緩沖區(qū)的讀取和刷新都都會(huì)有比較大的效率問(wèn)題。為了解決效率問(wèn)題,顯示系統(tǒng)通常會(huì)引入兩個(gè)緩沖區(qū),即雙緩沖機(jī)制。在這種情況下,GPU 會(huì)預(yù)先渲染好一幀放入一個(gè)緩沖區(qū)內(nèi),讓視頻控制器讀取,當(dāng)下一幀渲染好后,GPU 會(huì)直接把視頻控制器的指針指向第二個(gè)緩沖器。如此一來(lái)效率會(huì)有很大的提升。

雙緩沖雖然能解決效率問(wèn)題,但會(huì)引入一個(gè)新的問(wèn)題。當(dāng)視頻控制器還未讀取完成時(shí),即屏幕內(nèi)容剛顯示一半時(shí),GPU 將新的一幀內(nèi)容提交到幀緩沖區(qū)并把兩個(gè)緩沖區(qū)進(jìn)行交換后,視頻控制器就會(huì)把新的一幀數(shù)據(jù)的下半段顯示到屏幕上,造成畫(huà)面撕裂現(xiàn)象,如下圖:

為了解決這個(gè)問(wèn)題,GPU 通常有一個(gè)機(jī)制叫做垂直同步(簡(jiǎn)寫(xiě)也是 V-Sync),當(dāng)開(kāi)啟垂直同步后,GPU 會(huì)等待顯示器的 VSync 信號(hào)發(fā)出后,才進(jìn)行新的一幀渲染和緩沖區(qū)更新。這樣能解決畫(huà)面撕裂現(xiàn)象,也增加了畫(huà)面流暢度,但需要消費(fèi)更多的計(jì)算資源,也會(huì)帶來(lái)部分延遲。

那么目前主流的移動(dòng)設(shè)備是什么情況呢?從網(wǎng)上查到的資料可以知道,iOS 設(shè)備會(huì)始終使用雙緩存,并開(kāi)啟垂直同步。而安卓設(shè)備直到 4.1 版本,Google 才開(kāi)始引入這種機(jī)制,目前安卓系統(tǒng)是三緩存+垂直同步。

卡頓產(chǎn)生的原因和解決方案

在 VSync 信號(hào)到來(lái)后,系統(tǒng)圖形服務(wù)會(huì)通過(guò) CADisplayLink 等機(jī)制通知 App,App 主線程開(kāi)始在 CPU 中計(jì)算顯示內(nèi)容,比如視圖的創(chuàng)建、布局計(jì)算、圖片解碼、文本繪制等。隨后 CPU 會(huì)將計(jì)算好的內(nèi)容提交到 GPU 去,由 GPU 進(jìn)行變換、合成、渲染。隨后 GPU 會(huì)把渲染結(jié)果提交到幀緩沖區(qū)去,等待下一次 VSync 信號(hào)到來(lái)時(shí)顯示到屏幕上。由于垂直同步的機(jī)制,如果在一個(gè) VSync 時(shí)間內(nèi),CPU 或者 GPU 沒(méi)有完成內(nèi)容提交,則那一幀就會(huì)被丟棄,等待下一次機(jī)會(huì)再顯示,而這時(shí)顯示屏?xí)A糁暗膬?nèi)容不變。這就是界面卡頓的原因。

從上面的圖中可以看到,CPU 和 GPU 不論哪個(gè)阻礙了顯示流程,都會(huì)造成掉幀現(xiàn)象。所以開(kāi)發(fā)時(shí),也需要分別對(duì) CPU 和 GPU 壓力進(jìn)行評(píng)估和優(yōu)化。

CPU 資源消耗原因和解決方案

對(duì)象創(chuàng)建

對(duì)象的創(chuàng)建會(huì)分配內(nèi)存、調(diào)整屬性、甚至還有讀取文件等操作,比較消耗 CPU 資源。盡量用輕量的對(duì)象代替重量的對(duì)象,可以對(duì)性能有所優(yōu)化。比如 CALayer 比 UIView 要輕量許多,那么不需要響應(yīng)觸摸事件的控件,用 CALayer 顯示會(huì)更加合適。如果對(duì)象不涉及 UI 操作,則盡量放到后臺(tái)線程去創(chuàng)建,但可惜的是包含有 CALayer 的控件,都只能在主線程創(chuàng)建和操作。通過(guò) Storyboard 創(chuàng)建視圖對(duì)象時(shí),其資源消耗會(huì)比直接通過(guò)代碼創(chuàng)建對(duì)象要大非常多,在性能敏感的界面里,Storyboard 并不是一個(gè)好的技術(shù)選擇。

盡量推遲對(duì)象創(chuàng)建的時(shí)間,并把對(duì)象的創(chuàng)建分散到多個(gè)任務(wù)中去。盡管這實(shí)現(xiàn)起來(lái)比較麻煩,并且?guī)?lái)的優(yōu)勢(shì)并不多,但如果有能力做,還是要盡量嘗試一下。如果對(duì)象可以復(fù)用,并且復(fù)用的代價(jià)比釋放、創(chuàng)建新對(duì)象要小,那么這類(lèi)對(duì)象應(yīng)當(dāng)盡量放到一個(gè)緩存池里復(fù)用。

對(duì)象調(diào)整

對(duì)象的調(diào)整也經(jīng)常是消耗 CPU 資源的地方。這里特別說(shuō)一下 CALayer:CALayer 內(nèi)部并沒(méi)有屬性,當(dāng)調(diào)用屬性方法時(shí),它內(nèi)部是通過(guò)運(yùn)行時(shí)?resolveInstanceMethod 為對(duì)象臨時(shí)添加一個(gè)方法,并把對(duì)應(yīng)屬性值保存到內(nèi)部的一個(gè) Dictionary 里,同時(shí)還會(huì)通知 delegate、創(chuàng)建動(dòng)畫(huà)等等,非常消耗資源。UIView 的關(guān)于顯示相關(guān)的屬性(比如 frame/bounds/transform)等實(shí)際上都是 CALayer 屬性映射來(lái)的,所以對(duì) UIView 的這些屬性進(jìn)行調(diào)整時(shí),消耗的資源要遠(yuǎn)大于一般的屬性。對(duì)此你在應(yīng)用中,應(yīng)該盡量減少不必要的屬性修改。

當(dāng)視圖層次調(diào)整時(shí),UIView、CALayer 之間會(huì)出現(xiàn)很多方法調(diào)用與通知,所以在優(yōu)化性能時(shí),應(yīng)該盡量避免調(diào)整視圖層次、添加和移除視圖。

對(duì)象銷(xiāo)毀

對(duì)象的銷(xiāo)毀雖然消耗資源不多,但累積起來(lái)也是不容忽視的。通常當(dāng)容器類(lèi)持有大量對(duì)象時(shí),其銷(xiāo)毀時(shí)的資源消耗就非常明顯。同樣的,如果對(duì)象可以放到后臺(tái)線程去釋放,那就挪到后臺(tái)線程去。這里有個(gè)小 Tip:把對(duì)象捕獲到 block 中,然后扔到后臺(tái)隊(duì)列去隨便發(fā)送個(gè)消息以避免編譯器警告,就可以讓對(duì)象在后臺(tái)線程銷(xiāo)毀了。

1

2

3

4

5NSArray *tmp=self.array;

self.array=nil;

dispatch_async(queue,^{

[tmpclass];

});

布局計(jì)算

視圖布局的計(jì)算是 App 中最為常見(jiàn)的消耗 CPU 資源的地方。如果能在后臺(tái)線程提前計(jì)算好視圖布局、并且對(duì)視圖布局進(jìn)行緩存,那么這個(gè)地方基本就不會(huì)產(chǎn)生性能問(wèn)題了。

不論通過(guò)何種技術(shù)對(duì)視圖進(jìn)行布局,其最終都會(huì)落到對(duì) UIView.frame/bounds/center 等屬性的調(diào)整上。上面也說(shuō)過(guò),對(duì)這些屬性的調(diào)整非常消耗資源,所以盡量提前計(jì)算好布局,在需要時(shí)一次性調(diào)整好對(duì)應(yīng)屬性,而不要多次、頻繁的計(jì)算和調(diào)整這些屬性。

Autolayout

Autolayout 是蘋(píng)果本身提倡的技術(shù),在大部分情況下也能很好的提升開(kāi)發(fā)效率,但是 Autolayout 對(duì)于復(fù)雜視圖來(lái)說(shuō)常常會(huì)產(chǎn)生嚴(yán)重的性能問(wèn)題。隨著視圖數(shù)量的增長(zhǎng),Autolayout 帶來(lái)的 CPU 消耗會(huì)呈指數(shù)級(jí)上升。具體數(shù)據(jù)可以看這個(gè)文章:http://pilky.me/36/。 如果你不想手動(dòng)調(diào)整 frame 等屬性,你可以用一些工具方法替代(比如常見(jiàn)的 left/right/top/bottom/width/height 快捷屬性),或者使用 ComponentKit、AsyncDisplayKit 等框架。

文本計(jì)算

如果一個(gè)界面中包含大量文本(比如微博微信朋友圈等),文本的寬高計(jì)算會(huì)占用很大一部分資源,并且不可避免。如果你對(duì)文本顯示沒(méi)有特殊要求,可以參考下 UILabel 內(nèi)部的實(shí)現(xiàn)方式:用 [NSAttributedString boundingRectWithSize:options:context:] 來(lái)計(jì)算文本寬高,用 -[NSAttributedString drawWithRect:options:context:] 來(lái)繪制文本。盡管這兩個(gè)方法性能不錯(cuò),但仍舊需要放到后臺(tái)線程進(jìn)行以避免阻塞主線程。

如果你用 CoreText 繪制文本,那就可以先生成 CoreText 排版對(duì)象,然后自己計(jì)算了,并且 CoreText 對(duì)象還能保留以供稍后繪制使用。

文本渲染

屏幕上能看到的所有文本內(nèi)容控件,包括 UIWebView,在底層都是通過(guò) CoreText 排版、繪制為 Bitmap 顯示的。常見(jiàn)的文本控件 (UILabel、UITextView 等),其排版和繪制都是在主線程進(jìn)行的,當(dāng)顯示大量文本時(shí),CPU 的壓力會(huì)非常大。對(duì)此解決方案只有一個(gè),那就是自定義文本控件,用 TextKit 或最底層的 CoreText 對(duì)文本異步繪制。盡管這實(shí)現(xiàn)起來(lái)非常麻煩,但其帶來(lái)的優(yōu)勢(shì)也非常大,CoreText 對(duì)象創(chuàng)建好后,能直接獲取文本的寬高等信息,避免了多次計(jì)算(調(diào)整 UILabel 大小時(shí)算一遍、UILabel 繪制時(shí)內(nèi)部再算一遍);CoreText 對(duì)象占用內(nèi)存較少,可以緩存下來(lái)以備稍后多次渲染。

圖片的解碼

當(dāng)你用 UIImage 或 CGImageSource 的那幾個(gè)方法創(chuàng)建圖片時(shí),圖片數(shù)據(jù)并不會(huì)立刻解碼。圖片設(shè)置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的數(shù)據(jù)才會(huì)得到解碼。這一步是發(fā)生在主線程的,并且不可避免。如果想要繞開(kāi)這個(gè)機(jī)制,常見(jiàn)的做法是在后臺(tái)線程先把圖片繪制到 CGBitmapContext 中,然后從 Bitmap 直接創(chuàng)建圖片。目前常見(jiàn)的網(wǎng)絡(luò)圖片庫(kù)都自帶這個(gè)功能。

圖像的繪制

圖像的繪制通常是指用那些以 CG 開(kāi)頭的方法把圖像繪制到畫(huà)布中,然后從畫(huà)布創(chuàng)建圖片并顯示這樣一個(gè)過(guò)程。這個(gè)最常見(jiàn)的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是線程安全的,所以圖像的繪制可以很容易的放到后臺(tái)線程進(jìn)行。一個(gè)簡(jiǎn)單異步繪制的過(guò)程大致如下(實(shí)際情況會(huì)比這個(gè)復(fù)雜得多,但原理基本一致):

1

2

3

4

5

6

7

8

9

10

11-(void)display{

dispatch_async(backgroundQueue,^{

CGContextRefctx=CGBitmapContextCreate(...);

// draw in context...

CGImageRefimg=CGBitmapContextCreateImage(ctx);

CFRelease(ctx);

dispatch_async(mainQueue,^{

layer.contents=img;

});

});

}

GPU 資源消耗原因和解決方案

相對(duì)于 CPU 來(lái)說(shuō),GPU 能干的事情比較單一:接收提交的紋理(Texture)和頂點(diǎn)描述(三角形),應(yīng)用變換(transform)、混合并渲染,然后輸出到屏幕上。通常你所能看到的內(nèi)容,主要也就是紋理(圖片)和形狀(三角模擬的矢量圖形)兩類(lèi)。

紋理的渲染

所有的 Bitmap,包括圖片、文本、柵格化的內(nèi)容,最終都要由內(nèi)存提交到顯存,綁定為 GPU Texture。不論是提交到顯存的過(guò)程,還是 GPU 調(diào)整和渲染 Texture 的過(guò)程,都要消耗不少 GPU 資源。當(dāng)在較短時(shí)間顯示大量圖片時(shí)(比如 TableView 存在非常多的圖片并且快速滑動(dòng)時(shí)),CPU 占用率很低,GPU 占用非常高,界面仍然會(huì)掉幀。避免這種情況的方法只能是盡量減少在短時(shí)間內(nèi)大量圖片的顯示,盡可能將多張圖片合成為一張進(jìn)行顯示。

當(dāng)圖片過(guò)大,超過(guò) GPU 的最大紋理尺寸時(shí),圖片需要先由 CPU 進(jìn)行預(yù)處理,這對(duì) CPU 和 GPU 都會(huì)帶來(lái)額外的資源消耗。目前來(lái)說(shuō),iPhone 4S 以上機(jī)型,紋理尺寸上限都是 4096×4096,更詳細(xì)的資料可以看這里:iosres.com。所以,盡量不要讓圖片和視圖的大小超過(guò)這個(gè)值。

視圖的混合 (Composing)

當(dāng)多個(gè)視圖(或者說(shuō) CALayer)重疊在一起顯示時(shí),GPU 會(huì)首先把他們混合到一起。如果視圖結(jié)構(gòu)過(guò)于復(fù)雜,混合的過(guò)程也會(huì)消耗很多 GPU 資源。為了減輕這種情況的 GPU 消耗,應(yīng)用應(yīng)當(dāng)盡量減少視圖數(shù)量和層次,并在不透明的視圖里標(biāo)明 opaque 屬性以避免無(wú)用的 Alpha 通道合成。當(dāng)然,這也可以用上面的方法,把多個(gè)視圖預(yù)先渲染為一張圖片來(lái)顯示。

圖形的生成。

CALayer 的 border、圓角、陰影、遮罩(mask),CASharpLayer 的矢量圖形顯示,通常會(huì)觸發(fā)離屏渲染(offscreen rendering),而離屏渲染通常發(fā)生在 GPU 中。當(dāng)一個(gè)列表視圖中出現(xiàn)大量圓角的 CALayer,并且快速滑動(dòng)時(shí),可以觀察到 GPU 資源已經(jīng)占滿,而 CPU 資源消耗很少。這時(shí)界面仍然能正?;瑒?dòng),但平均幀數(shù)會(huì)降到很低。為了避免這種情況,可以嘗試開(kāi)啟 CALayer.shouldRasterize 屬性,但這會(huì)把原本離屏渲染的操作轉(zhuǎn)嫁到 CPU 上去。對(duì)于只需要圓角的某些場(chǎng)合,也可以用一張已經(jīng)繪制好的圓角圖片覆蓋到原本視圖上面來(lái)模擬相同的視覺(jué)效果。最徹底的解決辦法,就是把需要顯示的圖形在后臺(tái)線程繪制為圖片,避免使用圓角、陰影、遮罩等屬性。

AsyncDisplayKit

AsyncDisplayKit 是 Facebook 開(kāi)源的一個(gè)用于保持 iOS 界面流暢的庫(kù),我從中學(xué)到了很多東西,所以下面我會(huì)花較大的篇幅來(lái)對(duì)其進(jìn)行介紹和分析。

ASDK 的由來(lái)

ASDK 的作者是 Scott Goodson (Linkedin),

他曾經(jīng)在蘋(píng)果工作,負(fù)責(zé) iOS 的一些內(nèi)置應(yīng)用的開(kāi)發(fā),比如股票、計(jì)算器、地圖、鐘表、設(shè)置、Safari 等,當(dāng)然他也參與了 UIKit framework 的開(kāi)發(fā)。后來(lái)他加入 Facebook 后,負(fù)責(zé) Paper 的開(kāi)發(fā),創(chuàng)建并開(kāi)源了 AsyncDisplayKit。目前他在 Pinterest 和 Instagram 負(fù)責(zé) iOS 開(kāi)發(fā)和用戶體驗(yàn)的提升等工作。

ASDK 自 2014 年 6 月開(kāi)源,10 月發(fā)布 1.0 版。目前 ASDK 即將要發(fā)布 2.0 版。

V2.0 增加了更多布局相關(guān)的代碼,ComponentKit 團(tuán)隊(duì)為此貢獻(xiàn)很多。

現(xiàn)在 Github 的 master 分支上的版本是 V1.9.1,已經(jīng)包含了 V2.0 的全部?jī)?nèi)容。

ASDK 的資料

想要了解 ASDK 的原理和細(xì)節(jié),最好從下面幾個(gè)視頻開(kāi)始:

2014.10.15NSLondon – Scott Goodson – Behind AsyncDisplayKit

2015.03.02MCE 2015 – Scott Goodson – Effortless Responsiveness with AsyncDisplayKit

2015.10.25AsyncDisplayKit 2.0: Intelligent User Interfaces – NSSpain 2015

前兩個(gè)視頻內(nèi)容大同小異,都是介紹 ASDK 的基本原理,附帶介紹 POP 等其他項(xiàng)目。

后一個(gè)視頻增加了 ASDK 2.0 的新特性的介紹。

除此之外,還可以到 Github Issues 里看一下 ASDK 相關(guān)的討論,下面是幾個(gè)比較重要的內(nèi)容:

關(guān)于 Runloop Dispatch

關(guān)于 ComponentKit 和 ASDK 的區(qū)別

為什么不支持 Storyboard 和 Autolayout

如何評(píng)測(cè)界面的流暢度

之后,還可以到 Google Groups 來(lái)查看和討論更多內(nèi)容:

https://groups.google.com/forum/#!forum/asyncdisplaykit

ASDK 的基本原理

ASDK 認(rèn)為,阻塞主線程的任務(wù),主要分為上面這三大類(lèi)。文本和布局的計(jì)算、渲染、解碼、繪制都可以通過(guò)各種方式異步執(zhí)行,但 UIKit 和 Core Animation 相關(guān)操作必需在主線程進(jìn)行。ASDK 的目標(biāo),就是盡量把這些任務(wù)從主線程挪走,而挪不走的,就盡量?jī)?yōu)化性能。

為了達(dá)成這一目標(biāo),ASDK 嘗試對(duì) UIKit 組件進(jìn)行封裝:

這是常見(jiàn)的 UIView 和 CALayer 的關(guān)系:View 持有 Layer 用于顯示,View 中大部分顯示屬性實(shí)際是從 Layer 映射而來(lái);Layer 的 delegate 在這里是 View,當(dāng)其屬性改變、動(dòng)畫(huà)產(chǎn)生時(shí),View 能夠得到通知。UIView 和 CALayer 不是線程安全的,并且只能在主線程創(chuàng)建、訪問(wèn)和銷(xiāo)毀。

ASDK 為此創(chuàng)建了 ASDisplayNode 類(lèi),包裝了常見(jiàn)的視圖屬性(比如 frame/bounds/alpha/transform/backgroundColor/superNode/subNodes 等),然后它用 UIView->CALayer 相同的方式,實(shí)現(xiàn)了 ASNode->UIView 這樣一個(gè)關(guān)系。

當(dāng)不需要響應(yīng)觸摸事件時(shí),ASDisplayNode 可以被設(shè)置為 layer backed,即 ASDisplayNode 充當(dāng)了原來(lái) UIView 的功能,節(jié)省了更多資源。

與 UIView 和 CALayer 不同,ASDisplayNode 是線程安全的,它可以在后臺(tái)線程創(chuàng)建和修改。Node 剛創(chuàng)建時(shí),并不會(huì)在內(nèi)部新建 UIView 和 CALayer,直到第一次在主線程訪問(wèn) view 或 layer 屬性時(shí),它才會(huì)在內(nèi)部生成對(duì)應(yīng)的對(duì)象。當(dāng)它的屬性(比如frame/transform)改變后,它并不會(huì)立刻同步到其持有的 view 或 layer 去,而是把被改變的屬性保存到內(nèi)部的一個(gè)中間變量,稍后在需要時(shí),再通過(guò)某個(gè)機(jī)制一次性設(shè)置到內(nèi)部的 view 或 layer。

通過(guò)模擬和封裝 UIView/CALayer,開(kāi)發(fā)者可以把代碼中的 UIView 替換為 ASNode,很大的降低了開(kāi)發(fā)和學(xué)習(xí)成本,同時(shí)能獲得 ASDK 底層大量的性能優(yōu)化。為了方便使用, ASDK 把大量常用控件都封裝成了 ASNode 的子類(lèi),比如 Button、Control、Cell、Image、ImageView、Text、TableView、CollectionView 等。利用這些控件,開(kāi)發(fā)者可以盡量避免直接使用 UIKit 相關(guān)控件,以獲得更完整的性能提升。

ASDK 的圖層預(yù)合成

有時(shí)一個(gè) layer 會(huì)包含很多 sub-layer,而這些 sub-layer 并不需要響應(yīng)觸摸事件,也不需要進(jìn)行動(dòng)畫(huà)和位置調(diào)整。ASDK 為此實(shí)現(xiàn)了一個(gè)被稱為 pre-composing 的技術(shù),可以把這些 sub-layer 合成渲染為一張圖片。開(kāi)發(fā)時(shí),ASNode 已經(jīng)替代了 UIView 和 CALayer;直接使用各種 Node 控件并設(shè)置為 layer backed 后,ASNode 甚至可以通過(guò)預(yù)合成來(lái)避免創(chuàng)建內(nèi)部的 UIView 和 CALayer。

通過(guò)這種方式,把一個(gè)大的層級(jí),通過(guò)一個(gè)大的繪制方法繪制到一張圖上,性能會(huì)獲得很大提升。CPU 避免了創(chuàng)建 UIKit 對(duì)象的資源消耗,GPU 避免了多張 texture 合成和渲染的消耗,更少的 bitmap 也意味著更少的內(nèi)存占用。

ASDK 異步并發(fā)操作

自 iPhone 4S 起,iDevice 已經(jīng)都是雙核 CPU 了,現(xiàn)在的 iPad 甚至已經(jīng)更新到 3 核了。充分利用多核的優(yōu)勢(shì)、并發(fā)執(zhí)行任務(wù)對(duì)保持界面流暢有很大作用。ASDK 把布局計(jì)算、文本排版、圖片/文本/圖形渲染等操作都封裝成較小的任務(wù),并利用 GCD 異步并發(fā)執(zhí)行。如果開(kāi)發(fā)者使用了 ASNode 相關(guān)的控件,那么這些并發(fā)操作會(huì)自動(dòng)在后臺(tái)進(jìn)行,無(wú)需進(jìn)行過(guò)多配置。

Runloop 任務(wù)分發(fā)

Runloop work distribution 是 ASDK 比較核心的一個(gè)技術(shù),ASDK 的介紹視頻和文檔中都沒(méi)有詳細(xì)展開(kāi)介紹,所以這里我會(huì)多做一些分析。如果你對(duì) Runloop 還不太了解,可以看一下我之前的文章深入理解RunLoop,里面對(duì) ASDK 也有所提及。

iOS 的顯示系統(tǒng)是由 VSync 信號(hào)驅(qū)動(dòng)的,VSync 信號(hào)由硬件時(shí)鐘生成,每秒鐘發(fā)出 60 次(這個(gè)值取決設(shè)備硬件,比如 iPhone 真機(jī)上通常是 59.97)。iOS 圖形服務(wù)接收到 VSync 信號(hào)后,會(huì)通過(guò) IPC 通知到 App 內(nèi)。App 的 Runloop 在啟動(dòng)后會(huì)注冊(cè)對(duì)應(yīng)的 CFRunLoopSource 通過(guò) mach_port 接收傳過(guò)來(lái)的時(shí)鐘信號(hào)通知,隨后 Source 的回調(diào)會(huì)驅(qū)動(dòng)整個(gè) App 的動(dòng)畫(huà)與顯示。

Core Animation 在 RunLoop 中注冊(cè)了一個(gè) Observer,監(jiān)聽(tīng)了 BeforeWaiting 和 Exit 事件。這個(gè) Observer 的優(yōu)先級(jí)是 2000000,低于常見(jiàn)的其他 Observer。當(dāng)一個(gè)觸摸事件到來(lái)時(shí),RunLoop 被喚醒,App 中的代碼會(huì)執(zhí)行一些操作,比如創(chuàng)建和調(diào)整視圖層級(jí)、設(shè)置 UIView 的 frame、修改 CALayer 的透明度、為視圖添加一個(gè)動(dòng)畫(huà);這些操作最終都會(huì)被 CALayer 捕獲,并通過(guò) CATransaction 提交到一個(gè)中間狀態(tài)去(CATransaction 的文檔略有提到這些內(nèi)容,但并不完整)。當(dāng)上面所有操作結(jié)束后,RunLoop 即將進(jìn)入休眠(或者退出)時(shí),關(guān)注該事件的 Observer 都會(huì)得到通知。這時(shí) CA 注冊(cè)的那個(gè) Observer 就會(huì)在回調(diào)中,把所有的中間狀態(tài)合并提交到 GPU 去顯示;如果此處有動(dòng)畫(huà),CA 會(huì)通過(guò) DisplayLink 等機(jī)制多次觸發(fā)相關(guān)流程。

ASDK 在此處模擬了 Core Animation 的這個(gè)機(jī)制:所有針對(duì) ASNode 的修改和提交,總有些任務(wù)是必需放入主線程執(zhí)行的。當(dāng)出現(xiàn)這種任務(wù)時(shí),ASNode 會(huì)把任務(wù)用 ASAsyncTransaction(Group) 封裝并提交到一個(gè)全局的容器去。ASDK 也在 RunLoop 中注冊(cè)了一個(gè) Observer,監(jiān)視的事件和 CA 一樣,但優(yōu)先級(jí)比 CA 要低。當(dāng) RunLoop 進(jìn)入休眠前、CA 處理完事件后,ASDK 就會(huì)執(zhí)行該 loop 內(nèi)提交的所有任務(wù)。具體代碼見(jiàn)這個(gè)文件:ASAsyncTransactionGroup

通過(guò)這種機(jī)制,ASDK 可以在合適的機(jī)會(huì)把異步、并發(fā)的操作同步到主線程去,并且能獲得不錯(cuò)的性能。

其他

ASDK 中還有封裝很多高級(jí)的功能,比如滑動(dòng)列表的預(yù)加載、V2.0添加的新的布局模式等。ASDK 是一個(gè)很龐大的庫(kù),它本身并不推薦你把整個(gè) App 全部都改為 ASDK 驅(qū)動(dòng),把最需要提升交互性能的地方用 ASDK 進(jìn)行優(yōu)化就足夠了。

微博 Demo 性能優(yōu)化技巧

我為了演示 YYKit 的功能,實(shí)現(xiàn)了微博和 Twitter 的 Demo,并為它們做了不少性能優(yōu)化,下面就是優(yōu)化時(shí)用到的一些技巧。

預(yù)排版

當(dāng)獲取到 API JSON 數(shù)據(jù)后,我會(huì)把每條 Cell 需要的數(shù)據(jù)都在后臺(tái)線程計(jì)算并封裝為一個(gè)布局對(duì)象 CellLayout。CellLayout 包含所有文本的 CoreText 排版結(jié)果、Cell 內(nèi)部每個(gè)控件的高度、Cell 的整體高度。每個(gè) CellLayout 的內(nèi)存占用并不多,所以當(dāng)生成后,可以全部緩存到內(nèi)存,以供稍后使用。這樣,TableView 在請(qǐng)求各個(gè)高度函數(shù)時(shí),不會(huì)消耗任何多余計(jì)算量;當(dāng)把 CellLayout 設(shè)置到 Cell 內(nèi)部時(shí),Cell 內(nèi)部也不用再計(jì)算布局了。

對(duì)于通常的 TableView 來(lái)說(shuō),提前在后臺(tái)計(jì)算好布局結(jié)果是非常重要的一個(gè)性能優(yōu)化點(diǎn)。為了達(dá)到最高性能,你可能需要犧牲一些開(kāi)發(fā)速度,不要用 Autolayout 等技術(shù),少用 UILabel 等文本控件。但如果你對(duì)性能的要求并不那么高,可以嘗試用 TableView 的預(yù)估高度的功能,并把每個(gè) Cell 高度緩存下來(lái)。這里有個(gè)來(lái)自百度知道團(tuán)隊(duì)的開(kāi)源項(xiàng)目可以很方便的幫你實(shí)現(xiàn)這一點(diǎn):FDTemplateLayoutCell

預(yù)渲染

微博的頭像在某次改版中換成了圓形,所以我也跟進(jìn)了一下。當(dāng)頭像下載下來(lái)后,我會(huì)在后臺(tái)線程將頭像預(yù)先渲染為圓形并單獨(dú)保存到一個(gè) ImageCache 中去。

對(duì)于 TableView 來(lái)說(shuō),Cell 內(nèi)容的離屏渲染會(huì)帶來(lái)較大的 GPU 消耗。在 Twitter Demo 中,我為了圖省事兒用到了不少 layer 的圓角屬性,你可以在低性能的設(shè)備(比如 iPad 3)上快速滑動(dòng)一下這個(gè)列表,能感受到雖然列表并沒(méi)有較大的卡頓,但是整體的平均幀數(shù)降了下來(lái)。用 Instument 查看時(shí)能夠看到 GPU 已經(jīng)滿負(fù)荷運(yùn)轉(zhuǎn),而 CPU 卻比較清閑。為了避免離屏渲染,你應(yīng)當(dāng)盡量避免使用 layer 的 border、corner、shadow、mask 等技術(shù),而盡量在后臺(tái)線程預(yù)先繪制好對(duì)應(yīng)內(nèi)容。

異步繪制

我只在顯示文本的控件上用到了異步繪制的功能,但效果很不錯(cuò)。我參考 ASDK 的原理,實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的異步繪制控件。這塊代碼我單獨(dú)提取出來(lái),放到了這里:YYAsyncLayer。YYAsyncLayer 是 CALayer 的子類(lèi),當(dāng)它需要顯示內(nèi)容(比如調(diào)用了 [layer setNeedDisplay])時(shí),它會(huì)向 delegate,也就是 UIView 請(qǐng)求一個(gè)異步繪制的任務(wù)。在異步繪制時(shí),Layer 會(huì)傳遞一個(gè)BOOL(^isCancelled)()這樣的 block,繪制代碼可以隨時(shí)調(diào)用該 block 判斷繪制任務(wù)是否已經(jīng)被取消。

當(dāng) TableView 快速滑動(dòng)時(shí),會(huì)有大量異步繪制任務(wù)提交到后臺(tái)線程去執(zhí)行。但是有時(shí)滑動(dòng)速度過(guò)快時(shí),繪制任務(wù)還沒(méi)有完成就可能已經(jīng)被取消了。如果這時(shí)仍然繼續(xù)繪制,就會(huì)造成大量的 CPU 資源浪費(fèi),甚至阻塞線程并造成后續(xù)的繪制任務(wù)遲遲無(wú)法完成。我的做法是盡量快速、提前判斷當(dāng)前繪制任務(wù)是否已經(jīng)被取消;在繪制每一行文本前,我都會(huì)調(diào)用 isCancelled() 來(lái)進(jìn)行判斷,保證被取消的任務(wù)能及時(shí)退出,不至于影響后續(xù)操作。

目前有些第三方微博客戶端(比如 VVebo、墨客等),使用了一種方式來(lái)避免高速滑動(dòng)時(shí) Cell 的繪制過(guò)程,相關(guān)實(shí)現(xiàn)見(jiàn)這個(gè)項(xiàng)目:VVeboTableViewDemo。它的原理是,當(dāng)滑動(dòng)時(shí),松開(kāi)手指后,立刻計(jì)算出滑動(dòng)停止時(shí) Cell 的位置,并預(yù)先繪制那個(gè)位置附近的幾個(gè) Cell,而忽略當(dāng)前滑動(dòng)中的 Cell。這個(gè)方法比較有技巧性,并且對(duì)于滑動(dòng)性能來(lái)說(shuō)提升也很大,唯一的缺點(diǎn)就是快速滑動(dòng)中會(huì)出現(xiàn)大量空白內(nèi)容。如果你不想實(shí)現(xiàn)比較麻煩的異步繪制但又想保證滑動(dòng)的流暢性,這個(gè)技巧是個(gè)不錯(cuò)的選擇。

全局并發(fā)控制

當(dāng)我用 concurrent queue 來(lái)執(zhí)行大量繪制任務(wù)時(shí),偶爾會(huì)遇到這種問(wèn)題:

大量的任務(wù)提交到后臺(tái)隊(duì)列時(shí),某些任務(wù)會(huì)因?yàn)槟承┰颍ù颂幨?CGFont 鎖)被鎖住導(dǎo)致線程休眠,或者被阻塞,concurrent queue 隨后會(huì)創(chuàng)建新的線程來(lái)執(zhí)行其他任務(wù)。當(dāng)這種情況變多時(shí),或者 App 中使用了大量 concurrent queue 來(lái)執(zhí)行較多任務(wù)時(shí),App 在同一時(shí)刻就會(huì)存在幾十個(gè)線程同時(shí)運(yùn)行、創(chuàng)建、銷(xiāo)毀。CPU 是用時(shí)間片輪轉(zhuǎn)來(lái)實(shí)現(xiàn)線程并發(fā)的,盡管 concurrent queue 能控制線程的優(yōu)先級(jí),但當(dāng)大量線程同時(shí)創(chuàng)建運(yùn)行銷(xiāo)毀時(shí),這些操作仍然會(huì)擠占掉主線程的 CPU 資源。ASDK 有個(gè) Feed 列表的 Demo:SocialAppLayout,當(dāng)列表內(nèi) Cell 過(guò)多,并且非??焖俚幕瑒?dòng)時(shí),界面仍然會(huì)出現(xiàn)少量卡頓,我謹(jǐn)慎的猜測(cè)可能與這個(gè)問(wèn)題有關(guān)。

使用 concurrent queue 時(shí)不可避免會(huì)遇到這種問(wèn)題,但使用 serial queue 又不能充分利用多核 CPU 的資源。我寫(xiě)了一個(gè)簡(jiǎn)單的工具YYDispatchQueuePool,為不同優(yōu)先級(jí)創(chuàng)建和 CPU 數(shù)量相同的 serial queue,每次從 pool 中獲取 queue 時(shí),會(huì)輪詢返回其中一個(gè) queue。我把 App 內(nèi)所有異步操作,包括圖像解碼、對(duì)象釋放、異步繪制等,都按優(yōu)先級(jí)不同放入了全局的 serial queue 中執(zhí)行,這樣盡量避免了過(guò)多線程導(dǎo)致的性能問(wèn)題。

更高效的異步圖片加載

SDWebImage 在這個(gè) Demo 里仍然會(huì)產(chǎn)生少量性能問(wèn)題,并且有些地方不能滿足我的需求,所以我自己實(shí)現(xiàn)了一個(gè)性能更高的圖片加載庫(kù)。在顯示簡(jiǎn)單的單張圖片時(shí),利用 UIView.layer.contents 就足夠了,沒(méi)必要使用 UIImageView 帶來(lái)額外的資源消耗,為此我在 CALayer 上添加了 setImageWithURL 等方法。除此之外,我還把圖片解碼等操作通過(guò) YYDispatchQueuePool 進(jìn)行管理,控制了 App 總線程數(shù)量。

其他可以改進(jìn)的地方

上面這些優(yōu)化做完后,微博 Demo 已經(jīng)非常流暢了,但在我的設(shè)想中,仍然有一些進(jìn)一步優(yōu)化的技巧,但限于時(shí)間和精力我并沒(méi)有實(shí)現(xiàn),下面簡(jiǎn)單列一下:

列表中有不少視覺(jué)元素并不需要觸摸事件,這些元素可以用 ASDK 的圖層合成技術(shù)預(yù)先繪制為一張圖。

再進(jìn)一步減少每個(gè) Cell 內(nèi)圖層的數(shù)量,用 CALayer 替換掉 UIView。

目前每個(gè) Cell 的類(lèi)型都是相同的,但顯示的內(nèi)容卻各部一樣,比如有的 Cell 有圖片,有的 Cell 里是卡片。把 Cell 按類(lèi)型劃分,進(jìn)一步減少 Cell 內(nèi)不必要的視圖對(duì)象和操作,應(yīng)該能有一些效果。

把需要放到主線程執(zhí)行的任務(wù)劃分為足夠小的塊,并通過(guò) Runloop 來(lái)進(jìn)行調(diào)度,在每個(gè) Loop 里判斷下一次 VSync 的時(shí)間,并在下次 VSync 到來(lái)前,把當(dāng)前未執(zhí)行完的任務(wù)延遲到下一個(gè)機(jī)會(huì)去。這個(gè)只是我的一個(gè)設(shè)想,并不一定能實(shí)現(xiàn)或起作用。

如何評(píng)測(cè)界面的流暢度

最后還是要提一下,“過(guò)早的優(yōu)化是萬(wàn)惡之源”,在需求未定,性能問(wèn)題不明顯時(shí),沒(méi)必要嘗試做優(yōu)化,而要盡量正確的實(shí)現(xiàn)功能。做性能優(yōu)化時(shí),也最好是走修改代碼 -> Profile -> 修改代碼這樣一個(gè)流程,優(yōu)先解決最值得優(yōu)化的地方。

如果你需要一個(gè)明確的 FPS 指示器,可以嘗試一下KMCGeigerCounter。對(duì)于 CPU 的卡頓,它可以通過(guò)內(nèi)置的 CADisplayLink 檢測(cè)出來(lái);對(duì)于 GPU 帶來(lái)的卡頓,它用了一個(gè) 1×1 的 SKView 來(lái)進(jìn)行監(jiān)視。這個(gè)項(xiàng)目有兩個(gè)小問(wèn)題:SKView 雖然能監(jiān)視到 GPU 的卡頓,但引入 SKView 本身就會(huì)對(duì) CPU/GPU 帶來(lái)額外的一點(diǎn)的資源消耗;這個(gè)項(xiàng)目在 iOS 9 下有一些兼容問(wèn)題,需要稍作調(diào)整。

我自己也寫(xiě)了個(gè)簡(jiǎn)單的 FPS 指示器:FPSLabel只有幾十行代碼,僅用到了 CADisplayLink 來(lái)監(jiān)視 CPU 的卡頓問(wèn)題。雖然不如上面這個(gè)工具完善,但日常使用沒(méi)有太大問(wèn)題。

最后,用 Instuments 的 GPU Driver 預(yù)設(shè),能夠?qū)崟r(shí)查看到 CPU 和 GPU 的資源消耗。在這個(gè)預(yù)設(shè)內(nèi),你能查看到幾乎所有與顯示有關(guān)的數(shù)據(jù),比如 Texture 數(shù)量、CA 提交的頻率、GPU 消耗等,在定位界面卡頓的問(wèn)題時(shí),這是最好的工具。

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

相關(guān)閱讀更多精彩內(nèi)容

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