扒一扒NSView和CALayer

概述

iOS UIKit的UIView从出生开始便有了一个CALayer,而真正在屏幕上负责显示任务的是UIView的layer。
而Mac AppKit的NSView最初不是由 Core Animation Layer 驱动的,是因为那个时候还米有GPU,所有视图的绘制由CPU完成,后来有了GPU,就顺便整合了UIKit的特性,也使得NSView也可以有一个layer,然后把这个有layer的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属性使设置的颜色生效。

那么有layer-backed view之前,设置背景色方案是重写draw(_ dirtyRect: NSRect)方法

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

那么从这儿开始便产生了两种NSView的绘制:
把重写draw(_ dirtyRect: NSRect)方法设置背景色叫做traditional AppKit drawing(传统绘制),把设置wantsLayer=true,继而操作layer?.backgroundColor设置背景色的方式成为layer-backed drawing
按照苹果官方的建议是推荐使用layer-backed NSViews 进行绘制

利用layer-backed NSViews 绘制的优势

一. Drawing

1.在有layer之前的traditional AppKit drawing:
看看一个小小的示例:

custom drawRect .png

边框色,文字,图片都是在drawRect方法里面绘制,传入的dirtyRect代表当前绘制的区域,下面在介绍这个绘制区域的时候都把它称作dirty region
每个被window管理的view及其子view都有一个dirtyRect, 再看一个图示:(左边一蓝色view,右边红色view,里面一个子view有文字有图片)
dirtyRegionDraw.png

NSWindow会递归遍历每个view的dirty region,先绘制最上层的父view,然后是其子view。整体的绘制过程中涉及的方法调用图如下:

DrawingFlow.png

在这种传统的绘制机制中,一旦设置了NSView setNeedsDisplay为YES,这个view的区域就会标记为
dirty region,NSWindow会记录这个区域,它会死死地认定这一块区域就是要重新绘制,这样会导致一个结果,所有与这个区域有交合的view都会被重新绘制

dirtyRegionRedraw.png

如图中黄色的view某一块区域与左边被标记dirty region重绘区有重叠,因此黄色view也会重绘。

2.使用 Core Animation layers以及它是如何工作
我们让一个NSView成为layer-backed view就设置其view.wantsLayer = true,还有一个重要的体现是它的子view也拥有了一个layer,如下图

layer-backed.png

那么layer-backed view是怎么进行绘制的:
layerDrawing.png

图上的介绍已经很清晰:当一个layer需要被绘制的时候,系统会创建一个CGContextRef的对象,用于存储用于绘制像素的Data,然后调用drawLayer:inContext:最终调用到了NSView的drawRect方法,最后的结果就是layer的content有了绘制并暂存的数据,最终把像素体现在屏幕上,并有一份缓存。

每一个layer-backed view都会对应一个dirty region,设置setNeedsDisplay为YES后只会触发它本身layer的绘制,也就意味着,不会导致跟这个view有区域交集的其他view触发重绘:

layerRedraw.png

同样是两个view有重合的情况,只因他们是layer-backed view,因此黄色view不会被牵连而重新绘制

二. Animating

1.还是先看traditional Animating AppKit
做一个简单的调整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

这种传统模式下动画执行的过程中每一步都会更新view的frame,整个过程是在主线程里面进行。动画的每一步都会调用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

所不同的是:内部用Layer Core Animation做动画,同时又真实改变了view的frame,可以设置customView.layerContentsRedrawPolicy = .onSetNeedsDisplay,控制动画过程中的不进行重新绘制(不会实时调用drawRect)。关于layerContentsRedrawPolicy下面会详细介绍。

layer-backed view的animator()还有用吗?
运行下面的代码同样可以有动画:

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

它内部机制是会开启隐式动画模式,同时用Layer Core Animation做动画,又真实改变了view的frame:


animator.png

注意:Layer Core Animation执行动画的过程是在一个新的线程。

三. Best Practices

1.合理设置layer-backed view的重绘策略
针对有layer的NSView,有一个layerContentsRedrawPolicy属性,用于设置重绘策略,有以下枚举值:

  • NSViewLayerContentsRedrawDuringViewResize 当尺寸拉伸时候进行重绘制
  • NSViewLayerContentsRedrawOnSetNeedsDisplay 当设置setNeedsDisplay为YES时候进行重绘制
  • NSViewLayerContentsRedrawBeforeViewResize 当尺寸拉伸前进行重绘制
  • NSViewLayerContentsRedrawNever 永远不会重新绘制

其中,NSViewLayerContentsRedrawDuringViewResize是默认值,只要view 尺寸改变就会重新绘制layer,虽然作为默认值,但是苹果不推荐使用,推荐使用NSViewLayerContentsRedrawOnSetNeedsDisplay,当你需要重新绘制就手动设置setNeedsDisplay为YES。

2.节省内存
当内容完全一样的多个layer-backed view同时显示在屏幕上的时候,不要使用drawRect画边框,文字,图片,着色,这样会导致他们各自layer的content都产生同一份内容,这样会产生多份同样内存:

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之间共享数据,节省内存,此外如果content是image的话,会自动拉伸图片自适应
值得注意的是官方的介绍有一个点,layer-backed view不应该直接去设置view.layer的属性,应该在系统的视图更新的某个生命周期去设置,具体就是下面的两个方法:

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

调用过程示意图:


layerUpdating.png

为了深化这一块的认识,我们拿Mac系统NSButton类的实现举例子:


nsbutton.png

背景是纯色拉伸的图片,然后一个TextField控件用于显示文字,点击后背景图片换成蓝色,button尺寸改变而拉伸的时候,保持背景图片拉伸不失真,TextField保持居中。
直接上关键代码:
- (BOOL)wantsUpdateLayer {
   return YES; // 告诉系统想要使用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尺寸改变或者重新布局时候会调用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;
    // 重新布局,调用layout,调整_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视图属性

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容