卡頓產(chǎn)生的原因和解決方案
CPU 和 GPU 不論哪個(gè)阻礙了顯示流程,都會造成掉幀現(xiàn)象嫂易。所以開發(fā)時(shí),也需要分別對 CPU 和 GPU 壓力進(jìn)行評估和優(yōu)化
CPU 資源消耗原因和解決方案
對象創(chuàng)建
對象的創(chuàng)建會分配內(nèi)存网杆、調(diào)整屬性吠撮、甚至還有讀取文件等操作,比較消耗 CPU 資源柬姚。盡量用輕量的對象代替重量的對象拟杉,可以對性能有所優(yōu)化。比如 CALayer 比 UIView 要輕量許多量承,那么不需要響應(yīng)觸摸事件的控件搬设,用 CALayer 顯示會更加合適。如果對象不涉及 UI 操作撕捍,則盡量放到后臺線程去創(chuàng)建拿穴,但可惜的是包含有 CALayer 的控件,都只能在主線程創(chuàng)建和操作忧风。通過 Storyboard 創(chuàng)建視圖對象時(shí)默色,其資源消耗會比直接通過代碼創(chuàng)建對象要大非常多,在性能敏感的界面里狮腿,Storyboard 并不是一個(gè)好的技術(shù)選擇腿宰。
盡量推遲對象創(chuàng)建的時(shí)間,并把對象的創(chuàng)建分散到多個(gè)任務(wù)中去缘厢。盡管這實(shí)現(xiàn)起來比較麻煩吃度,并且?guī)淼膬?yōu)勢并不多,但如果有能力做贴硫,還是要盡量嘗試一下椿每。如果對象可以復(fù)用,并且復(fù)用的代價(jià)比釋放夜畴、創(chuàng)建新對象要小拖刃,那么這類對象應(yīng)當(dāng)盡量放到一個(gè)緩存池里復(fù)用。
對象調(diào)整
對象的調(diào)整也經(jīng)常是消耗 CPU 資源的地方贪绘。這里特別說一下 CALayer:CALayer 內(nèi)部并沒有屬性,當(dāng)調(diào)用屬性方法時(shí)央碟,它內(nèi)部是通過運(yùn)行時(shí) resolveInstanceMethod 為對象臨時(shí)添加一個(gè)方法税灌,并把對應(yīng)屬性值保存到內(nèi)部的一個(gè) Dictionary 里均函,同時(shí)還會通知 delegate、創(chuàng)建動畫等等菱涤,非常消耗資源苞也。UIView 的關(guān)于顯示相關(guān)的屬性(比如 frame/bounds/transform)等實(shí)際上都是 CALayer 屬性映射來的,所以對 UIView 的這些屬性進(jìn)行調(diào)整時(shí)粘秆,消耗的資源要遠(yuǎn)大于一般的屬性如迟。對此你在應(yīng)用中,應(yīng)該盡量減少不必要的屬性修改攻走。
當(dāng)視圖層次調(diào)整時(shí)殷勘,UIView、CALayer 之間會出現(xiàn)很多方法調(diào)用與通知昔搂,所以在優(yōu)化性能時(shí)玲销,應(yīng)該盡量避免調(diào)整視圖層次、添加和移除視圖摘符。
對象銷毀
對象的銷毀雖然消耗資源不多贤斜,但累積起來也是不容忽視的。通常當(dāng)容器類持有大量對象時(shí)逛裤,其銷毀時(shí)的資源消耗就非常明顯瘩绒。 同樣的,如果對象可以放到后臺線程去釋放带族,那就挪到后臺線程去锁荔。這里有個(gè)小 Tip:把對象捕獲到 block 中,然后扔到后臺隊(duì)列去隨便發(fā)送個(gè)消息以避免編譯器警告炉菲,就可以讓對象在后臺線程銷毀了堕战。
布局計(jì)算
視圖布局的計(jì)算是 App 中最為常見的消耗 CPU 資源的地方。如果能在后臺線程提前計(jì)算好視圖布局拍霜、并且對視圖布局進(jìn)行緩存嘱丢,那么這個(gè)地方基本就不會產(chǎn)生性能問題了。
不論通過何種技術(shù)對視圖進(jìn)行布局祠饺,其最終都會落到對 UIView.frame/bounds/center 等屬性的調(diào)整上越驻。上面也說過,對這些屬性的調(diào)整非常消耗資源道偷,所以盡量提前計(jì)算好布局缀旁,在需要時(shí)一次性調(diào)整好對應(yīng)屬性,而不要多次勺鸦、頻繁的計(jì)算和調(diào)整這些屬性并巍。
Autolayout
Autolayout 是蘋果本身提倡的技術(shù),在大部分情況下也能很好的提升開發(fā)效率换途,但是 Autolayout 對于復(fù)雜視圖來說常常會產(chǎn)生嚴(yán)重的性能問題懊渡。隨著視圖數(shù)量的增長刽射,Autolayout 帶來的 CPU 消耗會呈指數(shù)級上升。具體數(shù)據(jù)可以看這個(gè)文章:http://pilky.me/36/剃执。 如果你不想手動調(diào)整 frame 等屬性誓禁,你可以用一些工具方法替代(比如常見的 left/right/top/bottom/width/height 快捷屬性),或者使用 ComponentKit肾档、AsyncDisplayKit 等框架摹恰。
文本計(jì)算
如果一個(gè)界面中包含大量文本(比如微博微信朋友圈等),文本的寬高計(jì)算會占用很大一部分資源怒见,并且不可避免俗慈。如果你對文 本顯示沒有特殊要求,可以參考下 UILabel 內(nèi)部的實(shí)現(xiàn)方式:用 [NSAttributedString boundingRectWithSize:options:context:] 來計(jì)算文本寬高速种,用 -[NSAttributedString drawWithRect:options:context:] 來繪制文本姜盈。盡管這兩個(gè)方法性能不錯(cuò),但仍舊需要放到后臺線程進(jìn)行以避免阻塞主線程配阵。
如果你用 CoreText 繪制文本馏颂,那就可以先生成 CoreText 排版對象,然后自己計(jì)算了棋傍,并且 CoreText 對象還能保留以供稍后繪制使用救拉。
文本渲染
屏幕上能看到的所有文本內(nèi)容控件,包括 UIWebView瘫拣,在底層都是通過 CoreText 排版亿絮、繪制為 Bitmap 顯示的。常見的文本控件 (UILabel麸拄、UITextView 等)派昧,其排版和繪制都是在主線程進(jìn)行的,當(dāng)顯示大量文本時(shí)拢切,CPU 的壓力會非常大蒂萎。對此解決方案只有一個(gè),那就是自定義文本控件淮椰,用 TextKit 或最底層的 CoreText 對文本異步繪制五慈。盡管這實(shí)現(xiàn)起來非常麻煩,但其帶來的優(yōu)勢也非常大主穗,CoreText 對象創(chuàng)建好后泻拦,能直接獲取文本的寬高等信息,避免了多次計(jì)算(調(diào)整 UILabel 大小時(shí)算一遍忽媒、UILabel 繪制時(shí)內(nèi)部再算一遍)争拐;CoreText 對象占用內(nèi)存較少,可以緩存下來以備稍后多次渲染晦雨。
圖片的解碼
當(dāng)你用 UIImage 或 CGImageSource 的那幾個(gè)方法創(chuàng)建圖片時(shí)陆错,圖片數(shù)據(jù)并不會立刻解碼灯抛。圖片設(shè)置到 UIImageView 或者 CALayer.contents 中去金赦,并且 CALayer 被提交到 GPU 前音瓷,CGImage 中的數(shù)據(jù)才會得到解碼。這一步是發(fā)生在主線程的夹抗,并且不可避免绳慎。如果想要繞開這個(gè)機(jī)制,常見的做法是在后臺線程先把圖片繪制到 CGBitmapContext 中漠烧,然后從 Bitmap 直接創(chuàng)建圖片杏愤。目前常見的網(wǎng)絡(luò)圖片庫都自帶這個(gè)功能。
圖像的繪制
圖像的繪制通常是指用那些以 CG 開頭的方法把圖像繪制到畫布中已脓,然后從畫布創(chuàng)建圖片并顯示這樣一個(gè)過程珊楼。這個(gè)最常見的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是線程安全的度液,所以圖像的繪制可以很容易的放到后臺線程進(jìn)行厕宗。一個(gè)簡單異步繪制的過程大致如下(實(shí)際情況會比這個(gè)復(fù)雜得多,但原理基本一致):
- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}
GPU 資源消耗原因和解決方案
相對于 CPU 來說堕担,GPU 能干的事情比較單一:接收提交的紋理(Texture)和頂點(diǎn)描述(三角形)已慢,應(yīng)用變換(transform)、混合并渲染霹购,然后輸出到屏幕上佑惠。通常你所能看到的內(nèi)容,主要也就是紋理(圖片)和形狀(三角模擬的矢量圖形)兩類齐疙。
紋理的渲染
所有的 Bitmap膜楷,包括圖片、文本贞奋、柵格化的內(nèi)容赌厅,最終都要由內(nèi)存提交到顯存,綁定為 GPU Texture忆矛。不論是提交到顯存的過程察蹲,還是 GPU 調(diào)整和渲染 Texture 的過程,都要消耗不少 GPU 資源催训。當(dāng)在較短時(shí)間顯示大量圖片時(shí)(比如 TableView 存在非常多的圖片并且快速滑動時(shí))洽议,CPU 占用率很低,GPU 占用非常高漫拭,界面仍然會掉幀亚兄。避免這種情況的方法只能是盡量減少在短時(shí)間內(nèi)大量圖片的顯示,盡可能將多張圖片合成為一張進(jìn)行顯示采驻。
當(dāng)圖片過大审胚,超過 GPU 的最大紋理尺寸時(shí)匈勋,圖片需要先由 CPU 進(jìn)行預(yù)處理,這對 CPU 和 GPU 都會帶來額外的資源消耗膳叨。目前來說洽洁,iPhone 4S 以上機(jī)型,紋理尺寸上限都是 4096x4096菲嘴,更詳細(xì)的資料可以看這里:iosres.com饿自。所以,盡量不要讓圖片和視圖的大小超過這個(gè)值龄坪。
視圖的混合 (Composing)
當(dāng)多個(gè)視圖(或者說 CALayer)重疊在一起顯示時(shí)昭雌,GPU 會首先把他們混合到一起。如果視圖結(jié)構(gòu)過于復(fù)雜健田,混合的過程也會消耗很多 GPU 資源烛卧。為了減輕這種情況的 GPU 消耗,應(yīng)用應(yīng)當(dāng)盡量減少視圖數(shù)量和層次妓局,并在不透明的視圖里標(biāo)明 opaque 屬性以避免無用的 Alpha 通道合成总放。當(dāng)然,這也可以用上面的方法跟磨,把多個(gè)視圖預(yù)先渲染為一張圖片來顯示间聊。
圖形的生成。
CALayer 的 border抵拘、圓角哎榴、陰影、遮罩(mask)僵蛛,CASharpLayer 的矢量圖形顯示尚蝌,通常會觸發(fā)離屏渲染(offscreen rendering),而離屏渲染通常發(fā)生在 GPU 中充尉。當(dāng)一個(gè)列表視圖中出現(xiàn)大量圓角的 CALayer飘言,并且快速滑動時(shí),可以觀察到 GPU 資源已經(jīng)占滿驼侠,而 CPU 資源消耗很少姿鸿。這時(shí)界面仍然能正常滑動倒源,但平均幀數(shù)會降到很低苛预。為了避免這種情況,可以嘗試開啟 CALayer.shouldRasterize 屬性笋熬,但這會把原本離屏渲染的操作轉(zhuǎn)嫁到 CPU 上去热某。對于只需要圓角的某些場合,也可以用一張已經(jīng)繪制好的圓角圖片覆蓋到原本視圖上面來模擬相同的視覺效果。最徹底的解決辦法昔馋,就是把需要顯示的圖形在 后臺線程繪制為圖片筹吐,避免使用圓角、陰影秘遏、遮罩等屬性丘薛。
AsyncDisplayKit
ASDK 中還有封裝很多高級的功能,比如滑動列表的預(yù)加載垄提、V2.0添加的新的布局模式等榔袋。ASDK 是一個(gè)很龐大的庫,它本身并不推薦你把整個(gè) App 全部都改為 ASDK 驅(qū)動铡俐,把最需要提升交互性能的地方用 ASDK 進(jìn)行優(yōu)化就足夠了。
預(yù)排版
當(dāng)獲取到 API JSON 數(shù)據(jù)后妥粟,我會把每條 Cell 需要的數(shù)據(jù)都在后臺線程計(jì)算并封裝為一個(gè)布局對象 CellLayout审丘。CellLayout 包含所有文本的 CoreText 排版結(jié)果、Cell 內(nèi)部每個(gè)控件的高度勾给、Cell 的整體高度滩报。每個(gè) CellLayout 的內(nèi)存占用并不多,所以當(dāng)生成后播急,可以全部緩存到內(nèi)存脓钾,以供稍后使用。這樣桩警,TableView 在請求各個(gè)高度函數(shù)時(shí)可训,不會消耗任何多余計(jì)算量;當(dāng)把 CellLayout 設(shè)置到 Cell 內(nèi)部時(shí)捶枢,Cell 內(nèi)部也不用再計(jì)算布局了握截。
對于通常的 TableView 來說,提前在后臺計(jì)算好布局結(jié)果是非常重要的一個(gè)性能優(yōu)化點(diǎn)烂叔。為了達(dá)到最高性能谨胞,你可能需要犧牲一些開發(fā)速度,不要用 Autolayout 等技術(shù)蒜鸡,少用 UILabel 等文本控件胯努。但如果你對性能的要求并不那么高,可以嘗試用 TableView 的預(yù)估高度的功能逢防,并把每個(gè) Cell 高度緩存下來叶沛。這里有個(gè)來自百度知道團(tuán)隊(duì)的開源項(xiàng)目可以很方便的幫你實(shí)現(xiàn)這一點(diǎn):FDTemplateLayoutCell。
預(yù)渲染
微博的頭像在某次改版中換成了圓形胞四,所以我也跟進(jìn)了一下恬汁。當(dāng)頭像下載下來后,我會在后臺線程將頭像預(yù)先渲染為圓形并單獨(dú)保存到一個(gè) ImageCache 中去。
對于 TableView 來說氓侧,Cell 內(nèi)容的離屏渲染會帶來較大的 GPU 消耗脊另。在 Twitter Demo 中,我為了圖省事兒用到了不少 layer 的圓角屬性约巷,你可以在低性能的設(shè)備(比如 iPad 3)上快速滑動一下這個(gè)列表偎痛,能感受到雖然列表并沒有較大的卡頓,但是整體的平均幀數(shù)降了下來独郎。用 Instument 查看時(shí)能夠看到 GPU 已經(jīng)滿負(fù)荷運(yùn)轉(zhuǎn)踩麦,而 CPU 卻比較清閑。為了避免離屏渲染氓癌,你應(yīng)當(dāng)盡量避免使用 layer 的 border谓谦、corner、shadow贪婉、mask 等技術(shù)反粥,而盡量在后臺線程預(yù)先繪制好對應(yīng)內(nèi)容。
異步繪制
我只在顯示文本的控件上用到了異步繪制的功能疲迂,但效果很不錯(cuò)才顿。我參考 ASDK 的原理,實(shí)現(xiàn)了一個(gè)簡單的異步繪制控件尤蒿。這塊代碼我單獨(dú)提取出來郑气,放到了這里:YYAsyncLayer。YYAsyncLayer 是 CALayer 的子類腰池,當(dāng)它需要顯示內(nèi)容(比如調(diào)用了 [layer setNeedDisplay])時(shí)尾组,它會向 delegate,也就是 UIView 請求一個(gè)異步繪制的任務(wù)巩螃。在異步繪制時(shí)演怎,Layer 會傳遞一個(gè) BOOL(^isCancelled)() 這樣的 block,繪制代碼可以隨時(shí)調(diào)用該 block 判斷繪制任務(wù)是否已經(jīng)被取消避乏。
當(dāng) TableView 快速滑動時(shí)爷耀,會有大量異步繪制任務(wù)提交到后臺線程去執(zhí)行。但是有時(shí)滑動速度過快時(shí)拍皮,繪制任務(wù)還沒有完成就可能已經(jīng)被取消了歹叮。如果這時(shí)仍然繼續(xù)繪制,就會造 成大量的 CPU 資源浪費(fèi)铆帽,甚至阻塞線程并造成后續(xù)的繪制任務(wù)遲遲無法完成咆耿。我的做法是盡量快速、提前判斷當(dāng)前繪制任務(wù)是否已經(jīng)被取消爹橱;在繪制每一行文本前萨螺,我都會調(diào)用 isCancelled() 來進(jìn)行判斷,保證被取消的任務(wù)能及時(shí)退出,不至于影響后續(xù)操作慰技。
目前有些第三方微博客戶端(比如 VVebo椭盏、墨客等),使用了一種方式來避免高速滑動時(shí) Cell 的繪制過程吻商,相關(guān)實(shí)現(xiàn)見這個(gè)項(xiàng)目:VVeboTableViewDemo掏颊。 它的原理是,當(dāng)滑動時(shí)艾帐,松開手指后乌叶,立刻計(jì)算出滑動停止時(shí) Cell 的位置,并預(yù)先繪制那個(gè)位置附近的幾個(gè) Cell柒爸,而忽略當(dāng)前滑動中的 Cell准浴。這個(gè)方法比較有技巧性,并且對于滑動性能來說提升也很大揍鸟,唯一的缺點(diǎn)就是快速滑動中會出現(xiàn)大量空白內(nèi)容兄裂。如果你不想實(shí)現(xiàn)比較麻煩的異步繪制但又 想保證滑動的流暢性,這個(gè)技巧是個(gè)不錯(cuò)的選擇阳藻。
全局并發(fā)控制
大量的任務(wù)提交到后臺隊(duì)列時(shí),某些任務(wù)會因?yàn)槟承┰颍ù颂幨?CGFont 鎖)被鎖住導(dǎo)致線程休眠谈撒,或者被阻塞腥泥,concurrent queue 隨后會創(chuàng)建新的線程來執(zhí)行其他任務(wù)。當(dāng)這種情況變多時(shí)啃匿,或者 App 中使用了大量 concurrent queue 來執(zhí)行較多任務(wù)時(shí)蛔外,App 在同一時(shí)刻就會存在幾十個(gè)線程同時(shí)運(yùn)行、創(chuàng)建溯乒、銷毀夹厌。CPU 是用時(shí)間片輪轉(zhuǎn)來實(shí)現(xiàn)線程并發(fā)的,盡管 concurrent queue 能控制線程的優(yōu)先級裆悄,但當(dāng)大量線程同時(shí)創(chuàng)建運(yùn)行銷毀時(shí)矛纹,這些操作仍然會擠占掉主線程的 CPU 資源。ASDK 有個(gè) Feed 列表的 Demo:SocialAppLayout光稼,當(dāng)列表內(nèi) Cell 過多或南,并且非常快速的滑動時(shí)艾君,界面仍然會出現(xiàn)少量卡頓采够,我謹(jǐn)慎的猜測可能與這個(gè)問題有關(guān)。
使用 concurrent queue 時(shí)不可避免會遇到這種問題冰垄,但使用 serial queue 又不能充分利用多核 CPU 的資源蹬癌。我寫了一個(gè)簡單的工具 YYDispatchQueuePool, 為不同優(yōu)先級創(chuàng)建和 CPU 數(shù)量相同的 serial queue,每次從 pool 中獲取 queue 時(shí)逝薪,會輪詢返回其中一個(gè) queue隅要。我把 App 內(nèi)所有異步操作,包括圖像解碼翼闽、對象釋放拾徙、異步繪制等,都按優(yōu)先級不同放入了全局的 serial queue 中執(zhí)行感局,這樣盡量避免了過多線程導(dǎo)致的性能問題尼啡。
更高效的異步圖片加載
SDWebImage 在這個(gè) Demo 里仍然會產(chǎn)生少量性能問題,并且有些地方不能滿足我的需求询微,所以我自己實(shí)現(xiàn)了一個(gè)性能更高的圖片加載庫崖瞭。在顯示簡單的單張圖片時(shí),利用 UIView.layer.contents 就足夠了撑毛,沒必要使用 UIImageView 帶來額外的資源消耗书聚,為此我在 CALayer 上添加了 setImageWithURL 等方法。除此之外藻雌,我還把圖片解碼等操作通過 YYDispatchQueuePool 進(jìn)行管理雌续,控制了 App 總線程數(shù)量。
其他可以改進(jìn)的地方
列表中有不少視覺元素并不需要觸摸事件胯杭,這些元素可以用 ASDK 的圖層合成技術(shù)預(yù)先繪制為一張圖驯杜。
再進(jìn)一步減少每個(gè) Cell 內(nèi)圖層的數(shù)量,用 CALayer 替換掉 UIView做个。
目前每個(gè) Cell 的類型都是相同的鸽心,但顯示的內(nèi)容卻各部一樣,比如有的 Cell 有圖片居暖,有的 Cell 里是卡片顽频。把 Cell 按類型劃分,進(jìn)一步減少 Cell 內(nèi)不必要的視圖對象和操作太闺,應(yīng)該能有一些效果糯景。
把需要放到主線程執(zhí)行的任務(wù)劃分為足夠小的塊,并通過 Runloop 來進(jìn)行調(diào)度跟束,在每個(gè) Loop 里判斷下一次 VSync 的時(shí)間莺奸,并在下次 VSync 到來前,把當(dāng)前未執(zhí)行完的任務(wù)延遲到下一個(gè)機(jī)會去冀宴。這個(gè)只是我的一個(gè)設(shè)想灭贷,并不一定能實(shí)現(xiàn)或起作用。
如何評測界面的流暢度
最后還是要提一下略贮,“過早的優(yōu)化是萬惡之源”甚疟,在需求未定仗岖,性能問題不明顯時(shí),沒必要嘗試做優(yōu)化览妖,而要盡量正確的實(shí)現(xiàn)功能轧拄。做性能優(yōu)化時(shí),也最好是走修改代碼 -> Profile -> 修改代碼這樣一個(gè)流程讽膏,優(yōu)先解決最值得優(yōu)化的地方檩电。
最后,用 Instuments 的 GPU Driver 預(yù)設(shè)府树,能夠?qū)崟r(shí)查看到 CPU 和 GPU 的資源消耗俐末。在這個(gè)預(yù)設(shè)內(nèi),你能查看到幾乎所有與顯示有關(guān)的數(shù)據(jù)奄侠,比如 Texture 數(shù)量卓箫、CA 提交的頻率、GPU 消耗等垄潮,在定位界面卡頓的問題時(shí)烹卒,這是最好的工具。