卡頓產生的原因和解決方案
CPU 和 GPU 不論哪個阻礙了顯示流程,都會造成掉幀現象。所以開發時,也需要分別對 CPU 和 GPU 壓力進行評估和優化
CPU 資源消耗原因和解決方案
對象創建
對象的創建會分配內存、調整屬性、甚至還有讀取文件等操作,比較消耗 CPU 資源。盡量用輕量的對象代替重量的對象,可以對性能有所優化。比如 CALayer 比 UIView 要輕量許多,那么不需要響應觸摸事件的控件,用 CALayer 顯示會更加合適。如果對象不涉及 UI 操作,則盡量放到后臺線程去創建,但可惜的是包含有 CALayer 的控件,都只能在主線程創建和操作。通過 Storyboard 創建視圖對象時,其資源消耗會比直接通過代碼創建對象要大非常多,在性能敏感的界面里,Storyboard 并不是一個好的技術選擇。
盡量推遲對象創建的時間,并把對象的創建分散到多個任務中去。盡管這實現起來比較麻煩,并且帶來的優勢并不多,但如果有能力做,還是要盡量嘗試一下。如果對象可以復用,并且復用的代價比釋放、創建新對象要小,那么這類對象應當盡量放到一個緩存池里復用。
對象調整
對象的調整也經常是消耗 CPU 資源的地方。這里特別說一下 CALayer:CALayer 內部并沒有屬性,當調用屬性方法時,它內部是通過運行時 resolveInstanceMethod 為對象臨時添加一個方法,并把對應屬性值保存到內部的一個 Dictionary 里,同時還會通知 delegate、創建動畫等等,非常消耗資源。UIView 的關于顯示相關的屬性(比如 frame/bounds/transform)等實際上都是 CALayer 屬性映射來的,所以對 UIView 的這些屬性進行調整時,消耗的資源要遠大于一般的屬性。對此你在應用中,應該盡量減少不必要的屬性修改。
當視圖層次調整時,UIView、CALayer 之間會出現很多方法調用與通知,所以在優化性能時,應該盡量避免調整視圖層次、添加和移除視圖。
對象銷毀
對象的銷毀雖然消耗資源不多,但累積起來也是不容忽視的。通常當容器類持有大量對象時,其銷毀時的資源消耗就非常明顯。 同樣的,如果對象可以放到后臺線程去釋放,那就挪到后臺線程去。這里有個小 Tip:把對象捕獲到 block 中,然后扔到后臺隊列去隨便發送個消息以避免編譯器警告,就可以讓對象在后臺線程銷毀了。
布局計算
視圖布局的計算是 App 中最為常見的消耗 CPU 資源的地方。如果能在后臺線程提前計算好視圖布局、并且對視圖布局進行緩存,那么這個地方基本就不會產生性能問題了。
不論通過何種技術對視圖進行布局,其最終都會落到對 UIView.frame/bounds/center 等屬性的調整上。上面也說過,對這些屬性的調整非常消耗資源,所以盡量提前計算好布局,在需要時一次性調整好對應屬性,而不要多次、頻繁的計算和調整這些屬性。
Autolayout
Autolayout 是蘋果本身提倡的技術,在大部分情況下也能很好的提升開發效率,但是 Autolayout 對于復雜視圖來說常常會產生嚴重的性能問題。隨著視圖數量的增長,Autolayout 帶來的 CPU 消耗會呈指數級上升。具體數據可以看這個文章:http://pilky.me/36/。 如果你不想手動調整 frame 等屬性,你可以用一些工具方法替代(比如常見的 left/right/top/bottom/width/height 快捷屬性),或者使用 ComponentKit、AsyncDisplayKit 等框架。
文本計算
如果一個界面中包含大量文本(比如微博微信朋友圈等),文本的寬高計算會占用很大一部分資源,并且不可避免。如果你對文 本顯示沒有特殊要求,可以參考下 UILabel 內部的實現方式:用 [NSAttributedString boundingRectWithSize:options:context:] 來計算文本寬高,用 -[NSAttributedString drawWithRect:options:context:] 來繪制文本。盡管這兩個方法性能不錯,但仍舊需要放到后臺線程進行以避免阻塞主線程。
如果你用 CoreText 繪制文本,那就可以先生成 CoreText 排版對象,然后自己計算了,并且 CoreText 對象還能保留以供稍后繪制使用。
文本渲染
屏幕上能看到的所有文本內容控件,包括 UIWebView,在底層都是通過 CoreText 排版、繪制為 Bitmap 顯示的。常見的文本控件 (UILabel、UITextView 等),其排版和繪制都是在主線程進行的,當顯示大量文本時,CPU 的壓力會非常大。對此解決方案只有一個,那就是自定義文本控件,用 TextKit 或最底層的 CoreText 對文本異步繪制。盡管這實現起來非常麻煩,但其帶來的優勢也非常大,CoreText 對象創建好后,能直接獲取文本的寬高等信息,避免了多次計算(調整 UILabel 大小時算一遍、UILabel 繪制時內部再算一遍);CoreText 對象占用內存較少,可以緩存下來以備稍后多次渲染。
圖片的解碼
當你用 UIImage 或 CGImageSource 的那幾個方法創建圖片時,圖片數據并不會立刻解碼。圖片設置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的數據才會得到解碼。這一步是發生在主線程的,并且不可避免。如果想要繞開這個機制,常見的做法是在后臺線程先把圖片繪制到 CGBitmapContext 中,然后從 Bitmap 直接創建圖片。目前常見的網絡圖片庫都自帶這個功能。
圖像的繪制
圖像的繪制通常是指用那些以 CG 開頭的方法把圖像繪制到畫布中,然后從畫布創建圖片并顯示這樣一個過程。這個最常見的地方就是 [UIView drawRect:] 里面了。由于 CoreGraphic 方法通常都是線程安全的,所以圖像的繪制可以很容易的放到后臺線程進行。一個簡單異步繪制的過程大致如下(實際情況會比這個復雜得多,但原理基本一致):
- (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)和頂點描述(三角形),應用變換(transform)、混合并渲染,然后輸出到屏幕上。通常你所能看到的內容,主要也就是紋理(圖片)和形狀(三角模擬的矢量圖形)兩類。
紋理的渲染
所有的 Bitmap,包括圖片、文本、柵格化的內容,最終都要由內存提交到顯存,綁定為 GPU Texture。不論是提交到顯存的過程,還是 GPU 調整和渲染 Texture 的過程,都要消耗不少 GPU 資源。當在較短時間顯示大量圖片時(比如 TableView 存在非常多的圖片并且快速滑動時),CPU 占用率很低,GPU 占用非常高,界面仍然會掉幀。避免這種情況的方法只能是盡量減少在短時間內大量圖片的顯示,盡可能將多張圖片合成為一張進行顯示。
當圖片過大,超過 GPU 的最大紋理尺寸時,圖片需要先由 CPU 進行預處理,這對 CPU 和 GPU 都會帶來額外的資源消耗。目前來說,iPhone 4S 以上機型,紋理尺寸上限都是 4096x4096,更詳細的資料可以看這里:iosres.com。所以,盡量不要讓圖片和視圖的大小超過這個值。
視圖的混合 (Composing)
當多個視圖(或者說 CALayer)重疊在一起顯示時,GPU 會首先把他們混合到一起。如果視圖結構過于復雜,混合的過程也會消耗很多 GPU 資源。為了減輕這種情況的 GPU 消耗,應用應當盡量減少視圖數量和層次,并在不透明的視圖里標明 opaque 屬性以避免無用的 Alpha 通道合成。當然,這也可以用上面的方法,把多個視圖預先渲染為一張圖片來顯示。
圖形的生成。
CALayer 的 border、圓角、陰影、遮罩(mask),CASharpLayer 的矢量圖形顯示,通常會觸發離屏渲染(offscreen rendering),而離屏渲染通常發生在 GPU 中。當一個列表視圖中出現大量圓角的 CALayer,并且快速滑動時,可以觀察到 GPU 資源已經占滿,而 CPU 資源消耗很少。這時界面仍然能正?;瑒?,但平均幀數會降到很低。為了避免這種情況,可以嘗試開啟 CALayer.shouldRasterize 屬性,但這會把原本離屏渲染的操作轉嫁到 CPU 上去。對于只需要圓角的某些場合,也可以用一張已經繪制好的圓角圖片覆蓋到原本視圖上面來模擬相同的視覺效果。最徹底的解決辦法,就是把需要顯示的圖形在 后臺線程繪制為圖片,避免使用圓角、陰影、遮罩等屬性。
AsyncDisplayKit
ASDK 中還有封裝很多高級的功能,比如滑動列表的預加載、V2.0添加的新的布局模式等。ASDK 是一個很龐大的庫,它本身并不推薦你把整個 App 全部都改為 ASDK 驅動,把最需要提升交互性能的地方用 ASDK 進行優化就足夠了。
預排版
當獲取到 API JSON 數據后,我會把每條 Cell 需要的數據都在后臺線程計算并封裝為一個布局對象 CellLayout。CellLayout 包含所有文本的 CoreText 排版結果、Cell 內部每個控件的高度、Cell 的整體高度。每個 CellLayout 的內存占用并不多,所以當生成后,可以全部緩存到內存,以供稍后使用。這樣,TableView 在請求各個高度函數時,不會消耗任何多余計算量;當把 CellLayout 設置到 Cell 內部時,Cell 內部也不用再計算布局了。
對于通常的 TableView 來說,提前在后臺計算好布局結果是非常重要的一個性能優化點。為了達到最高性能,你可能需要犧牲一些開發速度,不要用 Autolayout 等技術,少用 UILabel 等文本控件。但如果你對性能的要求并不那么高,可以嘗試用 TableView 的預估高度的功能,并把每個 Cell 高度緩存下來。這里有個來自百度知道團隊的開源項目可以很方便的幫你實現這一點:FDTemplateLayoutCell。
預渲染
微博的頭像在某次改版中換成了圓形,所以我也跟進了一下。當頭像下載下來后,我會在后臺線程將頭像預先渲染為圓形并單獨保存到一個 ImageCache 中去。
對于 TableView 來說,Cell 內容的離屏渲染會帶來較大的 GPU 消耗。在 Twitter Demo 中,我為了圖省事兒用到了不少 layer 的圓角屬性,你可以在低性能的設備(比如 iPad 3)上快速滑動一下這個列表,能感受到雖然列表并沒有較大的卡頓,但是整體的平均幀數降了下來。用 Instument 查看時能夠看到 GPU 已經滿負荷運轉,而 CPU 卻比較清閑。為了避免離屏渲染,你應當盡量避免使用 layer 的 border、corner、shadow、mask 等技術,而盡量在后臺線程預先繪制好對應內容。
異步繪制
我只在顯示文本的控件上用到了異步繪制的功能,但效果很不錯。我參考 ASDK 的原理,實現了一個簡單的異步繪制控件。這塊代碼我單獨提取出來,放到了這里:YYAsyncLayer。YYAsyncLayer 是 CALayer 的子類,當它需要顯示內容(比如調用了 [layer setNeedDisplay])時,它會向 delegate,也就是 UIView 請求一個異步繪制的任務。在異步繪制時,Layer 會傳遞一個 BOOL(^isCancelled)() 這樣的 block,繪制代碼可以隨時調用該 block 判斷繪制任務是否已經被取消。
當 TableView 快速滑動時,會有大量異步繪制任務提交到后臺線程去執行。但是有時滑動速度過快時,繪制任務還沒有完成就可能已經被取消了。如果這時仍然繼續繪制,就會造 成大量的 CPU 資源浪費,甚至阻塞線程并造成后續的繪制任務遲遲無法完成。我的做法是盡量快速、提前判斷當前繪制任務是否已經被取消;在繪制每一行文本前,我都會調用 isCancelled() 來進行判斷,保證被取消的任務能及時退出,不至于影響后續操作。
目前有些第三方微博客戶端(比如 VVebo、墨客等),使用了一種方式來避免高速滑動時 Cell 的繪制過程,相關實現見這個項目:VVeboTableViewDemo。 它的原理是,當滑動時,松開手指后,立刻計算出滑動停止時 Cell 的位置,并預先繪制那個位置附近的幾個 Cell,而忽略當前滑動中的 Cell。這個方法比較有技巧性,并且對于滑動性能來說提升也很大,唯一的缺點就是快速滑動中會出現大量空白內容。如果你不想實現比較麻煩的異步繪制但又 想保證滑動的流暢性,這個技巧是個不錯的選擇。
全局并發控制
大量的任務提交到后臺隊列時,某些任務會因為某些原因(此處是 CGFont 鎖)被鎖住導致線程休眠,或者被阻塞,concurrent queue 隨后會創建新的線程來執行其他任務。當這種情況變多時,或者 App 中使用了大量 concurrent queue 來執行較多任務時,App 在同一時刻就會存在幾十個線程同時運行、創建、銷毀。CPU 是用時間片輪轉來實現線程并發的,盡管 concurrent queue 能控制線程的優先級,但當大量線程同時創建運行銷毀時,這些操作仍然會擠占掉主線程的 CPU 資源。ASDK 有個 Feed 列表的 Demo:SocialAppLayout,當列表內 Cell 過多,并且非??焖俚幕瑒訒r,界面仍然會出現少量卡頓,我謹慎的猜測可能與這個問題有關。
使用 concurrent queue 時不可避免會遇到這種問題,但使用 serial queue 又不能充分利用多核 CPU 的資源。我寫了一個簡單的工具 YYDispatchQueuePool, 為不同優先級創建和 CPU 數量相同的 serial queue,每次從 pool 中獲取 queue 時,會輪詢返回其中一個 queue。我把 App 內所有異步操作,包括圖像解碼、對象釋放、異步繪制等,都按優先級不同放入了全局的 serial queue 中執行,這樣盡量避免了過多線程導致的性能問題。
更高效的異步圖片加載
SDWebImage 在這個 Demo 里仍然會產生少量性能問題,并且有些地方不能滿足我的需求,所以我自己實現了一個性能更高的圖片加載庫。在顯示簡單的單張圖片時,利用 UIView.layer.contents 就足夠了,沒必要使用 UIImageView 帶來額外的資源消耗,為此我在 CALayer 上添加了 setImageWithURL 等方法。除此之外,我還把圖片解碼等操作通過 YYDispatchQueuePool 進行管理,控制了 App 總線程數量。
其他可以改進的地方
列表中有不少視覺元素并不需要觸摸事件,這些元素可以用 ASDK 的圖層合成技術預先繪制為一張圖。
再進一步減少每個 Cell 內圖層的數量,用 CALayer 替換掉 UIView。
目前每個 Cell 的類型都是相同的,但顯示的內容卻各部一樣,比如有的 Cell 有圖片,有的 Cell 里是卡片。把 Cell 按類型劃分,進一步減少 Cell 內不必要的視圖對象和操作,應該能有一些效果。
把需要放到主線程執行的任務劃分為足夠小的塊,并通過 Runloop 來進行調度,在每個 Loop 里判斷下一次 VSync 的時間,并在下次 VSync 到來前,把當前未執行完的任務延遲到下一個機會去。這個只是我的一個設想,并不一定能實現或起作用。
如何評測界面的流暢度
最后還是要提一下,“過早的優化是萬惡之源”,在需求未定,性能問題不明顯時,沒必要嘗試做優化,而要盡量正確的實現功能。做性能優化時,也最好是走修改代碼 -> Profile -> 修改代碼這樣一個流程,優先解決最值得優化的地方。
最后,用 Instuments 的 GPU Driver 預設,能夠實時查看到 CPU 和 GPU 的資源消耗。在這個預設內,你能查看到幾乎所有與顯示有關的數據,比如 Texture 數量、CA 提交的頻率、GPU 消耗等,在定位界面卡頓的問題時,這是最好的工具。