iOS 核心動畫的圖層效能
圖層效能
要更快效能,也要做對正確的事情。——Stephen R. Covey
在第14章『影象IO』討論如何高效地載入和顯示影象,通過檢視來避免可能引起動畫幀率下降的效能問題。在最後一章,我們將著重圖層樹本身,以發掘最好的效能。
如果你依然在程式設計的世界裡迷茫,不知道自己的未來規劃,小編給大家推薦一個IOS高階交流群:458839238 裡面可以與大神一起交流並走出迷茫。小白可進群免費領取學習資料,看看前輩們是如何在程式設計的世界裡傲然前行!
群內提供資料結構與演算法、底層進階、swift、逆向、整合面試題等免費資料
附上一份收集的各大廠面試題(附答案) ! 群檔案直接獲取
各大廠面試題
隱式繪製
寄宿圖可以通過Core Graphics直接繪製,也可以直接載入一個圖片檔案並賦值給 contents
屬性,或事先繪製一個螢幕之外的 CGContext
上下文。在之前的兩章中我們討論了這些場景下的優化。但是除了常見的顯式建立寄宿圖,你也可以通過以下三種方式建立隱式的:1,使用特性的圖層屬性。2,特定的檢視。3,特定的圖層子類。
瞭解這個情況為什麼發生何時發生是很重要的,它能夠讓你避免引入不必要的軟體繪製行為。
文字
CATextLayer
和 UILabel
都是直接將文字繪製在圖層的寄宿圖中。事實上這兩種方式用了完全不同的渲染方式:在iOS 6及之前, UILabel
用WebKit的HTML渲染引擎來繪製文字,而 CATextLayer
用的是Core Text.後者渲染更迅速,所以在所有需要繪製大量文字的情形下都優先使用它吧。但是這兩種方法都用了軟體的方式繪製,因此他們實際上要比硬體加速合成方式要慢。
不論如何,儘可能地避免改變那些包含文字的檢視的frame,因為這樣做的話文字就需要重繪。例如,如果你想在圖層的角落裡顯示一段靜態的文字,但是這個圖層經常改動,你就應該把文字放在一個子圖層中。
光柵化
在第四章『視覺效果』中我們提到了 CALayer
的 shouldRasterize
屬性,它可以解決重疊透明圖層的混合失靈問題。同樣在第12章『速度的曲調』中,它也是作為繪製複雜圖層樹結構的優化方法。
啟用 shouldRasterize
屬性會將圖層繪製到一個螢幕之外的影象。然後這個影象將會被快取起來並繪製到實際圖層的 contents
和子圖層。如果有很多的子圖層或者有複雜的效果應用,這樣做就會比重繪所有事務的所有幀划得來得多。但是光柵化原始影象需要時間,而且還會消耗額外的記憶體。
當我們使用得當時,光柵化可以提供很大的效能優勢(如你在第12章所見),但是一定要避免作用在內容不斷變動的圖層上,否則它快取方面的好處就會消失,而且會讓效能變的更糟。
為了檢測你是否正確地使用了光柵化方式,用Instrument檢視一下Color Hits Green和Misses Red專案,是否已光柵化影象被頻繁地重新整理(這樣就說明圖層並不是光柵化的好選擇,或則你無意間觸發了不必要的改變導致了重繪行為)。
離屏渲染
Offscreen rendering does not necessarily imply software drawing, but it means that the layer must first be rendered (either by the CPU or GPU) into an offscreen context before being displayed. The layer attributes that trigger offscreen rendering are as follows:
當圖層屬性的混合體被指定為在未預合成之前不能直接在螢幕中繪製時,螢幕外渲染就被喚起了。螢幕外渲染並不意味著軟體繪製,但是它意味著圖層必須在被顯示之前在一個螢幕外上下文中被渲染(不論CPU還是GPU)。圖層的以下屬性將會觸發螢幕外繪製:
maskToBounds
螢幕外渲染和我們啟用光柵化時相似,除了它並沒有像光柵化圖層那麼消耗大,子圖層並沒有被影響到,而且結果也沒有被快取,所以不會有長期的記憶體佔用。但是,如果太多圖層在螢幕外渲染依然會影響到效能。
有時候我們可以把那些需要螢幕外繪製的圖層開啟光柵化以作為一個優化方式,前提是這些圖層並不會被頻繁地重繪。
對於那些需要動畫而且要在螢幕外渲染的圖層來說,你可以用 CAShapeLayer
, contentsCenter
或者 shadowPath
來獲得同樣的表現而且較少地影響到效能。
CAShapeLayer
cornerRadius
和 maskToBounds
獨立作用的時候都不會有太大的效能問題,但是當他倆結合在一起,就觸發了螢幕外渲染。有時候你想顯示圓角並沿著圖層裁切子圖層的時候,你可能會發現你並不需要沿著圓角裁切,這個情況下用 CAShapeLayer
就可以避免這個問題了。
你想要的只是圓角且沿著矩形邊界裁切,同時還不希望引起效能問題。其實你可以用現成的 UIBezierPath
的構造器 +bezierPathWithRoundedRect:cornerRadius:
(見清單15.1).這樣做並不會比直接用 cornerRadius
更快,但是它避免了效能問題。
清單15.1 用 CAShapeLayer
畫一個圓角矩形
#import "ViewController.h" #import <QuartzCore/QuartzCore.h> @interface ViewController () @property (nonatomic, weak) IBOutlet UIView *layerView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create shape layer CAShapeLayer *blueLayer = [CAShapeLayer layer]; blueLayer.frame = CGRectMake(50, 50, 100, 100); blueLayer.fillColor = [UIColor blueColor].CGColor; blueLayer.path = [UIBezierPath bezierPathWithRoundedRect: CGRectMake(0, 0, 100, 100) cornerRadius:20].CGPath; //add it to our view [self.layerView.layer addSublayer:blueLayer]; } @end
可伸縮圖片
另一個建立圓角矩形的方法就是用一個圓形內容圖片並結合第二章『寄宿圖』提到的 contensCenter
屬性去建立一個可伸縮圖片(見清單15.2).理論上來說,這個應該比用 CAShapeLayer
要快,因為一個可拉伸圖片只需要18個三角形(一個圖片是由一個3*3網格渲染而成),然而,許多都需要渲染成一個順滑的曲線。在實際應用上,二者並沒有太大的區別。
清單15.2 用可伸縮圖片繪製圓角矩形
@implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create layer CALayer *blueLayer = [CALayer layer]; blueLayer.frame = CGRectMake(50, 50, 100, 100); blueLayer.contentsCenter = CGRectMake(0.5, 0.5, 0.0, 0.0); blueLayer.contentsScale = [UIScreen mainScreen].scale; blueLayer.contents = (__bridge id)[UIImage imageNamed:@"Circle.png"].CGImage; //add it to our view [self.layerView.layer addSublayer:blueLayer]; } @end
使用可伸縮圖片的優勢在於它可以繪製成任意邊框效果而不需要額外的效能消耗。舉個例子,可伸縮圖片甚至還可以顯示出矩形陰影的效果。
shadowPath
在第2章我們有提到 shadowPath
屬性。如果圖層是一個簡單幾何圖形如矩形或者圓角矩形(假設不包含任何透明部分或者子圖層),創建出一個對應形狀的陰影路徑就比較容易,而且Core Animation繪製這個陰影也相當簡單,避免了螢幕外的圖層部分的預排版需求。這對效能來說很有幫助。
如果你的圖層是一個更復雜的圖形,生成正確的陰影路徑可能就比較難了,這樣子的話你可以考慮用繪圖軟體預先生成一個陰影背景圖。
混合和過度繪製
在第12章有提到,GPU每一幀可以繪製的畫素有一個最大限制(就是所謂的fill rate),這個情況下可以輕易地繪製整個螢幕的所有畫素。但是如果由於重疊圖層的關係需要不停地重繪同一區域的話,掉幀就可能發生了。
GPU會放棄繪製那些完全被其他圖層遮擋的畫素,但是要計算出一個圖層是否被遮擋也是相當複雜並且會消耗處理器資源。同樣,合併不同圖層的透明重疊畫素(即混合)消耗的資源也是相當客觀的。所以為了加速處理程序,不到必須時刻不要使用透明圖層。任何情況下,你應該這樣做:
backgroundColor opaque
這樣做減少了混合行為(因為編譯器知道在圖層之後的東西都不會對最終的畫素顏色產生影響)並且計算得到了加速,避免了過度繪製行為因為Core Animation可以捨棄所有被完全遮蓋住的圖層,而不用每個畫素都去計算一遍。
如果用到了影象,儘量避免透明除非非常必要。如果影象要顯示在一個固定的背景顏色或是固定的背景圖之前,你沒必要相對前景移動,你只需要預填充背景圖片就可以避免執行時混色了。
如果是文字的話,一個白色背景的 UILabel
(或者其他顏色)會比透明背景要更高效。
最後,明智地使用 shouldRasterize
屬性,可以將一個固定的圖層體系摺疊成單張圖片,這樣就不需要每一幀重新合成了,也就不會有因為子圖層之間的混合和過度繪製的效能問題了。
減少圖層數量
初始化圖層,處理圖層,打包通過IPC發給渲染引擎,轉化成OpenGL幾何圖形,這些是一個圖層的大致資源開銷。事實上,一次效能夠在螢幕上顯示的最大圖層數量也是有限的。
確切的限制數量取決於iOS裝置,圖層型別,圖層內容和屬性等。但是總得說來可以容納上百或上千個,下面我們將演示即使圖層本身並沒有做什麼也會遇到的效能問題。
裁切
在對圖層做任何優化之前,你需要確定你不是在建立一些不可見的圖層,圖層在以下幾種情況下回事不可見的:
- 圖層在螢幕邊界之外,或是在父圖層邊界之外。
- 完全在一個不透明圖層之後。
- 完全透明
Core Animation非常擅長處理對視覺效果無意義的圖層。但是經常性地,你自己的程式碼會比Core Animation更早地想知道一個圖層是否是有用的。理想狀況下,在圖層物件在建立之前就想知道,以避免建立和配置不必要圖層的額外工作。
舉個例子。清單15.3 的程式碼展示了一個簡單的滾動3D圖層矩陣。這看上去很酷,尤其是圖層在移動的時候(見圖15.1),但是繪製他們並不是很麻煩,因為這些圖層就是一些簡單的矩形色塊。
清單15.3 繪製3D圖層矩陣
#import "ViewController.h" #import <QuartzCore/QuartzCore.h> #define WIDTH 10 #define HEIGHT 10 #define DEPTH 10 #define SIZE 100 #define SPACING 150 #define CAMERA_DISTANCE 500 @interface ViewController () @property (nonatomic, strong) IBOutlet UIScrollView *scrollView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //set content size self.scrollView.contentSize = CGSizeMake((WIDTH - 1)*SPACING, (HEIGHT - 1)*SPACING); //set up perspective transform CATransform3D transform = CATransform3DIdentity; transform.m34 = -1.0 / CAMERA_DISTANCE; self.scrollView.layer.sublayerTransform = transform; //create layers for (int z = DEPTH - 1; z >= 0; z--) { for (int y = 0; y < HEIGHT; y++) { for (int x = 0; x < WIDTH; x++) { //create layer CALayer *layer = [CALayer layer]; layer.frame = CGRectMake(0, 0, SIZE, SIZE); layer.position = CGPointMake(x*SPACING, y*SPACING); layer.zPosition = -z*SPACING; //set background color layer.backgroundColor = [UIColor colorWithWhite:1-z*(1.0/DEPTH) alpha:1].CGColor; //attach to scroll view [self.scrollView.layer addSublayer:layer]; } } } //log NSLog(@"displayed: %i", DEPTH*HEIGHT*WIDTH); } @end

image
圖15.1 滾動的3D圖層矩陣
WIDTH
, HEIGHT
和 DEPTH
常量控制著圖層的生成。在這個情況下,我們得到的是10 10 10個圖層,總量為1000個,不過一次性顯示在螢幕上的大約就幾百個。
如果把 WIDTH
和 HEIGHT
常量增加到100,我們的程式就會慢得像龜爬了。這樣我們有了100000個圖層,效能下降一點兒也不奇怪。
但是顯示在螢幕上的圖層數量並沒有增加,那麼根本沒有額外的東西需要繪製。程式慢下來的原因其實是因為在管理這些圖層上花掉了不少功夫。他們大部分對渲染的最終結果沒有貢獻,但是在丟棄這麼圖層之前,Core Animation要強制計算每個圖層的位置,就這樣,我們的幀率就慢了下來。
我們的圖層是被安排在一個均勻的柵格中,我們可以計算出哪些圖層會被最終顯示在螢幕上,根本不需要對每個圖層的位置進行計算。這個計算並不簡單,因為我們還要考慮到透視的問題。如果我們直接這樣做了,Core Animation就不用費神了。
既然這樣,讓我們來重構我們的程式碼吧。改造後,隨著檢視的滾動動態地例項化圖層而不是事先都分配好。這樣,在創造他們之前,我們就可以計算出是否需要他。接著,我們增加一些程式碼去計算可視區域這樣就可以排除區域之外的圖層了。清單15.4是改造後的結果。
清單15.4 排除可視區域之外的圖層
#import "ViewController.h" #import <QuartzCore/QuartzCore.h> #define WIDTH 100 #define HEIGHT 100 #define DEPTH 10 #define SIZE 100 #define SPACING 150 #define CAMERA_DISTANCE 500 #define PERSPECTIVE(z) (float)CAMERA_DISTANCE/(z + CAMERA_DISTANCE) @interface ViewController () <UIScrollViewDelegate> @property (nonatomic, weak) IBOutlet UIScrollView *scrollView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //set content size self.scrollView.contentSize = CGSizeMake((WIDTH - 1)*SPACING, (HEIGHT - 1)*SPACING); //set up perspective transform CATransform3D transform = CATransform3DIdentity; transform.m34 = -1.0 / CAMERA_DISTANCE; self.scrollView.layer.sublayerTransform = transform; } - (void)viewDidLayoutSubviews { [self updateLayers]; } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [self updateLayers]; } - (void)updateLayers { //calculate clipping bounds CGRect bounds = self.scrollView.bounds; bounds.origin = self.scrollView.contentOffset; bounds = CGRectInset(bounds, -SIZE/2, -SIZE/2); //create layers NSMutableArray *visibleLayers = [NSMutableArray array]; for (int z = DEPTH - 1; z >= 0; z--) { //increase bounds size to compensate for perspective CGRect adjusted = bounds; adjusted.size.width /= PERSPECTIVE(z*SPACING); adjusted.size.height /= PERSPECTIVE(z*SPACING); adjusted.origin.x -= (adjusted.size.width - bounds.size.width) / 2; adjusted.origin.y -= (adjusted.size.height - bounds.size.height) / 2; for (int y = 0; y < HEIGHT; y++) { //check if vertically outside visible rect if (y*SPACING < adjusted.origin.y || y*SPACING >= adjusted.origin.y + adjusted.size.height) { continue; } for (int x = 0; x < WIDTH; x++) { //check if horizontally outside visible rect if (x*SPACING < adjusted.origin.x ||x*SPACING >= adjusted.origin.x + adjusted.size.width) { continue; } //create layer CALayer *layer = [CALayer layer]; layer.frame = CGRectMake(0, 0, SIZE, SIZE); layer.position = CGPointMake(x*SPACING, y*SPACING); layer.zPosition = -z*SPACING; //set background color layer.backgroundColor = [UIColor colorWithWhite:1-z*(1.0/DEPTH) alpha:1].CGColor; //attach to scroll view [visibleLayers addObject:layer]; } } } //update layers self.scrollView.layer.sublayers = visibleLayers; //log NSLog(@"displayed: %i/%i", [visibleLayers count], DEPTH*HEIGHT*WIDTH); } @end
這個計算機制並不具有普適性,但是原則上是一樣。(當你用一個 UITableView
或者 UICollectionView
時,系統做了類似的事情)。這樣做的結果?我們的程式可以處理成百上千個『虛擬』圖層而且完全沒有效能問題!因為它不需要一次性例項化幾百個圖層。
物件回收
處理巨大數量的相似檢視或圖層時還有一個技巧就是回收他們。物件回收在iOS頗為常見; UITableView
和 UICollectionView
都有用到, MKMapView
中的動畫pin碼也有用到,還有其他很多例子。
物件回收的基礎原則就是你需要建立一個相似物件池。當一個物件的指定例項(本例子中指的是圖層)結束了使命,你把它新增到物件池中。每次當你需要一個例項時,你就從池中取出一個。當且僅當池中為空時再建立一個新的。
這樣做的好處在於避免了不斷建立和釋放物件(相當消耗資源,因為涉及到記憶體的分配和銷燬)而且也不必給相似例項重複賦值。
好了,讓我們再次更新程式碼吧(見清單15.5)
清單15.5 通過回收減少不必要的分配
@interface ViewController () <UIScrollViewDelegate> @property (nonatomic, weak) IBOutlet UIScrollView *scrollView; @property (nonatomic, strong) NSMutableSet *recyclePool; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create recycle pool self.recyclePool = [NSMutableSet set]; //set content size self.scrollView.contentSize = CGSizeMake((WIDTH - 1)*SPACING, (HEIGHT - 1)*SPACING); //set up perspective transform CATransform3D transform = CATransform3DIdentity; transform.m34 = -1.0 / CAMERA_DISTANCE; self.scrollView.layer.sublayerTransform = transform; } - (void)viewDidLayoutSubviews { [self updateLayers]; } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [self updateLayers]; } - (void)updateLayers { //calculate clipping bounds CGRect bounds = self.scrollView.bounds; bounds.origin = self.scrollView.contentOffset; bounds = CGRectInset(bounds, -SIZE/2, -SIZE/2); //add existing layers to pool [self.recyclePool addObjectsFromArray:self.scrollView.layer.sublayers]; //disable animation [CATransaction begin]; [CATransaction setDisableActions:YES]; //create layers NSInteger recycled = 0; NSMutableArray *visibleLayers = [NSMutableArray array]; for (int z = DEPTH - 1; z >= 0; z--) { //increase bounds size to compensate for perspective CGRect adjusted = bounds; adjusted.size.width /= PERSPECTIVE(z*SPACING); adjusted.size.height /= PERSPECTIVE(z*SPACING); adjusted.origin.x -= (adjusted.size.width - bounds.size.width) / 2; adjusted.origin.y -= (adjusted.size.height - bounds.size.height) / 2; for (int y = 0; y < HEIGHT; y++) { //check if vertically outside visible rect if (y*SPACING < adjusted.origin.y || y*SPACING >= adjusted.origin.y + adjusted.size.height) { continue; } for (int x = 0; x < WIDTH; x++) { //check if horizontally outside visible rect if (x*SPACING < adjusted.origin.x || x*SPACING >= adjusted.origin.x + adjusted.size.width) { continue; } //recycle layer if available CALayer *layer = [self.recyclePool anyObject]; if (layer) { recycled ++; [self.recyclePool removeObject:layer]; } else { layer = [CALayer layer]; layer.frame = CGRectMake(0, 0, SIZE, SIZE); } //set position layer.position = CGPointMake(x*SPACING, y*SPACING); layer.zPosition = -z*SPACING; //set background color layer.backgroundColor = [UIColor colorWithWhite:1-z*(1.0/DEPTH) alpha:1].CGColor; //attach to scroll view [visibleLayers addObject:layer]; } } } [CATransaction commit]; //update layers self.scrollView.layer.sublayers = visibleLayers; //log NSLog(@"displayed: %i/%i recycled: %i", [visibleLayers count], DEPTH*HEIGHT*WIDTH, recycled); } @end
本例中,我們只有圖層物件這一種型別,但是UIKit有時候用一個識別符號字串來區分儲存在不同物件池中的不同的可回收物件型別。
你可能注意到當設定圖層屬性時我們用了一個 CATransaction
來抑制動畫效果。在之前並不需要這樣做,因為在顯示之前我們給所有圖層設定一次屬性。但是既然圖層正在被回收,禁止隱式動畫就有必要了,不然當屬性值改變時,圖層的隱式動畫就會被觸發。
Core Graphics繪製
當排除掉對螢幕顯示沒有任何貢獻的圖層或者檢視之後,長遠看來,你可能仍然需要減少圖層的數量。例如,如果你正在使用多個 UILabel
或者 UIImageView
例項去顯示固定內容,你可以把他們全部替換成一個單獨的檢視,然後用 -drawRect:
方法繪製出那些複雜的檢視層級。
這個提議看上去並不合理因為大家都知道軟體繪製行為要比GPU合成要慢而且還需要更多的記憶體空間,但是在因為圖層數量而使得效能受限的情況下,軟體繪製很可能提高效能呢,因為它避免了圖層分配和操作問題。
你可以自己實驗一下這個情況,它包含了效能和柵格化的權衡,但是意味著你可以從圖層樹上去掉子圖層(用 shouldRasterize
,與完全遮擋圖層相反)。
-renderInContext: 方法
用Core Graphics去繪製一個靜態佈局有時候會比用層級的 UIView
例項來得快,但是使用 UIView
例項要簡單得多而且比用手寫程式碼寫出相同效果要可靠得多,更邊說Interface Builder來得直接明瞭。為了效能而捨棄這些便利實在是不應該。
幸好,你不必這樣,如果大量的檢視或者圖層真的關聯到了螢幕上將會是一個大問題。沒有與圖層樹相關聯的圖層不會被送到渲染引擎,也沒有效能問題(在他們被建立和配置之後)。
使用 CALayer
的 -renderInContext:
方法,你可以將圖層及其子圖層快照進一個Core Graphics上下文然後得到一個圖片,它可以直接顯示在 UIImageView
中,或者作為另一個圖層的 contents
。不同於 shouldRasterize
—— 要求圖層與圖層樹相關聯 —— ,這個方法沒有持續的效能消耗。
當圖層內容改變時,重新整理這張圖片的機會取決於你(不同於 shouldRasterize
,它自動地處理快取和快取驗證),但是一旦圖片被生成,相比於讓Core Animation處理一個複雜的圖層樹,你節省了相當客觀的效能。
總結
本章學習了使用Core Animation圖層可能遇到的效能瓶頸,並討論瞭如何避免或減小壓力。你學習瞭如何管理包含上千虛擬圖層的場景(事實上只建立了幾百個)。同時也學習了一些有用的技巧,選擇性地選取光柵化或者繪製圖層內容在合適的時候重新分配給CPU和GPU。這些就是我們要講的關於Core Animation的全部了(至少可以等到蘋果發明什麼新的玩意兒)。