扒一扒NSView和CALayer

概述

iOS UIKit的UIView從出生開始便有了一個CALayer,而真正在屏幕上負責顯示任務的是UIView的layer。
而Mac AppKit的NSView最初不是由 Core Animation Layer 驅(qū)動的,是因為那個時候還米有GPU,所有視圖的繪制由CPU完成,后來有了GPU,就順便整合了UIKit的特性,也使得NSView也可以有一個layer,然后把這個有l(wèi)ayer的NSView稱作layer-backed view。不過一個NSView想成為layer-backed view還需要設置 wantsLayer屬性 = true。
比如想要設置NSView背景顏色的時候,因NSView不帶有backgroundColor屬性,想要讓其具有一個layer,通過layer的backgroundColor屬性來設置顏色:

view.wantsLayer = true
view.layer?.backgroundColor = NSColor.red.cgColor

注意必須要有view.wantsLayer = true,使得NSView成為一個layer-backed view,才能操作它的layer屬性使設置的顏色生效。

那么有l(wèi)ayer-backed view之前,設置背景色方案是重寫draw(_ dirtyRect: NSRect)方法

override func draw(_ dirtyRect: NSRect) {
    super.draw(dirtyRect)
    NSColor.red.setFill()
    dirtyRect.fill()
}

那么從這兒開始便產(chǎn)生了兩種NSView的繪制:
把重寫draw(_ dirtyRect: NSRect)方法設置背景色叫做traditional AppKit drawing(傳統(tǒng)繪制),把設置wantsLayer=true,繼而操作layer?.backgroundColor設置背景色的方式成為layer-backed drawing
按照蘋果官方的建議是推薦使用layer-backed NSViews 進行繪制。

利用layer-backed NSViews 繪制的優(yōu)勢

一. Drawing

1.在有l(wèi)ayer之前的traditional AppKit drawing:
看看一個小小的示例:

custom drawRect .png

邊框色,文字,圖片都是在drawRect方法里面繪制,傳入的dirtyRect代表當前繪制的區(qū)域,下面在介紹這個繪制區(qū)域的時候都把它稱作dirty region。
每個被window管理的view及其子view都有一個dirtyRect, 再看一個圖示:(左邊一藍色view,右邊紅色view,里面一個子view有文字有圖片)
dirtyRegionDraw.png

NSWindow會遞歸遍歷每個view的dirty region,先繪制最上層的父view,然后是其子view。整體的繪制過程中涉及的方法調(diào)用圖如下:

DrawingFlow.png

在這種傳統(tǒng)的繪制機制中,一旦設置了NSView setNeedsDisplay為YES,這個view的區(qū)域就會標記為
dirty region,NSWindow會記錄這個區(qū)域,它會死死地認定這一塊區(qū)域就是要重新繪制,這樣會導致一個結果,所有與這個區(qū)域有交合的view都會被重新繪制

dirtyRegionRedraw.png

如圖中黃色的view某一塊區(qū)域與左邊被標記dirty region重繪區(qū)有重疊,因此黃色view也會重繪。

2.使用 Core Animation layers以及它是如何工作
我們讓一個NSView成為layer-backed view就設置其view.wantsLayer = true,還有一個重要的體現(xiàn)是它的子view也擁有了一個layer,如下圖

layer-backed.png

那么layer-backed view是怎么進行繪制的:
layerDrawing.png

圖上的介紹已經(jīng)很清晰:當一個layer需要被繪制的時候,系統(tǒng)會創(chuàng)建一個CGContextRef的對象,用于存儲用于繪制像素的Data,然后調(diào)用drawLayer:inContext:最終調(diào)用到了NSView的drawRect方法,最后的結果就是layer的content有了繪制并暫存的數(shù)據(jù),最終把像素體現(xiàn)在屏幕上,并有一份緩存。

每一個layer-backed view都會對應一個dirty region,設置setNeedsDisplay為YES后只會觸發(fā)它本身layer的繪制,也就意味著,不會導致跟這個view有區(qū)域交集的其他view觸發(fā)重繪:

layerRedraw.png

同樣是兩個view有重合的情況,只因他們是layer-backed view,因此黃色view不會被牽連而重新繪制

二. Animating

1.還是先看traditional Animating AppKit
做一個簡單的調(diào)整frame的動畫:

var frame = customView.frame
frame.size = CGSize(width: 300, height: 300)
customView.animator().frame = frame // NSAnimationContext.current.duration=0.25,默認動畫時間是0.25秒

還可以開啟隱式動畫模式,這樣直接改變view的frame屬性就可以有動畫

NSAnimationContext.current.allowsImplicitAnimation = true
var frame = customView.frame
frame.size = CGSize(width: 300, height: 300)
customView.frame = frame

這種傳統(tǒng)模式下動畫執(zhí)行的過程中每一步都會更新view的frame,整個過程是在主線程里面進行。動畫的每一步都會調(diào)用drawRect方法。

2.Layer-backed view的Core Animation

customView.superView.wantsLayer = true 
let anim = CABasicAnimation(keyPath: "bounds.size")
anim.duration = 1.0
anim.fromValue = rightView.layer?.bounds.size
anim.toValue = CGSize(width: 300, height: 300)
customView.layer?.add(anim, forKey: "animation")

注意使用這種layer add Animation方式,必須要保證customView的superView是Layer-backed view,即
customView.superView.wantsLayer = true

也可以通過直接改變layer的屬性,開啟layer的隱式動畫:

customView.superView.wantsLayer = true // 保證superView是Layer-backed view
NSAnimationContext.current.allowsImplicitAnimation = true // 開啟隱式動畫模式
var layerBounds = rightView.layer?.bounds
layerBounds?.size = CGSize(width: 300, height: 300)
customView.layer?.bounds = layerBounds!

同樣可以直接改customView的frame做隱式動畫:

customView.wantsLayer = true 
NSAnimationContext.current.allowsImplicitAnimation = true
var frame = rightView.frame
frame.size = CGSize(width: 300, height: 300)
customView.frame = frame

所不同的是:內(nèi)部用Layer Core Animation做動畫,同時又真實改變了view的frame,可以設置customView.layerContentsRedrawPolicy = .onSetNeedsDisplay,控制動畫過程中的不進行重新繪制(不會實時調(diào)用drawRect)。關于layerContentsRedrawPolicy下面會詳細介紹。

layer-backed view的animator()還有用嗎?
運行下面的代碼同樣可以有動畫:

rightView.wantsLayer = true
var frame = rightView.frame
frame.size = CGSize(width: 300, height: 300)
rightView.animator().frame = frame

它內(nèi)部機制是會開啟隱式動畫模式,同時用Layer Core Animation做動畫,又真實改變了view的frame:


animator.png

注意:Layer Core Animation執(zhí)行動畫的過程是在一個新的線程。

三. Best Practices

1.合理設置layer-backed view的重繪策略
針對有l(wèi)ayer的NSView,有一個layerContentsRedrawPolicy屬性,用于設置重繪策略,有以下枚舉值:

  • NSViewLayerContentsRedrawDuringViewResize 當尺寸拉伸時候進行重繪制
  • NSViewLayerContentsRedrawOnSetNeedsDisplay 當設置setNeedsDisplay為YES時候進行重繪制
  • NSViewLayerContentsRedrawBeforeViewResize 當尺寸拉伸前進行重繪制
  • NSViewLayerContentsRedrawNever 永遠不會重新繪制

其中,NSViewLayerContentsRedrawDuringViewResize是默認值,只要view 尺寸改變就會重新繪制layer,雖然作為默認值,但是蘋果不推薦使用,推薦使用NSViewLayerContentsRedrawOnSetNeedsDisplay,當你需要重新繪制就手動設置setNeedsDisplay為YES。

2.節(jié)省內(nèi)存
當內(nèi)容完全一樣的多個layer-backed view同時顯示在屏幕上的時候,不要使用drawRect畫邊框,文字,圖片,著色,這樣會導致他們各自layer的content都產(chǎn)生同一份內(nèi)容,這樣會產(chǎn)生多份同樣內(nèi)存:

memoryuse.png

使用layer的屬性,borderColor,backGroundColor,對于圖片可以直接賦值image到layer.content,看看官方介紹:
The default value of this property is nil. If you are using the layer to display a static image, you can set this property to the CGImage containing the image you want to display. (In macOS 10.6 and later, you can also set the property to an NSImage object.) Assigning a value to this property causes the layer to use your image rather than create a separate backing store.
If the layer object is tied to a view object, you should avoid setting the contents of this property directly. The interplay between views and layers usually results in the view replacing the contents of this property during a subsequent update.
使用layer.content可以在多個重復的view之間共享數(shù)據(jù),節(jié)省內(nèi)存,此外如果content是image的話,會自動拉伸圖片自適應。
值得注意的是官方的介紹有一個點,layer-backed view不應該直接去設置view.layer的屬性,應該在系統(tǒng)的視圖更新的某個生命周期去設置,具體就是下面的兩個方法:

@property (readonly) BOOL wantsUpdateLayer NS_AVAILABLE_MAC(10_8);
- (void)updateLayer NS_AVAILABLE_MAC(10_8);

調(diào)用過程示意圖:


layerUpdating.png

為了深化這一塊的認識,我們拿Mac系統(tǒng)NSButton類的實現(xiàn)舉例子:


nsbutton.png

背景是純色拉伸的圖片,然后一個TextField控件用于顯示文字,點擊后背景圖片換成藍色,button尺寸改變而拉伸的時候,保持背景圖片拉伸不失真,TextField保持居中。
直接上關鍵代碼:
- (BOOL)wantsUpdateLayer {
   return YES; // 告訴系統(tǒng)想要使用updateLayer更新content
}
- (void)updateLayer {
    if (self.pressed) {
         self.layer.contents = [NSImage imageNamed:@"pureBlueImage"];
    } else {
         self.layer.contents = [NSImage imageNamed:@"pureGrayImage"];
    }
    // 設置layer拉伸取最中間的像素.
    self.layer.contentsCenter = CGRectMake(0.5, 0.5, 1e-5, 1e-5);
}
- (void)mouseDown:(NSEvent *)event {
     self.pressed = YES;
     [self setNeedsDisplay:YES];
}
// view尺寸改變或者重新布局時候會調(diào)用layout,類似于UIView的layoutSubviews方法
- (void)layout {
    if (_textField == nil) {
        _textField = [[NSTextField alloc] initWithFrame:frame];
        _textField.title = @”Button”;
    } else {
       _textField.frame = // Update the location
    }
    [super layout];
}
- (void)setTitle:(NSString *)title {
    //  NSTextField賦值title的時候由它自己重繪制layer content
    _textField.title = title;
    // 重新布局,調(diào)用layout,調(diào)整_textField的位置保持居中
    // 不需要設置button的setNeedsDisplay為YES重新繪制,
    // 因為button layer contentsCenter屬性設置拉伸中間的像素,當尺寸改變的時候,
    // layer自己拉伸至合適的大小
    [self setNeedsLayout:YES];
}

總結:

1. 推薦使用layer-backed view,并設置layerContentsRedrawPolicy=NSViewLayerContentsRedrawOnSetNeedsDisplay
2.盡量避免在drawRect畫邊框,文字,圖片
3.盡可能使用-wantsUpdateLayer and -updateLayer改變layer-backed view視圖屬性

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

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

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