Search code examples
iosswiftuikitcore-graphics

Can't get good performance with Draw(_ rect: CGRect)


I have a Timer() that asks the view to refresh every x seconds:

func updateTimer(_ stopwatch: Stopwatch) {
    stopwatch.counter = stopwatch.counter + 0.035


    Stopwatch.circlePercentage += 0.035
    stopwatchViewOutlet.setNeedsDisplay() 
}

--

class StopwatchView: UIView, UIGestureRecognizerDelegate {

let buttonClick = UITapGestureRecognizer()

    override func draw(_ rect: CGRect) {

    Stopwatch.drawStopwatchFor(view: self, gestureRecognizers: buttonClick)

    }
}

--

extension Stopwatch {

static func drawStopwatchFor(view: UIView, gestureRecognizers: UITapGestureRecognizer) {

    let scale: CGFloat = 0.8
    let joltButtonView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: 75, height: 75))
    let imageView: UIImageView!

    // -- Need to hook this to the actual timer --
    let temporaryVariableForTimeIncrement = CGFloat(circlePercentage)

    // Exterior of sphere:
    let timerRadius = min(view.bounds.size.width, view.bounds.size.height) / 2 * scale
    let timerCenter = CGPoint(x: view.bounds.midX, y: view.bounds.midY)
    let path = UIBezierPath(arcCenter: timerCenter, radius: timerRadius, startAngle: 0, endAngle: 2 * CGFloat.pi, clockwise: true)

    path.lineWidth = 2.0

    UIColor.blue.setStroke()
    path.stroke()

    // Interior of sphere
    let startAngle = -CGFloat.pi / 2
    let arc = CGFloat.pi * 2 * temporaryVariableForTimeIncrement / 100

    let cPath = UIBezierPath()
    cPath.move(to: timerCenter)
    cPath.addLine(to: CGPoint(x: timerCenter.x + timerRadius * cos(startAngle), y: timerCenter.y))
    cPath.addArc(withCenter: timerCenter, radius: timerRadius * CGFloat(0.99), startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
    cPath.addLine(to: CGPoint(x: timerCenter.x, y: timerCenter.y))

    let circleShape = CAShapeLayer()
    circleShape.path = cPath.cgPath
    circleShape.fillColor = UIColor.cyan.cgColor
    view.layer.addSublayer(circleShape)

    // Jolt button
    joltButtonView.center = CGPoint(x: view.bounds.midX, y: view.bounds.midY)
    joltButtonView.layer.cornerRadius = 38

    joltButtonView.backgroundColor = UIColor.white

    imageView = UIImageView(frame: joltButtonView.frame)
    imageView.contentMode = .scaleAspectFit
    imageView.image = image

    joltButtonView.addGestureRecognizer(gestureRecognizers)


    view.addSubview(joltButtonView)
    view.addSubview(imageView)


    }
}

My first problem is that the subviews are getting redrawn each time. The second one is that after a couple seconds, the performance starts to deteriorate really fast.

Too many instances of the subview

Trying to drive the blue graphic with a Timer()

Should I try to animate the circle percentage graphic instead of redrawing it each time the timer function is called?


Solution

  • The major observation is that you should not be adding subviews in draw. That is intended for rendering a single frame and you shouldn't be adding/removing things from the view hierarchy inside draw. Because you're adding subviews roughly ever 0.035 seconds, your view hierarchy is going to explode, with adverse memory and performance impact.

    You should either have a draw method that merely calls stroke on your updated UIBezierPath for the seconds hand. Or, alternatively, if using CAShapeLayer, just update its path (and no draw method is needed at all).

    So, a couple of peripheral observations:

    1. You are incrementing your "percentage" for every tick of your timer. But you are not guaranteed the frequency with which your timer will be called. So, instead of incrementing some "percentage", you should save the start time when you start the timer, and then upon every tick of the timer, you should recalculate the amount of time elapsed between then and now when figuring out how much time has elapsed.

    2. You are using a Timer which is good for most purposes, but for the sake of optimal drawing, you really should use a CADisplayLink, which is optimally timed to allow you to do whatever you need before the next refresh of the screen.

    So, here is an example of a simple clock face with a sweeping second hand:

    open class StopWatchView: UIView {
    
        let faceLineWidth: CGFloat = 5                                          // LineWidth of the face
        private var startTime:  Date?         { didSet { self.updateHand() } }  // When was the stopwatch resumed
        private var oldElapsed: TimeInterval? { didSet { self.updateHand() } }  // How much time when stopwatch paused
    
        public var elapsed: TimeInterval {                                      // How much total time on stopwatch
            guard let startTime = startTime else { return oldElapsed ?? 0 }
    
            return Date().timeIntervalSince(startTime) + (oldElapsed ?? 0)
        }
    
        private weak var displayLink: CADisplayLink?                            // Display link animating second hand
        public var isRunning: Bool { return displayLink != nil }                // Is the timer running?
    
        private var clockCenter: CGPoint {                                      // Center of the clock face
            return CGPoint(x: bounds.midX, y: bounds.midY)
        }
    
        private var radius: CGFloat {                                           // Radius of the clock face
            return (min(bounds.width, bounds.height) - faceLineWidth) / 2
        }
    
        private lazy var face: CAShapeLayer = {                                 // Shape layer for clock face
            let shapeLayer = CAShapeLayer()
            shapeLayer.lineWidth   = self.faceLineWidth
            shapeLayer.strokeColor = #colorLiteral(red: 0.1215686277, green: 0.01176470611, blue: 0.4235294163, alpha: 1).cgColor
            shapeLayer.fillColor   = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0).cgColor
            return shapeLayer
        }()
    
        private lazy var hand: CAShapeLayer = {                                 // Shape layer for second hand
            let shapeLayer = CAShapeLayer()
            shapeLayer.lineWidth = 3
            shapeLayer.strokeColor = #colorLiteral(red: 0.2196078449, green: 0.007843137719, blue: 0.8549019694, alpha: 1).cgColor
            shapeLayer.fillColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 0).cgColor
            return shapeLayer
        }()
    
        override public init(frame: CGRect = .zero) {
            super.init(frame: frame)
    
            configure()
        }
    
        required public init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
    
            configure()
        }
    
        // add necessary shape layers to layer hierarchy
    
        private func configure() {
            layer.addSublayer(face)
            layer.addSublayer(hand)
        }
    
        // if laying out subviews, make sure to resize face and update hand
    
        override open func layoutSubviews() {
            super.layoutSubviews()
    
            updateFace()
            updateHand()
        }
    
        // stop display link when view disappears
        //
        // this prevents display link from keeping strong reference to view after view is removed
    
        override open func willMove(toSuperview newSuperview: UIView?) {
            if newSuperview == nil {
                pause()
            }
        }
    
        // MARK: - DisplayLink routines
    
        /// Start display link
    
        open func resume() {
            // cancel any existing display link, if any
    
            pause()
    
            // start new display link
    
            startTime = Date()
            let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
            displayLink.add(to: .main, forMode: .commonModes)
    
            // save reference to it
    
            self.displayLink = displayLink
        }
    
        /// Stop display link
    
        open func pause() {
            displayLink?.invalidate()
    
            // calculate floating point number of seconds
    
            oldElapsed = elapsed
            startTime = nil
        }
    
        open func reset() {
            pause()
    
            oldElapsed = nil
        }
    
        /// Display link handler
        ///
        /// Will update path of second hand.
    
        @objc func handleDisplayLink(_ displayLink: CADisplayLink) {
            updateHand()
        }
    
        /// Update path of clock face
    
        private func updateFace() {
            face.path = UIBezierPath(arcCenter: clockCenter, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true).cgPath
        }
    
        /// Update path of second hand
    
        private func updateHand() {
            // convert seconds to an angle (in radians) and figure out end point of seconds hand on the basis of that
    
            let angle = CGFloat(elapsed / 60 * 2 * .pi - .pi / 2)
            let endPoint = CGPoint(x: clockCenter.x + cos(angle) * radius * 0.9, y: clockCenter.y + sin(angle) * radius * 0.9)
    
            // update path of hand
    
            let path = UIBezierPath()
            path.move(to: clockCenter)
            path.addLine(to: endPoint)
            hand.path = path.cgPath
        }
    }
    

    And

    class ViewController: UIViewController {
    
        @IBOutlet weak var stopWatchView: StopWatchView!
    
        @IBAction func didTapStartStopButton () {
            if stopWatchView.isRunning {
                stopWatchView.pause()
            } else {
                stopWatchView.resume()
            }
        }
    
        @IBAction func didTapResetButton () {
            stopWatchView.reset()
        }
    
    }
    

    Now, in the above example, I'm just updating the CAShapeLayer of the seconds hand in my CADisplayLink handler. Like I said at the start, you can alternatively have a draw method that simply strokes the paths you need for single frame of animation and then call setNeedsDisplay in your display link handler. But if you do that, don't change the view hierarchy within draw, but rather do any configuration you need in init and draw should just stroke whatever the path should be at that moment.