iOS核心動畫高階技術(十五) 圖層效能

weixin_33935777發表於2017-12-13

Doing more things faster is no substitute for doing the right things. 要更快效能,也要做對正確的事情。 ——Stephen R. Covey

在第14章『影象IO』討論如何高效地載入和顯示影象,通過檢視來避免可能引起動畫幀率下降的效能問題。在最後一章,我們將著重圖層樹本身,以發掘最好的效能。 #隱式繪製 寄宿圖可以通過Core Graphics直接繪製,也可以直接載入一個圖片檔案並賦值給 contents 屬性,或事先繪製一個螢幕之外的 CGContext 上下文。在之前的兩章中我們討論了這些場景下的優化。但是除了常見的顯式建立寄宿圖,你也可以通過以下三種方式建立隱式的:1,使用特性的圖層屬性。2,特定的檢視。3,特定的圖層子類。

瞭解這個情況為什麼發生何時發生是很重要的,它能夠讓你避免引入不必要的軟體繪製行為。

#文字 CATextLayerUILabel 都是直接將文字繪製在圖層的寄宿圖中。事實上這兩種方式用了完全不同的渲染方式:在iOS 6及之前, UILabel 用WebKit的HTML 渲染引擎來繪製文字,而 CATextLayer 用的是Core Text.後者渲染更迅速,所以 在所有需要繪製大量文字的情形下都優先使用它吧。但是這兩種方法都用了軟體的 方式繪製,因此他們實際上要比硬體加速合成方式要慢。

不論如何,儘可能地避免改變那些包含文字的檢視的frame,因為這樣做的話文字就需要重繪。例如,如果你想在圖層的角落裡顯示一段靜態的文字,但是這個圖層經常改動,你就應該把文字放在一個子圖層中。

#光柵化 在第四章『視覺效果』中我們提到了CALayershouldRasterize 屬性,它可以解決重疊透明圖層的混合失靈問題。同樣在第12章『速度的曲調』中,它也是作為繪製複雜圖層樹結構的優化方法。

啟用 shouldRasterize 屬性會將圖層繪製到一個螢幕之外的影象。然後這個影象將會被快取起來並繪製到實際圖層的 contents 和子圖層。如果有很多的子圖層或者有複雜的效果應用,這樣做就會比重繪所有事務的所有幀划得來得多。但是光柵化原始影象需要時間,而且還會消耗額外的記憶體。

當我們使用得當時,光柵化可以提供很大的效能優勢(如你在第12章所見),但是一定要避免作用在內容不斷變動的圖層上,否則它快取方面的好處就會消失,而且會讓效能變的更糟。

為了檢測你是否正確地使用了光柵化方式,用Instrument檢視一下Color Hits Green和Misses Red專案,是否已光柵化影象被頻繁地重新整理(這樣就說明圖層並不是光柵化的好選擇,或則你無意間觸發了不必要的改變導致了重繪行為)。 #離屏渲染 當圖層屬性的混合體被指定為在未預合成之前不能直接在螢幕中繪製時,螢幕外渲染就被喚起了。螢幕外渲染並不意味著軟體繪製,但是它意味著圖層必須在被顯示之前在一個螢幕外上下文中被渲染(不論CPU還是GPU)。圖層的以下屬性將會觸發螢幕外繪製:

  • 圓角(當和 maskToBounds 一起使用時)
  • 圖層蒙板
  • 陰影 螢幕外渲染和我們啟用光柵化時相似,除了它並沒有像光柵化圖層那麼消耗大,子圖層並沒有被影響到,而且結果也沒有被快取,所以不會有長期的記憶體佔用。但是,如果太多圖層在螢幕外渲染依然會影響到效能。

有時候我們可以把那些需要螢幕外繪製的圖層開啟光柵化以作為一個優化方式,前提是這些圖層並不會被頻繁地重繪。

對於那些需要動畫而且要在螢幕外渲染的圖層來說,你可以用 CAShapeLayercontentsCenter 或者 shadowPath 來獲得同樣的表現而且較少地影響到效能。 #CAShapeLayer cornerRadiusmaskToBounds 獨立作用的時候都不會有太大的效能問題, 但是當他倆結合在一起,就觸發了螢幕外渲染。有時候你想顯示圓角並沿著圖層裁切子圖層的時候,你可能會發現你並不需要沿著圓角裁切,這個情況下用 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
複製程式碼

#可伸縮圖片 另一個建立圓角矩形的方法就是用一個圓形內容圖片並結合第二章『寄宿圖』提到的contentsCenter屬性去建立一個可伸縮圖片(見清單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屬性為YES

這樣做減少了混合行為(因為編譯器知道在圖層之後的東西都不會對最終的畫素顏色產生影響)並且計算得到了加速,避免了過度繪製行為因為Core Animation可 以捨棄所有被完全遮蓋住的圖層,而不用每個畫素都去計算一遍。

如果用到了影象,儘量避免透明除非非常必要。如果影象要顯示在一個固定的背景顏色或是固定的背景圖之前,你沒必要相對前景移動,你只需要預填充背景圖片就可以避免執行時混色了。

如果是文字的話,一個白色背景的 UILabel (或者其他顏色)會比透明背景要更高效。

最後,明智地使用 shouldRasterize 屬性,可以將一個固定的圖層體系摺疊成單張圖片,這樣就不需要每一幀重新合成了,也就不會有因為子圖層之間的混合和過度繪製的效能問題了。 #減少圖層數量 初始化圖層,處理圖層,打包通過IPC發給渲染引擎,轉化成OpenGL幾何圖 形,這些是一個圖層的大致資源開銷。事實上,一次效能夠在螢幕上顯示的最大圖層數量也是有限的。

確切的限制數量取決於iOS裝置,圖層型別,圖層內容和屬性等。但是總得說來可以容納上百或上千個,下面我們將演示即使圖層本身並沒有做什麼也會遇到的效能問題。 #裁切 在對圖層做任何優化之前,你需要確定你不是在建立一些不可見的圖層,圖層在以下幾種情況下回事不可見的:

  • 圖層在螢幕邊界之外,或是在父圖層邊界之外。
  • 完全在一個不透明圖層之後。
  • 完全透明

Core Animation非常擅長處理對視覺效果無意義的圖層。但是經常性地,你自己的程式碼會比Core Animation更早地想知道一個圖層是否是有用的。理想狀況下,在圖層物件在建立之前就想知道,以避免建立和配置不必要圖層的額外工作。

舉個例子。清單15.3 的程式碼展示了一個簡單的滾動3D圖層矩陣。這看上去很酷,尤其是圖層在移動的時候(見圖15.1),但是繪製他們並不是很麻煩,因為這些圖層就是一些簡單的矩形色塊。

#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
複製程式碼

WIDTHHEIGHTDEPTH 常量控制著圖層的生成。在這個情況下,我們得到的是101010個圖層,總量為1000個,不過一次性顯示在螢幕上的大約就幾百個。

如果把 WIDTHHEIGHT 常量增加到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 頗為常見; UITableViewUICollectionView 都有用到, 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{
             //otherwise create a new one
             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的全部了(至少可以等到蘋果發明什麼新的玩意兒)。

相關文章