扒一扒NSView和CALayer

作者: Johankoi | 来源:发表于2018-08-16 16:32 被阅读111次

    概述

    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视图属性

    相关文章

      网友评论

      • xexiaoyi:改变了layer的位置后,这个view上面的按钮响应区域还在原来的位置,怎么处理呢?
        xexiaoyi:@心随我所动 我没做过ios开发呢
        Johankoi:@xexiaoyi iOS怎么处理的?
      • Johankoi:文章纯属参照苹果wwdc视频,根据自己的开发经验,理解得来。

      本文标题:扒一扒NSView和CALayer

      本文链接:https://www.haomeiwen.com/subject/euxovxtx.html