图层性能之离屏渲染、栅格化、回收池

CoreAnimationXmind.png

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

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

上一篇文章图像IO之图片加载、解码,缓存介绍了如何高效加载、绘制图片,避免影响帧率。这篇文章着重介绍图层树,以实现更好性能。

1. 隐式绘制 Inexplicit Drawing

图层的 backing image 可以使用 Core Graphics 绘制,或为contents属性赋值图片,或在离屏的CGContext提前渲染。之前的文章已经介绍过了这些场景的优化,但还可以通过以下三种方式优化:

  • 修改图层属性。
  • 使用特殊视图。
  • 使用特定图层子类。

应当了解什么情况下使用何种方式,并尽可能避免使用软件绘制。

1.1 栅格化 Rasterization

在之前的文章中,为了解决半透明图层重叠、复杂图层树的性能问题,已经使用过CALayershouldRasterize属性。shouldRasterize默认值为 false。

1.1.1 性能特点
  • 当被设置为 true 时,图层被 GPU 离屏渲染成图片,图片会被缓存并用来替换图层及其子图层。但首次使用时需时间来生成图片,并占用额外内存。
  • 内容更新时,再次离屏渲染图片。
  • 缓存大小被限制为2.5倍屏幕大小,
  • 缓存超过100ms没有使用会被自动丢弃。
1.1.2 用途
  • 静态内容想要避免重复绘制特殊效果,例如圆角、阴影等。因为一旦内容发生变化(如 resize、动画),之前处理得到的缓存就失效了。如果频繁发生变化,就又回到了每一帧都需要离屏渲染的场景,缓存占用的内存只会让性能变得更糟。
  • 想要避免重复绘制复杂层级结构。如果有很多子图层,或子图层有复杂显示效果,栅格化性能远高于每帧重绘。

使用 Debug > View Debugging > Rendering > Color Hits Green and Misses Red 检测是否使用了缓存图片。如果缓存的图片需要不断重新生成,该选项会使用红色标记重绘部分。

2. 离屏渲染 Offscreen Rendering

在屏幕中显示内容时,需一块至少与屏幕像素数据量一样大的 frame buffer,作为像素数据存储区域,而这也是 GPU 存储渲染结果的地方。如果因某些限制,无法把渲染结果直接写入 frame buffer,需先暂存到单独的一块内存区域,之后再写入 frame buffer,这个过程被称为离屏渲染 Offscreen Rendering

渲染结果先经过了离屏 buffer,再到 frame buffer。如下图所示:

CAOffscreenBuffer.png

2.1 CPU“离屏渲染”

如果在UIView中实现了draw(_:)方法,即使函数体内部没有任何代码,系统也会为其分配一块内存,等待 Core Graphics 可能的绘制操作。

Core Graphics、CoreText 的任何绘制方法,都会分配单独内存,不会直接绘制到 frame buffer。因为 CPU 不擅长渲染,我们就会认为需要尽量避免离屏渲染,但根据 Apple 工程师的说法,CPU 渲染并非真正意义上的离屏渲染。开启 Xcode 中的 Color Offscreen-Rendered Yellow 后,使用draw(_:)绘制的区域并不会被标记为黄色,从另一方面也说明了 Xcode 也不认为这属于离屏渲染。

2.2 画家算法

渲染工作主要由独立进程中的 render server 完成。对于每一层 layer,render server 会遵循画家算法,把各层按照深度排序,由深到浅依次输出到 frame buffer,后一次覆盖前一层。

CAPainter.png

上层会覆盖底层,被遮盖部分像素数据永久丢失。此时不能通过修改当前层的某一部分,让底下的层重新显示出来。

如果能在 frame buffer 之外另开启一块内存,把待处理的 layer 先画上去,然后在这块临时区里执行擦除、修改工作,处理完毕再写回到 frame buffer,得到最终结果。虽然这种方法需要额外空间,但得到了更大的灵活性。

2.3 GPU 离屏渲染

上面提到的 frame buffer 之外的内存,称为离屏 buffer,整个过程就是离屏渲染。对于每一层 layer,我们肯定希望找到单次遍历就能完成渲染的算法,不然就需要申请一块离屏 buffer,借助临时中转区完成一些修改、剪切操作。单独开辟离屏 buffer 是一种很昂贵的操作。

离屏渲染并不意味着软件绘制,但一定是在离屏 context 由 CPU 或 GPU 渲染。以下图层属性会引起离屏渲染:

  • cornerRadiusmasksToBounds一起使用。
  • mask
  • 阴影。

离屏渲染和开启光栅化有些像,但离屏渲染开销并没有光栅化那么大,子图层也不受影响,也不会缓存渲染结果,因此不会产生长期内存开销。太多图层离屏渲染会明显影响性能。

2.4 避免离屏渲染方案

如果图层需要 offscreen rendering,且图层内容没有变化,可以开启光栅化优化图层性能;如果图层需要 offscreen rendering,且图层内容有变化,可以使用CAShapeLayercontentsCentershadowPath实现相似效果,同时避免了离屏渲染。

2.4.1 CAShapeLayer

cornerRadiusmasksToBounds本身不会引起性能问题,只有一起使用时才会引起 offscreen rendering。

有时需要显示圆角矩形并裁减超出边界的子图层,但有时不需要裁减圆角,这时使用CAShapeLayer可以避免离屏渲染问题。

使用UIBezierPathinit(roundedRect:byRoundingCorners:cornerRadii:)创建左上角、右下角圆角的矩形:

        let blueLayer = CAShapeLayer()
        blueLayer.bounds = CGRect(x: 0, y: 0, width: 100, height: 100)
        blueLayer.fillColor = UIColor.blue.cgColor
        blueLayer.path = UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: 100, height: 100), byRoundingCorners: [.topLeft, .bottomRight], cornerRadii: CGSize(width: 10, height: 10)).cgPath
        view.layer.addSublayer(blueLayer)
2.4.2 拉伸图片

另一种创建圆角矩形方式是使用圆角图片,赋值给contents属性,并结合contentsCenter创建可拉伸图片。理论上来说,这种方式渲染速度比CAShapeLayer速度快。一个可拉伸图片需要18个三角形(一个图片是由一个3*3网格渲染而成),一条顺滑曲线需要很多三角形。

        let blueLayer = CAShapeLayer()
        blueLayer.bounds = CGRect(x: 0, y: 0, width: 200, height: 200)
        blueLayer.contentsCenter = CGRect(x: 0.5, y: 0.5, width: 0.0, height: 0.0)
        blueLayer.contentsScale = UIScreen.main.scale
        blueLayer.contents = UIImage(named: "Rounded")?.cgImage
        view.layer.addSublayer(blueLayer)

拉伸图片可以创建任意边框效果,而无需额外开销。

2.4.3 shadowPath

如果图层是矩形或圆角矩形,不含部分透明、子图层,可以很简单的创建阴影路径,进而简化 Core Animation 绘制阴影的计算工作,也避免了离屏渲染。

如果阴影形状复杂,使用图片作为阴影性能可能更好。

2.4.4 Group Opacity

allowsGroupOpacity属性是布尔类型,指示是否允许图层独立于父图层进行合成。

allowsGroupOpacity值为trueopacity值小于1.0时,图层可以独立于父图层进行合成,显示结果更逼真,特别是图层包含不透明组件时,但也更耗费性能。

默认从 bundle 的 Info.plist 文件读取UIViewGroupOpacity属性。如果没有读取到,则使用默认值 true。

opacity小于1.0时,会触发离屏渲染。设置为 false 可以避免这一情况。

将一对红色、蓝色图层重叠在一起,设置父图层opacity为0.5,并复制一份作为对比。左侧开启 group opacity,右侧关闭。如下所示:

        let redLayer1 = CALayer()
        redLayer1.frame = CGRect(x: view.bounds.size.width / 2 - 130, y: 100, width: 100, height: 200)
        redLayer1.backgroundColor = UIColor.red.cgColor
        redLayer1.opacity = 0.5
        redLayer1.allowsGroupOpacity = true
        view.layer.addSublayer(redLayer1)
        
        let blueLayer1 = CALayer()
        blueLayer1.frame = CGRect(x: 0, y: 0, width: 50, height: 100)
        blueLayer1.backgroundColor = UIColor.blue.cgColor
        blueLayer1.opacity = 0.8
        redLayer1.addSublayer(blueLayer1)
        
        // 关闭 group opacity
        let redLayer2 = CALayer()
        redLayer2.frame = CGRect(x: view.bounds.size.width / 2 + 30, y: 100, width: 100, height: 200)
        redLayer2.backgroundColor = UIColor.red.cgColor
        redLayer2.opacity = 0.5
        redLayer2.allowsGroupOpacity = false
        view.layer.addSublayer(redLayer2)
        
        let blueLayer2 = CALayer()
        blueLayer2.frame = CGRect(x: 0, y: 0, width: 50, height: 100)
        blueLayer2.backgroundColor = UIColor.blue.cgColor
        blueLayer2.opacity = 0.8
        redLayer2.addSublayer(blueLayer2)

打开 Color Offscreen-Rendered Yellow 后如下:

CAGroupOpacity.png

可以看到左侧开启 group opacity 的图层进行了离屏渲染。

如果用不到 group opacity,永远设置为 false。

3. 混合和重绘

GPU 每帧可渲染像素数量是有限制的,称为 fill rate。如果由于图层重叠,导致同一位置重绘多次,可能导致掉帧。

GPU 不会绘制完全被其他图层覆盖的像素,但计算图层是否被覆盖会耗费处理器资源。只有在必要的时候才使用透明度。下面方案可以提高性能:

  • 设置backgroundColor为固定、不透明颜色。
  • 设置视图的opaque为 true。

图层不透明后,底层图层不会对显示效果有任何作用,避免了混合和重绘。

除非特别需要,应避免图片 alpha 透明度。如果图片显示在固定背景色、静态图之上,且背景色、静态图不会随 foreground 移动,应填充图片背景,避免运行时混合。

使用UILabel时,白色或其他纯色背景比透明背景更容易渲染。

通过使用shouldRasterize属性,可以把静态图层树压缩为一张图片,无需每帧时混合,避免混合、重绘的开销。

4. 减少图层数量

初始化、预处理、打包并使用 IPC 发送到 render server,及转换至 OpenGL/Metal 等都会产生开销,这些限制了屏幕可显示图层数量上限。

上限数量随硬件、图层类型、内容、属性而已,但一般图层数量达到成百上千后,即使图层没进行任何设置也会开始产生性能问题。

4.1 3D 图层矩阵

对图层进行优化前,先确保屏幕上不显示的图层不要初始化、添加到屏幕中。图层可能由于以下原因不显示:

  • 位于屏幕、父图层可见区域之外。
  • 被其他不透明图层覆盖。
  • 完全透明。

Core Animation 可以很好的处理不可见图层,但你的代码能更早(比如创建前)的解决此类问题,进而避免初始化、配置图层的开销。

下面代码创建了滑动的3D图层矩阵,图层只简单设置了颜色。

    let width = 10
    let height = 10
    let depth = 10
    let size = 100
    let spacing: CGFloat = 150
    let cameraDistance: CGFloat = 500.0
    
    var scrollView = UIScrollView()
    
    private func test3DMatrixLayer() {
        scrollView.frame = view.bounds
        scrollView.backgroundColor = .gray
        scrollView.contentSize = CGSize(width: spacing * CGFloat(width - 1), height: CGFloat(height - 1) * spacing)
        view.addSubview(scrollView)
        
        var transform = CATransform3DIdentity
        transform.m34 = -1.0 / cameraDistance
        scrollView.layer.sublayerTransform = transform
        
        // Create layers
        for z in 0...(depth-1) {
            for y in 0...height {
                for x in 0...width {
                    // Create layer
                    let layer = CALayer()
                    layer.frame = CGRect(x: 0, y: 0, width: size, height: size)
                    layer.position = CGPoint(x: CGFloat(x) * spacing, y: CGFloat(y) * spacing)
                    layer.zPosition = -CGFloat(z) * spacing
                    
                    // Set background color
                    layer.backgroundColor = UIColor(white: 1-CGFloat(z)*(1.0/CGFloat(depth)), alpha: 1.0).cgColor
                    
                    // Attach to scroll view
                    scrollView.layer.addSublayer(layer)
                }
            }
        }
        
        print("Displayed:\(depth * height * width)")
    }

效果如下:

CA3DMatrixLayer.png

width、height、depth常量控制图层数量。在这个demo中,有10*10*10=1000个图层,其中几百个可见。

如果增加width、height常量为100,即共十万个图层,app 性能会立即降下来。但屏幕中可见图层数量并未改变,也就是并未进行额外绘制工作,app 性能下降是由于初始化图层、计算图层位置导致。

由于图层是均匀分布在网格中,可以计算出当前显示的图层,也就没有必要初始化、计算不可见图层位置。UITableViewUICollectionView使用了类似机制,只初始化当前可见的 cell。此外,还会复用出队的cell。

4.2 对象回收 Object Recycling

处理大量视图、图层时,可以通过对象回收减少性能消耗。UITableViewCellUICollectionViewCellMKMapView的图钉都用了回收机制。

Object recycling 的机制是创建一个相似对象池,当某个实例不再使用时,添加到回收池;当需要新实例时,从回收池取出,如果回收池没有实例,则直接创建。

使用回收池可以避免创建、销毁对象的开销,并且可以避免为相似实例重复赋值。

使用回收池重构 demo:

    let width = 100
    let height = 100
    let depth = 10
    let size: CGFloat = 100.0
    let spacing: CGFloat = 150.0
    let cameraDistance: CGFloat = 500.0
    
    var scrollView = UIScrollView()
    var recyclePool = NSMutableSet()
    
    override func viewWillLayoutSubviews() {
        updateLayers()
    }
    
    // 创建3D图层矩阵
    private func test3DMatrixLayer() {
        scrollView.frame = view.bounds
        scrollView.backgroundColor = .gray
        scrollView.contentSize = CGSize(width: spacing * CGFloat(width - 1), height: CGFloat(height - 1) * spacing)
        scrollView.delegate = self
        view.addSubview(scrollView)
        
        var transform = CATransform3DIdentity
        transform.m34 = -1.0 / cameraDistance
        scrollView.layer.sublayerTransform = transform
    }
    
    // MARK: - 循环使用已创建的图层
    
    private func perspective(_ z: CGFloat) -> CGFloat {
        return cameraDistance / (z + cameraDistance)
    }
    
    func updateLayers() {
        // Calculate clipping bounds
        var bounds = scrollView.bounds
        bounds.origin = scrollView.contentOffset
        bounds = bounds.insetBy(dx: -size/2, dy: -size/2)
        
        // Add existing layers to pool
        recyclePool.addObjects(from: scrollView.layer.sublayers ?? [])
        
        // Disable animation
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        
        // Create layers
        var recycled = 0
        var visibleLayers = [CALayer]()
        for z in (0...(depth-1)).reversed() {
            // Increase bounds size to compensate for perspective
            var adjusted = bounds
            adjusted.size.width /= perspective(CGFloat(z)*spacing)
            adjusted.size.height /= perspective(CGFloat(z)*spacing)
            adjusted.origin.x -= (adjusted.size.height - bounds.size.width) / 2
            adjusted.origin.y -= (adjusted.size.height - bounds.size.height) / 2
            
            for y in 0...height {
                // Check if vertically outside visible rect
                
                if CGFloat(y) * spacing < adjusted.origin.y || CGFloat(y)*spacing >= adjusted.origin.y + adjusted.size.height {
                    continue
                }
                
                for x in 0...width {
                    // Check if horizontally outside visible rect
                    if CGFloat(x)*spacing < adjusted.origin.x || CGFloat(x) * spacing >= adjusted.origin.x + adjusted.size.width {
                        continue
                    }
                    
                    // Recycle layer if available
                    var layer = recyclePool.anyObject() as? CALayer
                    if layer != nil {
                        recycled += 1
                        recyclePool.remove(layer)
                    } else {
                        // Otherwise create a new one
                        layer = CALayer()
                        layer?.frame = CGRect(x: 0, y: 0, width: size, height: size)
                    }
                    
                    // Set position
                    layer?.position = CGPoint(x: CGFloat(x)*spacing, y: CGFloat(y)*spacing)
                    layer?.zPosition = -CGFloat(z)*spacing
                    
                    // Set background color
                    layer?.backgroundColor = UIColor(white: 1-CGFloat(z)*(1.0/CGFloat(depth)), alpha: 1).cgColor
                    
                    // Attach to scroll view
                    visibleLayers.append(layer!)
                }
            }
        }
        
        // Update layers
        scrollView.layer.sublayers = visibleLayers
        
        print("Displayed:\(visibleLayers.count)/\(depth*height*width)")
    }

extension LayerPerformanceViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        updateLayers()
    }
}

这个示例中只有一种类型图层对象,系统框架使用标志符区分不同回收池。

使用回收池之前,每次都初始化图层,无需使用CATransaction禁用动画。开启回收后,对象可能是被回收的,修改图层属性会触发隐式动画,因此需先禁用动画。

4.3 Core Graphics 绘制

除了只创建屏幕中可见图层,还可以进一步减少图层数量。例如,如果使用了多个UILabelUIImageView显示静态内容,可以使用draw(_:)将整个视图层级绘制到一个视图中。

虽然 GPU 渲染速度比 CPU 快,也无需占用额外内存。但当性能瓶颈是图层数量时,软件绘制通过减少初始化图层数量和图层树层级,也能够提高整体性能。

4.4 render(in:)

虽然有时使用draw(_:)手动绘制内容可以提高性能,但要放弃UIView的很多便捷性,如 Interface Builder、Auto layout等。

幸运的是不是必须使用draw(_:)。当有大量图层时,只有图层添加到了图层树,才会被发送给 render tree,此时才会对渲染产生性能影响。

通过CALayerrender(in:)方法,可以将图层树绘制到 Core Graphics 的 context,并将结果作为图片输出。shouldRasterize栅格化图层时,图层必须添加到图层树中。使用render(in:)绘制时图层无需添加到图层树中。

使用render(in:)后,图层内容发生改变时需开发者进行处理,而shouldRasterize会自动缓存、检查缓存是否失效。但render(in:)生成图片后可以减少后续工作,Core Animaiton 无需维护复杂图层树。

总结

CALayerPerformance.png

这篇文章介绍了使用 Core Animation 图层可能遇到的性能瓶颈,如离屏渲染、混合和重绘、图层数量太对。并提供了如下多种解决方案。

  • shouldRasterize将图层渲染为一张图片,缓存起来使用。

  • 使用CAShapeLayer、图片创建圆角。

  • 使用shadowPath创建阴影。

  • 尽可能不使用不透明图层。

  • 只创建当前屏幕可见图层。

  • 添加图层到回收池,避免不必要的创建、释放。

  • 使用 Core Graphics 将复杂视图绘制到一个视图中。

  • 使用render(_:)将图层树绘制为图片。

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

上一篇:图像IO之图片加载、解码,缓存

参考资料:

  1. 关于离屏渲染的深入研究
  2. Advanced Graphics and Animations for iOS Apps Presentation Slides
  3. Advanced Graphics and Animations for iOS Apps Transcript
  4. How to make a UIView's subviews' alpha change according to it's parent's alpha?
  5. A Performance-minded take on iOS design

欢迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/图层性能之离屏渲染、栅格化、回收池.md

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

推荐阅读更多精彩内容