Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 40

Optimizing Rectangular Draws with CAShapeLayer

Recommended resources

Hello! Welcome back to our final lesson in the "UI & Rendering Performance Optimization" module.

In our previous lesson, we did a deep dive into the performance costs of custom drawing. We established that overriding draw(rect:) is a powerful tool, but it comes with significant overhead: it's CPU-bound, executes on the main thread, and requires a memory-intensive backing store. This can be a major source of UI stutter, especially during scrolling or animations.

Today, we'll learn the modern, high-performance solution to this problem. This lesson will show you how to replace a draw(rect:) implementation with a CAShapeLayer to improve rendering performance. You will learn not just the "how" but also the "why," understanding the architectural difference that makes CAShapeLayer so much more efficient.

Why CAShapeLayer is the Right Tool for the Job

Before we write any code, let's solidify why we are making this change. The core difference lies in which processor does the work.

As we discussed, draw(rect:) forces the CPU to create a bitmap, which is then sent to the GPU. A CAShapeLayer, on the other hand, is a special subclass of CALayer designed to draw vector shapes defined by a CGPath. The layer is given a description of the shape (the path), and the GPU handles the rasterization directly.

This GPU-centric approach has several key advantages:

  • Performance: The GPU is highly optimized for this kind of work, freeing up the CPU on the main thread to handle user interaction and business logic.
  • Memory Efficiency: CAShapeLayer doesn't need the intermediate memory buffer (the backing store) that a draw(rect:) view requires. The GPU renders the path directly.
  • Animatability: Properties of the CAShapeLayer, such as its path, strokeColor, or lineWidth, can be animated smoothly by Core Animation on the GPU. Trying to animate a shape drawn with draw(rect:) would require calling setNeedsDisplay() on every frame, re-running the CPU-bound drawing code repeatedly.

The following article provides an excellent introduction to CAShapeLayer and its performance benefits over draw(rect:).

A Practical Guide to CAShapeLayer

The article 'A Practical Guide to CAShapeLayer' by Dimas Ashidiqi starts by perfectly summarizing the performance distinction between draw(_:) and CAShapeLayer.

Please read the 'Introduction to CAShapeLayer' section. Focus on the distinction it makes between CPU and GPU usage.

Now that we're clear on the "why," let's get practical and perform the refactoring.


Refactoring a draw(rect:) View

Imagine you have a simple custom view that draws a bordered circle. A common way to implement this would be using draw(rect:).

The "Old" Way: draw(rect:)

// This is a CPU-bound implementation.
class CircleView: UIView {
    
    // Ensure the view has a clear background so we see our drawing.
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .clear
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        backgroundColor = .clear
    }
    
    override func draw(_ rect: CGRect) {
        // Create a circular path that fits within the view's bounds.
        let path = UIBezierPath(ovalIn: rect.insetBy(dx: 2, dy: 2))
        
        // Configure the drawing context.
        path.lineWidth = 4
        UIColor.systemRed.setStroke()
        
        // Execute the drawing command on the CPU.
        path.stroke()
    }
}

This code works, but as we know, it runs on the CPU and creates a backing store. During our profiling in previous lessons, this is the kind of implementation that would cause the Time Profiler to show significant time spent in -[UIView drawRect:].

iOS Rendering Pipeline with drawRect
As a reminder from our last lesson, this is the CPU-heavy pipeline our `CircleView` uses. Our goal is to bypass the `drawRect` and backing store steps.

The "New" Way: CAShapeLayer

Let's refactor CircleView to use CAShapeLayer. The process involves four main steps, which are clearly laid out in the guide we just read. We will apply them to our CircleView.

A Practical Guide to CAShapeLayer

Let's continue with 'A Practical Guide to CAShapeLayer'. This section provides the exact blueprint for our refactoring.

Read the 'Getting Started' section. Pay close attention to the four steps outlined for creating and configuring the CAShapeLayer and how the properties like fillColor, strokeColor, and lineWidth are set on the layer itself, not through a drawing context.

Following those steps, our new implementation looks like this:

// This is a GPU-accelerated implementation.
class ShapeLayerCircleView: UIView {

    // 1. Create the CAShapeLayer instance as a private property.
    private let circleLayer = CAShapeLayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupLayer()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupLayer()
    }

    private func setupLayer() {
        // 3. Customize the layer's properties.
        // Note: We use .cgColor because CALayer works with Core Graphics types.
        circleLayer.strokeColor = UIColor.systemRed.cgColor
        circleLayer.lineWidth = 4
        // Ensure the inside of the circle is transparent.
        circleLayer.fillColor = UIColor.clear.cgColor

        // 4. Add the CAShapeLayer to the view's layer as a sublayer.
        layer.addSublayer(circleLayer)
    }

    // This method is called whenever the view's bounds change.
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // 2. Create the path and assign it to the layer.
        // We do this here to ensure the path is updated if the view is resized.
        let circularPath = UIBezierPath(ovalIn: bounds.insetBy(dx: 2, dy: 2))
        circleLayer.path = circularPath.cgPath
        
        // Ensure the layer's frame matches the view's bounds.
        circleLayer.frame = bounds
    }
}

Let's break down the crucial differences:

  • No draw(rect:): The most important change is that we have completely removed the override func draw(_ rect: CGRect). The view no longer does any custom CPU drawing.
  • Declarative Properties: Instead of imperative drawing commands like setStroke() and stroke(), we declaratively set properties on the circleLayer (e.g., strokeColor, lineWidth). This tells Core Animation what to draw, and it figures out how to do it efficiently on the GPU.
  • layoutSubviews(): draw(rect:) is automatically called by the system when the view's geometry changes. Since we've removed it, we need a new place to update our circle's path when the view resizes. layoutSubviews() is the correct place for this. It's called whenever the view's bounds or frame changes, making it the perfect trigger to recalculate the path. This is a critical detail for creating robust, resizable components.
  • CGColor: CALayer properties expect CGColor instead of UIColor. You can always get the CGColor from a UIColor by accessing its .cgColor property.
CAShapeLayer Implementation and Animation in Xcode
This image shows a more complex example of what `CAShapeLayer` is capable of. The red progress ring is a `CAShapeLayer` whose `strokeEnd` property is being animated over time—a task that is highly efficient with layers but would be very costly with `draw(rect:)`.

By making this change, we have successfully offloaded the drawing work from the CPU to the GPU, eliminated a memory-hungry backing store, and created a component that is not only more performant but also ready to be animated efficiently.


Conclusion

This lesson concludes our module on UI and Rendering Performance. You've learned to identify and fix some of the most common and impactful rendering bottlenecks in iOS development.

Key Takeaways:

  • CAShapeLayer is a high-performance, GPU-accelerated alternative to CPU-bound draw(rect:) for drawing custom vector shapes.
  • Refactoring involves creating a CAShapeLayer, defining its shape via the path property, styling it with properties like strokeColor and fillColor, and adding it as a sublayer to your view.
  • Unlike draw(rect:), which is called automatically, you must manually update a CAShapeLayer's path when the view's geometry changes. The layoutSubviews() method is the standard place to perform this update.
  • By preferring CAShapeLayer over draw(rect:) for custom shapes, you write code that is more memory-efficient, performs better, and is easier to animate.

Preview of the Next Module:

We've now covered performance from the perspectives of memory management, CPU usage, and GPU rendering. In our next module, "Network Performance Optimization," we'll turn our attention to another critical area. Our first lesson will equip you with the foundational skill to profile network activity and inspect requests/responses using the Network instrument. This will provide the visibility you need to start diagnosing and solving network-related bottlenecks in your apps.