Skip to content

CALayer及其各种子类

pro648 edited this page Aug 17, 2020 · 1 revision

CoreAnimationXmind

这是 Core Animation 的系列文章,介绍了 Core Animation 的用法,以及如何进行性能优化。

  1. CoreAnimation基本介绍
  2. CGAffineTransform和CATransform3D
  3. CALayer及其各种子类
  4. CAAnimation:属性动画CABasicAnimation、CAKeyframeAnimation以及过渡动画、动画组
  5. 图层时间CAMediaTiming
  6. 计时器CADisplayLink
  7. 影响动画性能的因素及如何使用 Instruments 检测
  8. 图像IO之图片加载、解码,缓存
  9. 图层性能之离屏渲染、栅格化、回收池

我们已经介绍了CALayer类、CGAffineTransform、CATransform3D,但 Core Animation 图层不止用于设置图片、背景色。这一篇文章介绍一些图层类,进一步扩展 Core Animation 的能力。

1. CAShapeLayer

在第一篇文章CoreAnimation基本介绍中,介绍了使用CGPath创建任意形状的阴影,无需使用图片。如果可以创建任意形状图层就更好了。

CAShapeLayer在其坐标空间中绘制三次贝塞尔曲线图层,继承自CALayer

CAShapeLayer在 layer 的 contents 和第一个 sublayer 之间合成,CAShapeLayer通过矢量图形而非位图绘制。使用CGPath指定颜色、线宽和形状,CAShapeLayer自动渲染图层。你也可以使用 Core Graphics 直接向CALayercontents绘制路径,但使用CAShapeLayer有以下这些优点:

  • 快速。CAShapeLayer使用硬件加速,比使用 Core Graphics 绘制速度快。

  • 节省内存。CAShapeLayer无需像CALayer那样创建 backing image。因此,不会随着 layer 变大,占用更大内存。

  • 超出 layer 边框部分不会被裁剪。CAShapeLayer可以在bounds外绘制,不会像使用 Core Graphics 在CALayer绘制的图形一样被裁剪掉。

  • 旋转、缩放等变换操作后不会失真。由于CAShapeLayer是矢量图(Vector graphics),可以通过数学公式计算获得。放大时,不会像位图(bitmap)那样放大单个像素,也就不会出现线条或形状锯齿化的问题。

1.1 创建 CGPath

CAShapeLayer可用于绘制任何可用CGPath表示的形状。图形不一定闭合,路径不一定连续,可以在一个CAShapeLayer中添加多个 shape。

设置一些属性可以改变CAShapeLayer样式,如fillColorstrokeColorlineWidthlineCap(线末端样式)、lineJoin(线之间接头样式)等,但一个CAShapeLayer只能有一个fillColorlineDashPatternlineJoin等。如果需使用不同样式、颜色,需创建多个 shape layer。

下面代码显示了使用CAShapeLayer绘制线笔画,CAShapeLayerpath属性是CGPathRef类型。这里使用UIBezierPath创建 path,省去了手动释放CGPath的步骤。如下所示:

        // Create path
        let path = UIBezierPath()
        path.move(to: CGPoint(x: 175, y: 100))
        path.addArc(withCenter: CGPoint(x: 150, y: 100), radius: 25, startAngle: 0, endAngle: .pi * 2, clockwise: true)
        path.move(to: CGPoint(x: 150, y: 125))
        path.addLine(to: CGPoint(x: 150, y: 175))
        path.addLine(to: CGPoint(x: 125, y: 225))
        path.move(to: CGPoint(x: 150, y: 175))
        path.addLine(to: CGPoint(x: 175, y: 225))
        path.move(to: CGPoint(x: 100, y: 150))
        path.addLine(to: CGPoint(x: 200, y: 150))
        
        // Create shape layer
        let shapelLayer = CAShapeLayer()
        shapelLayer.strokeColor = UIColor.red.cgColor
        shapelLayer.fillColor = UIColor.clear.cgColor
        shapelLayer.lineWidth = 5
        shapelLayer.lineJoin = .bevel
        shapelLayer.lineCap = .round
        shapelLayer.path = path.cgPath
        
        // Add it to our view
        view.layer.addSublayer(shapelLayer)

效果如下:

CAShapeLayer

1.2 圆角

使用CAShapeLayer可以创建圆角矩形。与cornerRadius相比,CAShapeLayer允许指定单个角半径。下面代码创建三个圆角、一个直角的矩形:

        let rect = CGRect(x: 0, y: 0, width: 100, height: 100)
        let radii = CGSize(width: 20, height: 20)
        let path = UIBezierPath.init(roundedRect: rect, byRoundingCorners: [.topRight, .bottomRight, .bottomLeft], cornerRadii: radii)

CAShapeLayerpath属性设置上述贝塞尔曲线,可以获得圆角、直角组合的矩形。如果想要将 layer 的contents设置为同样图形,可以将CAShapeLayer赋值给mask属性。如下所示:

        // Create path
        let rect = CGRect(x: 0, y: 0, width: 100, height: 100)
        let radii = CGSize(width: 20, height: 20)
        let path = UIBezierPath.init(roundedRect: rect, byRoundingCorners: [.topRight, .bottomRight, .bottomLeft], cornerRadii: radii)
        
        let layer = CALayer()
        layer.backgroundColor = UIColor.gray.cgColor
        layer.position = view.center
        layer.bounds = CGRect(x: 0, y: 0, width: 100, height: 100)
        
        // Create mask layer
        let maskLayer = CAShapeLayer()
        maskLayer.path = path.cgPath
        layer.mask = maskLayer
        
        view.layer.addSublayer(layer)

效果如下:

mask

CALayermask属性是CALayer类,使用方法与 sublayer 类似,相对于拥有它的图层布局自身位置。与普通 sublayer 不同,mask不是在父图层内绘制,其决定了父图层的可见区域。

mask的颜色不重要,重要的是它的轮廓。与mask重合部分会被保留下来,mask以外部分会被隐藏。

MaskedImage

如果masklayer小于父图层,则只有与mask相交的父图层部分可见,其他部分都会被隐藏。

2. CATransformLayer

在 3D 场景中,创建对象的层级结构并将变换应用于根视图,整个层级结构会随之变换。

向容器中添加四个图层,不添加任何变换。如下所示:

ContainerLayer

旋转每个 layer Y轴后,得到如下四个图层:

DistintRotation

CALayer不能管理 3D 层级结构中的深度,其只能将Z轴场景展平到单个层级。为了解决这个问题,需使用CATransformLayer

与其他 layer 不同,CATransformLayer不会将 sublayer 展平到 Z=0 的平面中,因此,它不支持CALayer的众多功能:

  • 只渲染CATransformLayer的 sublayer。transform layer 的backgroundColorcontents、边缘样式、描边样式等都不会生效。
  • 2D 图像处理的属性会被忽略。包含filtersbackgroundFilterscompositingFiltermaskmasksToBounds和阴影样式等。
  • opacity属性会被单独应用到每个 sublayer,transform layer 不会形成合成组。
  • Transform layer 没有 2D 坐标空间概念,不能将自身点映射到二维空间。因此,不要对 transform layer 应用hitTest:方法。

下面代码创建了四个 layer,其具有相同的x、y坐标,不同z坐标。

    private func testTransformLayerA() {
        // Create the container as a CATransformLayer
        let container = CATransformLayer()
        
        // 如果使用CALayer,不能得到三维图层。
//        let container = CALayer()
        container.frame = view.frame
        view.layer.addSublayer(container)
        
        // Planes data
				let planesPosition = view.layer.position
        let planeSize = CGSize(width: 100, height: 100)
        
        // Create 4 planes
        let purplePlane = addPlane(to: container, size: planeSize, position: planesPosition, color: UIColor.purple)
        let redPlane = addPlane(to: container, size: planeSize, position: planesPosition, color: UIColor.red)
        let orangePlane = addPlane(to: container, size: planeSize, position: planesPosition, color: UIColor.orange)
        let yellowPlane = addPlane(to: container, size: planeSize, position: planesPosition, color: UIColor.yellow)
        
        // Apply transform to the container
        var t = CATransform3DIdentity
        t.m34 = 1.0 / -500
        t = CATransform3DRotate(t, .pi/3, 0, 1, 0)
        container.transform = t
        
        // Apply transform to the planes
        t = CATransform3DIdentity
        t = CATransform3DTranslate(t, 0, 0, 0)
        purplePlane.transform = t
        
        // Apply transform to the planes
        t = CATransform3DIdentity
        t = CATransform3DTranslate(t, 0, 0, -40)
        redPlane.transform = t
        
        // Apply transform to the planes
        t = CATransform3DIdentity
        t = CATransform3DTranslate(t, 0, 0, -80)
        orangePlane.transform = t
        
        // Apply transform to the planes
        t = CATransform3DIdentity
        t = CATransform3DTranslate(t, 0, 0, -120)
        yellowPlane.transform = t
    }
    
    private func addPlane(to container: CALayer, size: CGSize, position: CGPoint, color: UIColor) -> CALayer {
        let plane = CALayer()
        plane.backgroundColor = color.cgColor
        plane.opacity = 0.6
        plane.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
        plane.position = position
        plane.borderColor = UIColor.init(white: 1.0, alpha: 0.5).cgColor
        plane.borderWidth = 3
        plane.cornerRadius = 10
        container.addSublayer(plane)
        
        return plane
    }

运行结果如下:

CATransformLayer

如果使用CALayer替代CATransformLayer,效果如下:

CALayer

3. CAGradientLayer

CAGradientLayer绘制背景色渐变的图层。

Gradient layer 用于创建包含任意数量颜色的颜色渐变。默认情况下,颜色均匀分布在整个图层上,但可以使用locations属性指定颜色位置。

CAGradientLayer有以下属性:

  • locations:元素为浮点类型的数组,值范围为0至1,且只能递增。如果为nil,则均匀排布。默认为nil
  • colors:元素为CGColorRef类型的数组,默认为nil
  • startPoint:在图层坐标空间绘制时,渐变的起点。使用单位坐标系,并在绘制时映射到 layer 点坐标。默认值为(0.5, 0.5)。
  • endPoint:在图层坐标空间绘制时,渐变的终点。使用单位坐标系,并在绘制时映射到 layer 点坐标。默认值为(0.5, 1.0)。

下面代码展示了如何创建包含三种颜色、指定渐变位置的图层:

				gradient.colors = [UIColor.red.cgColor, UIColor.yellow.cgColor, UIColor.green.cgColor]
        gradient.locations = [0.0, 0.25, 0.5]
        
        gradient.startPoint = CGPoint(x: 0, y: 0)
        gradient.endPoint = CGPoint(x: 1, y: 1)

效果如下:

GradientLayer

4. CAReplicatorLayer

CAReplicatorLayer用于创建 layer 的指定数量副本,副本间有不同的几何坐标、显示属性(delay、transform)和颜色等。常用属性如下:

  • instanceCount:要创建的副本数,包括原始 layer。默认值时1,即不创建副本。
  • instanceDelay:指定副本显示延时。默认值为0.0秒,即同步显示。
  • instanceTransform:向前一个副本添加 transform,得到当前副本。默认为CATransform3DIdentity
  • preservesDepth:是否将子图层展平到平面中。默认为false。如果为true,则CAReplicatorLayer表现与CATransformLayer相似,同时受CATransformLayer同样限制。
  • instanceColor:指定原始图层的颜色。默认为不透明白色。
  • instanceRedOffset:指定颜色红色通道偏移量。向 k-1 实例添加偏移,得到 k 实例颜色。默认为0.0。

instanceGreenOffsetinstanceBlueOffsetinstanceAlphaOffsetinstanceRedOffset类似,只是通道不同。

下面的代码在屏幕中央创建一个白色的 layer,使用CAReplicatorLayer创建由十个 layer 构成圆形的图案。

        var replicatorLayer = CAReplicatorLayer()
        replicatorLayer.bounds = CGRect(x: 0, y: 0, width: view.bounds.size.width, height: view.bounds.size.height)
        view.layer.addSublayer(replicatorLayer)
        
        // Configure the replicator
        replicatorLayer.instanceCount = 10
        
        // Apply a transform for each instance
        var transform = CATransform3DIdentity
        transform = CATransform3DTranslate(transform, 0, 200, 0)
        transform = CATransform3DRotate(transform, .pi / 5.0, 0, 0, 1)
        transform = CATransform3DTranslate(transform, 0, -200, 0)
        replicatorLayer.instanceTransform = transform
        
        // Apply a color shift for each instance
        replicatorLayer.instanceBlueOffset = -0.1
        replicatorLayer.instanceGreenOffset = -0.1
        
        // Create a sublayer and place it inside the replicator
        let layer = CALayer()
        layer.bounds = CGRect(x: 0, y: 0, width: 100, height: 100)
        layer.position = view.layer.position
        layer.backgroundColor = UIColor.white.cgColor
        
        replicatorLayer.addSublayer(layer)

效果如下:

CAReplicatorLayer

CAReplicatorLayer可用于游戏中导弹发射后轨迹、粒子发射效果。此外,还可以用于镜像图片。

设置负值的缩放因子可以获得镜像。这里将其封装为单独视图,后续使用时只需继承自ReflectionView即可。

class ReflectionView: UIView {
    
    override class var layerClass: AnyClass {
        return CAReplicatorLayer.self
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    private func setup() {
        let layer = self.layer as! CAReplicatorLayer
        layer.instanceCount = 2
        
        // Move reflection instance below original and flip vertically
        var transform = CATransform3DIdentity
        let verticalOffset = self.bounds.size.height + 2
        transform = CATransform3DTranslate(transform, 0, verticalOffset, 0)
        transform = CATransform3DScale(transform, 1, -1, 0)
        layer.instanceTransform = transform
        
        // Reduce alpha of reflection layer
        layer.instanceAlphaOffset = -0.6
    }
}

效果如下:

Reflection

开源项目ReflectionView实现了自适应渐变淡出效果,淡出效果使用CAGradientLayer和 mask 实现。

5. CAScrollLayer

对于没有进行变换的 layer,bounds的大小与frame的大小一致。frame是由boundsposition派生而来。因此,改变一个会影响另一个。

如果想展示大图层的一部分应该如何做?例如,有一个很大的图片,或者一个长列表、文本,希望用户可以随意滑动。在 iOS 中,可以使用UITableViewUIScrollView,Core Animation 中对应的 layer 是什么呢?

想要展示大图一部分时,可以使用contentsRect属性,但当你的图层有 sublayer 时,每次滑动时都需要手动计算、更新所有 sublayer 位置,这样非常麻烦。

这时可以使用CAScrollLayerCAScrollLayerscroll(to:)方法自动调整bounds的原点,使图层内容看起来是在滑动。由于 Core Animation 不能识别用户手势,因此其不能将手势转换为滑动事件,另外也不会渲染滑动状态条和滑动弹性效果。

下面使用CAScrollLayer创建一个类似UIScrollView的替代控件。创建一个自定义UIView,使用CAScrollLayer作为 backing layer,使用UIPanGestureRecognizer处理手势。代码如下:

class ScrollView: UIView {
    
    override class var layerClass: AnyClass {
        return CAScrollLayer.self
    }
    
    private func setup() {
        // Enable clipping
        layer.masksToBounds = true
        backgroundColor = UIColor.lightGray
        
        // Attach pan gesture recognizer
        let recognizer = UIPanGestureRecognizer(target: self, action: #selector(self.pan(_:)))
        addGestureRecognizer(recognizer)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        
        setup()
    }
    
    @objc func pan(_ recognizer: UIPanGestureRecognizer) {
        // Get the offset by subtracting the pan gesture
        // Transform from the current bounds origin
        var offset = self.bounds.origin
        offset.x -= recognizer.translation(in: self).x
        offset.y -= recognizer.translation(in: self).y
        
        // Scroll the layer
        layer.scroll(offset)
        
        // Reset the pan gesture translation
        recognizer.setTranslation(CGPoint.zero, in: self)
    }
}

如下所示:

CAScrollLayer

CAScrollLayer类的以下方法实现了滚动功能:

  • scroll(to: CGPoint):将 layer 的原点设置为指定点。
  • scroll(to: CGRect):滚动内容,确保指定矩形区域可见。

我们使用CAScrollLayer实现的 ScrollView 类没有进行任何边界检测,内容可能会划出可见区域并可继续滚动。CAScrollLayer没有UIScrollViewcontentSize概念,因此没有总可滑动区域概念。也就是划动CAScrollLayer时,它只是调整bounds原点到指定位置。

既然可以通过调整CALayerbounds获得同样效果,什么情况下需要使用CAScrollLayer?事实上很少使用CAScrollLayerUIScrollView没有使用CAScrollLayer,而是直接操控 layer 的bounds进行滚动。

6. CATiledLayer

有时需要绘制的图片特别大,而移动设备内存非常有限,因此读取整个图片到内存不是一种好的解决方案。

载入大图会非常慢,常用的init(named:)contentsOfFile:方法会堵塞主线程,导致卡顿。图片最大大小受设备内存限制。屏幕上显示的图片最终都会被转换为 OpenGL texture,而 OpenGL texture 有一个最大的大小(通常为2048*2048或4096*4096,因设备而异)。

如果要显示的图片大于单个 texture,即使图片已经存在于内存中了,Core Animation 也必须使用 CPU 而非 GPU 处理图片,这时会明显感受到内存问题。

CATiledLayer通过把大图分割为小图解决上述性能问题。当需要渲染更多区域时,在一个或多个后台线程调用draw(in:)方法,为绘制操作提供数据。Drawing context 提供了 clip bounds 和 transform matrix,用于确定请求图块的分辨率和 bounds。

使用setNeedsDisplay(_:)方法使图层指定区域无效,但更新是异步的。且下一次的更新很可能不包含更新的内容,但后续的更新会包含。

6.1 显示多个小图

下面展示一张大图(2048*2048)。为了获得CATiledLayer的性能提升,需将大图分割为多张小图。虽然可以使用代码分割图片,但如果在运行时加载图片并分割,将会失去CATiledLayer提供的性能提升。这里直接使用分割好的小图,小图大小为256*256,共64张。

CATiledLayer添加到UIScrollView使用,并实现draw(in:)方法。当CATiledLayer需要加载新图片时,会调用draw(in:)方法。

    private func testTiledLayer() {
        view.addSubview(scrollView)
        
        // Add the tiled layer
        let tileLayer = CATiledLayer()
        tileLayer.frame = CGRect(x: 0, y: 0, width: 2048, height: 2048)
        tileLayer.delegate = self
        scrollView.layer.addSublayer(tileLayer)
        
        // Configure the scroll view.
        scrollView.contentSize = tileLayer.frame.size
        
        // Draw layer
        tileLayer.setNeedsDisplay()
    }
    
extension LayersViewController: CALayerDelegate {
    func draw(_ layer: CALayer, in ctx: CGContext) {
        guard let layer = layer as? CATiledLayer else  {
            return
        }
        
        // Determine tile coordinate
        let bounds = ctx.boundingBoxOfClipPath
        let x: Int = Int(floor(bounds.origin.x / layer.tileSize.width))
        let y: Int = Int(floor(bounds.origin.y / layer.tileSize.height))
        
        // Load tile image
        let imgName = "Snowman_0\(x)_0\(y)"
        let imgPath = Bundle.main.path(forResource: imgName, ofType: "jpg")
        guard let imgLocation = imgPath else { return }
        let tileImage = UIImage(contentsOfFile: imgLocation)
        
        // Draw tile
        UIGraphicsPushContext(ctx)
        tileImage?.draw(in: bounds)
        UIGraphicsPopContext()
    }
}

如下所示:

CATiledLayer

当滑动图片,会发现CATiledLayer载入小图的时候会淡入到屏幕中,这是CATiledLayer的默认行为,可以使用fadeDuration属性改变淡入时长或直接禁用掉。CATiledLayer不同于大部分UIKit和 Core Animation API,它支持多线程绘制,draw(in:)方法可能在多线程并行调用,需确保该方法内的绘制代码线程安全。

不要尝试直接修改CATiledLayercontents属性,因为这样会禁用它的异步机制,使其和普通的CALayer没有区别。

7. CAEmitterLayer

CAEmitterLayer是一个高性能的粒子引擎,用来创建实时粒子动画。例如,烟雾、火、雨等。

CAEmitterLayerCAEmitterCell实例的容器,CAEmitterCell定义了粒子效果。创建一个或多个CAEmitterCell对象作为不同类型粒子的模版,CAEmitterLayer基于模版产生粒子流。

CAEmitterCell继承自NSObject,和CALayer非常类似。CAEmitterCellcontents属性可以定义为一个CGImage,还有很多属性用于配制粒子的外观和行为。这里不会详细介绍每一个属性,你可以在CAEmitterCell文档中查看详细介绍。

下面创建拥有不同速度、透明度的粒子,以视图中心为emitterPosition向四周发射的爆炸效果。

        // Create particle emitter layer
        var replicatorLayer = CAReplicatorLayer()
        emitter.position = view.layer.position
        emitter.bounds = view.bounds
        view.layer.addSublayer(emitter)

        // Configure emitter
        emitter.renderMode = .additive
        emitter.emitterPosition = view.center

        // Create a particle template
        let cell = CAEmitterCell()
        cell.contents = UIImage(named: "Spark")?.cgImage
        cell.birthRate = 150
        cell.lifetime = 5
        cell.color = UIColor(red: 1.0, green: 0.5, blue: 0.1, alpha: 1.0).cgColor
        cell.alphaSpeed = -0.4
        cell.velocity = 50
        cell.velocityRange = 50
        cell.emissionRange = .pi * 2.0

        // Add particle template to emitter
        emitter.emitterCells = [cell]

如下所示:

CAEmitterLayer

CAEmitterCell属性可分为三类:

  • 属性初始值,如color属性指定一个可以混合contents图片的颜色。在上述示例中,color被设置为橘色。
  • 属性的变化范围。上述示例中,emissionRange被设置为360度,表示粒子可以向任意方向发射,粒子之间角度具有一定差值。可以通过设置一个小角度创建锥形效果。
  • 属性随时间的变化。上述示例中,alphaSpeed值为-0.4,表示粒子的alpha每秒减少0.4,创建一种粒子远离过程中逐渐消失的效果。

CAEmitterLayer属性控制整个粒子系统的位置和形状。CAEmitterLayer的有些属性与CAEmitterCell属性相同,设置CAEmitterLayer的属性后,会与CAEmitterCell属性相乘。使用CAEmitterLayer属性可以控制整个粒子系统效果。还有以下两个重要属性:

  • preservesDepth:定义是否将粒子展平到平面中,默认为false。如果为true,则该图层将其粒子渲染为位于该图层上层的三维坐标空间。启用后,layer 的filtersbackgroundFilters和阴影相关属性效果是未定义的。
  • renderMode:控制粒子图层在视觉上如何融合,默认值为unordered。示例中使用additive,即重叠部分亮度增加。

CAEmitterLayerscaleseedspin等属性乘数,只影响新创建的粒子,已经发射出粒子不受影响。例如,emitter 的scale值为1,发射一些粒子后修改scale为2。此时,已经发射出去的粒子大小不受影响,仍保持原来大小,新创建的粒子大小变为原来二倍。

总结

这一部分介绍了多种图层,以及使用这些图层可以实现的效果。像CATiledLayerCAEmitterLayer等类都可以单独写成一篇文章,这里只作简单介绍。另外,CATextLayerCAMetaLayerAVPlayerLayer也是CALayer的子类,这篇文章并未介绍,可以自行查阅文档。

CALayer并没有针对所有情况都进行性能优化。如果想要达到最佳性能,需根据需求选择合适子类。下一篇文章CAAnimation:属性动画CABasicAnimation、CAKeyframeAnimation以及过渡动画、动画组将介绍显式动画。

Demo名称:CoreAnimation
源码地址:https://github.com/pro648/BasicDemos-iOS/tree/master/CoreAnimation

上一篇:CGAffineTransform和CATransform3D

下一篇:CAAnimation:属性动画CABasicAnimation、CAKeyframeAnimation以及过渡动画、动画组

参考资料:

  1. Introduction to 3D drawing in Core Animation (Part 1)
Clone this wiki locally