iOS 中 UIView 和 CALayer 的关系

6,566 阅读15分钟

UIView 有一个名叫 layer ,类型为 CALayer 的对象属性,它们的行为很相似,主要区别在于:CALayer 继承自 NSObject ,不能够响应事件

这是因为 UIView 除了负责响应事件 ( 继承自 UIReponder ) 外,它还是一个对 CALayer 的底层封装。可以说,它们的相似行为都依赖于 CALayer 的实现,UIView 只不过是封装了它的高级接口而已。

CALayer 是什么呢?

CALayer(图层)

文档对它定义是:管理基于图像内容的对象,允许您对该内容执行动画

概念

图层通常用于为 view 提供后备存储,但也可以在没有 view 的情况下使用以显示内容。图层的主要工作是管理您提供的可视内容,但图层本身可以设置可视属性(例如背景颜色、边框和阴影)。除了管理可视内容外,该图层还维护有关内容几何的信息(例如位置、大小和变换),用于在屏幕上显示该内容

和 UIView 之间的关系

示例1 - layer 影响视图的变化:

let view = UIView(frame: CGRect(x: 44, y: 44, width: UIScreen.width - 88, height: 300))
view.backgroundColor = .red
view.layer.backgroundColor = UIColor.orange.cgColor

print("view: \(view.backgroundColor!)")
print("layer: \(view.layer.backgroundColor!)")

// Prints "view: 1 0.5 0 1"
// Prints "layer: 1 0.5 0 1"

view.layer.frame.origin.y = 100

print("view: \(view.frame.origin.y)")
print("layer: \(view.layer.frame.origin.y)")

// Prints "view: 100"
// Prints "layer: 100"

可以看到,无论是修改了 layer 的可视内容或是几何信息,view 都会跟着变化,反之也是如此。这就证明:UIView 依赖于 CALayer 得以显示。

既然他们的行为如此相似,为什么不直接用一个 UIViewCALayer 处理所有事件呢?主要是基于两点考虑:

  1. 职责不同

    UIVIew 的主要职责是负责接收并响应事件;而 CALayer 的主要职责是负责显示 UI。

  2. 需要复用

    在 macOS 和 App 系统上,NSViewUIView 虽然行为相似,在实现上却有着显著的区别,却又都依赖于 CALayer 。在这种情况下,只能封装一个 CALayer 出来。

CALayerDelegate

你可以使用 delegate (CALayerDelegate) 对象来提供图层的内容,处理任何子图层的布局,并提供自定义操作以响应与图层相关的更改。如果图层是由 UIView 创建的,则该 UIView 对象通常会自动指定为图层的委托。

注意:

  1. 在 iOS 中,如果图层与 UIView 对象关联,则必须将此属性设置为拥有该图层的 UIView 对象。
  2. delegate 只是另一种为图层提供处理内容的方式,并不是唯一的。UIView 的显示跟它图层委托没有太大关系。
  1. func display(_ layer: CALayer)

    当图层标记其内容为需要更新 ( setNeedsDisplay() ) 时,调用此方法。例如,为图层设置 contents 属性:

    let delegate = LayerDelegate()
         
    lazy var sublayer: CALayer = {
        let layer = CALayer()
        layer.delegate = self.delegate
        return layer
    }()
         
    // 调用 `sublayer.setNeedsDisplay()` 时,会调用 `sublayer.display(_:)`。
    class LayerDelegate: NSObject, CALayerDelegate {
        func display(_ layer: CALayer) {
            layer.contents = UIImage(named: "rabbit.png")?.cgImage
        }
    }
    

    那什么是 contents 呢?contents 被定义为是一个 Any 类型,但实际上它只作用于 CGImage 。造成这种奇怪的原因是,在 macOS 系统上,它能接受 CGImageNSImage 两种类型的对象。

    你可以把它想象中 UIImageView 中的 image 属性,实际上是,UIImageView 在内部通过转换,将 image.cgImage 赋值给了 contents

    注意:

    如果是 view 的图层,应避免直接设置此属性的内容。视图和图层之间的相互作用通常会导致视图在后续更新期间替换此属性的内容。

  2. func draw(_ layer: CALayer, in ctx: CGContext)

    display(_:) 一样,但是可以使用图层的 CGContext 来实现显示的过程(官方示例):

    // sublayer.setNeedsDisplay()
    class LayerDelegate: NSObject, CALayerDelegate {
        func draw(_ layer: CALayer, in ctx: CGContext) {
            ctx.addEllipse(in: ctx.boundingBoxOfClipPath)
            ctx.strokePath()
        }
    }
    
    • 和 view 中 draw(_ rect: CGRect) 的关系

      文档对其的解释大概是:

      此方法默认不执行任何操作。使用 Core Graphics 和 UIKit 等技术绘制视图内容的子类应重写此方法,并在其中实现其绘图代码。 如果视图以其他方式设置其内容,则无需覆盖此方法。 例如,如果视图仅显示背景颜色,或是使用基础图层对象直接设置其内容等。

      调用此方法时,在调用此方法的时候,UIKit 已经配置好了绘图环境。具体来说,UIKit 创建并配置用于绘制的图形上下文,并调整该上下文的变换,使其原点与视图边界矩形的原点匹配。可以使用 UIGraphicsGetCurrentContext() 函数获取对图形上下文的引用(非强引用)。

      那它是如何创建并配置绘图环境的?我在调查它们的关系时发现:

      /// 注:此方法默认不执行任何操作,调用 super.draw(_:) 与否并无影响。
      override func draw(_ rect: CGRect) {
          print(#function)
      }
      
      override func draw(_ layer: CALayer, in ctx: CGContext) {
          print(#function)
      }
      
      // Prints "draw(_:in:)"
      

      这种情况下,只输出图层的委托方法,而屏幕上没有任何 view 的画面显示。而如果调用图层的 super.draw(_:in:) 方法:

      /// 注:此方法默认不执行任何操作,调用 super.draw(_:) 与否并无影响。
      override func draw(_ rect: CGRect) {
          print(#function)
      }
      
      override func draw(_ layer: CALayer, in ctx: CGContext) {
          print(#function)
          super.draw(layer, in: ctx)
      }
      
      // Prints "draw(_:in:)"
      // Prints "draw"
      

      屏幕上有 view 的画面显示,为什么呢?首先我们要知道,在调用 view 的 draw(_:in:) 时,它需要一个载体/画板/图形上下文 ( UIGraphicsGetCurrentContext ) 来进行绘制操作。所以我猜测是,这个 UIGraphicsGetCurrentContext 是在图层的 super.draw(_:in:) 方法里面创建和配置的。

      具体的调用顺序是:

      1. 首先调用图层的 draw(_:in:) 方法;
      2. 随后在 super.draw(_:in:) 方法里面创建并配置好绘图环境;
      3. 通过图层的 super.draw(_:in:) 调用 view 的 draw(_:) 方法。

      此外,还有另一种情况是:

      override func draw(_ layer: CALayer, in ctx: CGContext) {
          print(#function)
      }
      

      只实现一个图层的 draw(_:in:) 方法,并且没有继续调用它的 super.draw(_:in:) 来创建绘图环境。那在没有绘图环境的时候,view 能显示吗?答案是可以的!这是因为:view 的显示不依赖于 UIGraphicsGetCurrentContext ,只有在绘制的时候才需要。

    • contents 之间的关系

      经过测试发现,调用 view 中 draw(_ rect: CGRect) 方法的所有绘制操作,都被保存在其图层的 contents 属性中:

      // ------ LayerView.swift ------
      override func draw(_ rect: CGRect) {
          UIColor.brown.setFill()	// 填充
          UIRectFill(rect)
      
          UIColor.white.setStroke()	// 描边
          let frame = CGRect(x: 20, y: 20, width: 80, height: 80)
          UIRectFrame(frame)
      }
      
      // ------ ViewController.swift ------
      DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
          print("contents: \(self.layerView.layer.contents)")
      }
      
      // Prints "Optional(<CABackingStore 0x7faf91f06e20 (buffer [480 256] BGRX8888)>)"
      

      这也是为什么要 CALayer 提供绘图环境、以及在上面介绍 contents 这个属性时需要注意的地方。

      重要:

      如果委托实现了 display(_ :) ,将不会调用此方法。

    • display(_ layer: CALayer) 之间的关系

      前面说过,view 的 draw(_:) 方法是由它图层的 draw(_:in:) 方法调用的。但是如果我们实现的是 display(_:) 而不是 draw(_:in:) 呢?这意味着 draw(_:in:) 失去了它的作用,在没有上下文的支持下,屏幕上将不会有任何关于 view 的画面显示,而 display(_:) 也不会自动调用 view 的 draw(_:) ,view 的 draw(_:) 方法也失去了意义,那 display(_ layer: CALayer) 的作用是什么?例如:

      override func draw(_ rect: CGRect) {
          print(#function)
      }
      
      override func display(_ layer: CALayer) {
          print(#function)
      }
      
      // Prints "display"
      

      这里 draw(_:) 没有被调用,屏幕上也没有相关 view 的显示。也就是说,此时除了在 display(_:) 上进行操作外,已经没有任何相关的地方可以设置图层的可视内容了(参考 "1. func display(_ layer: CALayer)",这里不再赘述,当然也可以设置背景颜色等)。当然,你可能永远都不会这么做,除非你创建了一个单独的图层。

      至于为什么不在 display(_ layer: CALayer) 方法里面调用它的父类实现,这是因为如果调用了会崩溃:

      // unrecognized selector sent to instance 0x7fbcdad03ba0
      

      至于为什么?根据我的参考资料,他们都没有在此继续调用 super ( UIView ) 的方法。我随意猜测一下是这样的:

      首先错误提示的意思翻译过来就是:无法识别的选择器(方法)发送到实例。那我们来分析一下,是哪一个实例中?是什么方法?

      1. super 实例;
      2. display(_ layer: CALayer)

      也就是说,在调用 super.display(_ layer: CALayer) 方法的时候,super 中找不到该方法。为什么呢?请注意 UIView 默认已经遵循了 CALayerDelegate 协议(右键点击 UIView 查看头文件),但是应该没有实现它的 display(_:) 方法,而是选择交给了子类去实现。类似的实现应该是:

      // 示意 `CALayerDelegate`
      @objc protocol LayerDelegate: NSObjectProtocol {
          @objc optional func display()
          @objc optional func draw()
      }
      
      // 示意 `CALayer`
      class Layer: NSObject {
          var delegate: LayerDelegate?
      }
      
      // 示意 `UIView`
      class BaseView: NSObject, LayerDelegate {
          let layer = Layer()
          override init() {
              super.init()
              layer.delegate = self
          }
      }
      // 注意:并没有实现委托的 `display()` 方法。
      extension BaseView: LayerDelegate {
          func draw() {}
      }
      
      // 示意 `UIView` 的子类
      class LayerView: BaseView {
          func display() {
              // 同样的代码在OC上实现没有问题。
              // 由于Swift是静态编译的关系,它会检测在 `BaseView` 类中有没有这个方法,
              // 如果没有就会提示编译错误。
              super.display()
          }
      }
      
      // ------ ViewController.swift ------
      let layerView = LayerView()
      // 如果在方法里面调用了 `super.display()` 将引发崩溃。
      layerView.display()
      // 正常执行
      layerView.darw()
      

    注意:

    只有当系统在检测到 view 的 draw(_:) 方法被实现时,才会自动调用图层的 display(_:)draw(_ rect: CGRect) 方法。否则就必须通过手动调用图层的 setNeedsDisplay() 方法来调用。

  3. func layerWillDraw(_ layer: CALayer)

    draw(_ layer: CALayer, in ctx: CGContext) 调用之前调用,可以使用此方法配置影响内容的任何图层状态(例如 contentsFormatisOpaque )。

  4. func layoutSublayers(of layer: CALayer)

    UIViewlayoutSubviews() 类似。当发现边界发生变化并且其 sublayers 可能需要重新排列时(例如通过 frame 改变大小),将调用此方法。

  5. func action(for layer: CALayer, forKey event: String) -> CAAction?

    CALayer 之所以能够执行动画,是因为它被定义在 Core Animation 框架中,是 Core Animation 执行操作的核心。也就是说,CALayer 除了负责显示内容外,还能执行动画(其实是 Core Animation 与硬件之间的操作在执行,CALayer 负责存储操作需要的数据,相当于 Model)。因此,使用 CALayer 的大部分属性都附带动画效果。但是在 UIView 中,默认将这个效果给关掉了,可以通过它图层的委托方法重新开启 ( 在 view animation block 中也会自动开启 ),返回决定它动画特效的对象,如果返回的是 nil ,将使用默认隐含的动画特效。

    示例 - 使用图层的委托方法返回一个从左到右移动对象的基本动画:

    final class CustomView: UIView {
        override func action(for layer: CALayer, forKey event: String) -> CAAction? {
            guard event == "moveRight" else {
                return super.action(for: layer, forKey: event)
            }
            let animation = CABasicAnimation()
            animation.valueFunction = CAValueFunction(name: .translateX)
            animation.fromValue = 1
            animation.toValue = 300
            animation.duration = 2
            return animation
        }
    }
    
    let view = CustomView(frame: CGRect(x: 44, y: 44, width: UIScreen.width - 88, height: 300))
    view.backgroundColor = .orange
    self.view.addSubview(view)
    
    let action = view.layer.action(forKey: "moveRight")
    action?.run(forKey: "transform", object: view.layer, arguments: nil)
    

    那怎么知道它的哪些属性是可以附带动画的呢?核心动画编程指南列出了你可能需要考虑设置动画的 CALayer 属性:

CALayer 坐标系

CALayer 具有除了 framebounds 之外区别于 UIView 的其他位置属性。UIView 使用的所谓 frameboundscenter 等属性,其实都是从 CALayer 中返回的,而 frame 只是 CALayer 中的一个计算型属性而已。

这里主要说一下 CALayer 中的 anchorPointposition 这两个属性,也是 CALayer 坐标系中的主要依赖:

  • var anchorPoint: CGPoint ( 锚点 )

    图层锚点示意图

    看 iOS 部分即可。可以看出,锚点是基于图层的内部坐标,它取值范围是 (0-1, 0-1) ,你可以把它想象成是 bounds 的缩放因子。中间的 (0.5, 0.5) 是每个图层的 anchorPoint 默认值;而左上角的 (0.0, 0.0) 被视为是 anchorPoint 的起始点。

    任何基于图层的几何操作都发生在指定点附近。例如,将旋转变换应用于具有默认锚点的图层会导致围绕其中心旋转,锚点更改为其他位置将导致图层围绕该新点旋转。

    锚点影响图层变换示意图
  • var position: CGPoint ( 锚点所处的位置 )

    锚点影响图层的位置示意图

    看 iOS 部分即可。图1中的 position 被标记为了 (100, 100) ,怎么来的?

    对于锚点来说,它在父图层中有着更详细的坐标。对 position 通俗来解释一下,就是锚点在父图层中的位置

    一个图层它的默认锚点是 (0.5, 0.5) ,既然如此,那就先看下锚点 x 在父图层中的位置,可以看到,从父图层 x 到锚点 x 的位置是 100,那么此时的 position.x 就是 100;而 y 也是类似的,从父图层 y 到锚点 y 的位置也是 100;则可以得出,此时锚点在父图层中的坐标是 (100, 100) ,也就是此时图层中 position 的值。

    对图2也是如此,此时的锚点处于起始点位置 (0.0, 0.0) ,从父图层 x 到锚点 x 的位置是 40;而从父图层 y 到锚点 y 的位置是 60 ,由此得出,此时图层中 position 的值是 (40, 60)

    这里其实计算 position 是有公式的,根据图1可以套用如下公式:

    1. position.x = frame.origin.x + 0.5 * bounds.size.width
    2. position.y = frame.origin.y + 0.5 * bounds.size.height

    因为里面的 0.5 是 anchorPoint 的默认值,更通用的公式应该是:

    1. position.x = frame.origin.x + anchorPoint.x * bounds.size.width
    2. position.y = frame.origin.y + anchorPoint.y * bounds.size.height

    注意:

    实际上,position 就是 UIView 中的 center 。如果我们修改了图层的 position ,那么 view 的 center 会随之改变,反之也是如此。

anchorPoint 和 position 之间的关系

前面说过,position 处于锚点中的位置(相对于父图层)。这里就有一个问题,那就是,既然 position 相对于 anchorPoint ,那如果修改了 anchorPoint 会不会导致 position 的变化?结论是不会:

let redView = UIView(frame: CGRect(x: 40, y: 60, width: 120, height: 80))
print(self.redView.layer.position)	// Prints "(100.0, 100.0)"
redView.layer.anchorPoint = CGPoint(x: 0, y: 1)
print(self.redView.layer.position)	// Prints "(100.0, 100.0)"

那修改了 position 会导致 anchorPoint 的变化吗?结论是也不会:

let redView = UIView(frame: CGRect(x: 40, y: 60, width: 120, height: 80))
print(redView.layer.anchorPoint)	// Prints "(0.5, 0.5)"
redView.layer.anchorPoint = CGPoint(x: 0, y: 1)
print(redView.layer.anchorPoint)	// Prints "(0.5, 0.5)"

经过测试,无论修改了谁另一方都不会受到影响,受到影响的只会是 frame.origin 。至于为什么两者互不影响,我暂时还没想到。我随意猜测一下是这样的:

其实 anchorPoint 就是 anchorPointposition 就是 position 。他们本身其实是没有关联的,因为它们默认处在的位置正好重叠了,所以就给我们造成了一种误区,认为 position 就一定是 anchorPoint 所在的那个点。

和 frame 之间的关系

CALayerframe文档中被描述为是一个计算型属性,它是从 boundsanchorPointposition 的值中派生出来的。为此属性指定新值时,图层会更改其 positionbounds 属性以匹配您指定的矩形。

那它们是如何决定 frame 的?根据图片可以套用如下公式:

  1. frame.x = position.x - anchorPoint.x * bounds.size.width
  2. frame.y = position.y - anchorPoint.y * bounds.size.height

这就解释了为什么修改 positionanchorPoint 会导致 frame 发生变化,我们可以测试一下,假设把锚点改为处在左下角 (0.0, 1.0) :

let redView = UIView(frame: CGRect(x: 40, y: 60, width: 120, height: 80))
redView.layer.anchorPoint = CGPoint(x: 0, y: 1)
print(redView.frame.origin)	// Prints "(100.0, 20.0)"

用公式来计算就是:frame.x (100) = 100 - 0 * 120frame.y (20) = 100 - 1 * 80 ;正好和打印的结果相符。反之,修改 position 属性也会导致 frame.origin 发生如公式般的变化,这里就不再赘述了。

注意:

如果修改了 frame 的值是会导致 position 发生变化的,因为 position 是基于父图层定义的;frame 的改变意味着它自身的位置在父图层中有所改变,position 也会因此改变。

但是修改了 frame 并不会导致 anchorPoint 发生变化,因为 anchorPoint 是基于自身图层定义的,无论外部怎么变,anchorPoint 都不会跟着变化。

修改 anchorPoint 所带来的困惑

对于修改 position 来说其实就是修改它的 "center" ,这里很容易理解。但是对于修改 anchorPoint ,相信很多人都有过同样的困惑,为什么修改了 anchorPoint 所带来的变化往往和自己想象中的不太一样呢?来看一个修改锚点 x 的例子 ( 0.5 → 0.2 ):

仔细观察一下 "图2" 就会发现,不管是新锚点还是旧锚点,它们在自身图层中的位置中都没有变化。既然锚点本身不会变化,那变化的就只能是 x 了。x 是如何变化的?从图片中可以很清楚地看到,是把新锚点移动到旧锚点的所在位置。这也是大部分人的误区,以为修改 0.5 -> 0.2 就是把旧的锚点移动到新锚点的所在位置,结果恰恰相反,这就是造成修改 anchorPoint 往往和自己想象中不太一样的原因。

还有一种比较好理解的方式就是,想象一下,假设 "图1" 中的底部红色图层是一张纸,而中间的白点相当于一枚大头钉固定在它中间,移动的时候,你就按住中间的大头钉让其保持不动。这时候假设你要开始移动到任意点了,那你会怎么做呢?唯一的一种方式就是,移动整个图层,让新的锚点顺着旧锚点中的位置靠拢,最终完全重合,就算移动完成了

参考

彻底理解position与anchorPoint

核心动画编程指南

iOS 核心动画:高级技巧

苹果 UIView 文档

苹果 CALayer 文档