iOS

Drawing Performance 를 이해하자!

iOS_Assin 2019. 10. 7. 18:56

 

High performance drawing on iOS의 내용을 정리한 포스트입니다.

 

Drawing 의 Performance 는 4가지 요소가 있습니다.

 

1. Low performance CPU-based drawing

2. High performance CPU-based drawing

3. Sublayer GPU-based drawing

4. Draw(layer:ctx:) GPU-based

 

Low performance CPU-based drawing

 

let renderer = UIGraphicsImageRenderer(size: bounds.size)
image = renderer.image { ctx in
 image?.draw(in: bounds)
 lineColor.setStroke() // any color
 ctx.cgContext.setLineCap(.round)
 ctx.cgContext.setLineWidth(lineWidth) // any width
 ctx.cgContext.move(to: previousTouchPosition)
 ctx.cgContext.addLine(to: newTouchPosition)
 ctx.cgContext.strokePath()
}

 

IPhone 6S : 60 프레임

11 인치 iPad Pro : 평균 17 프레임

 

11 인치 iPad Pro 에서 프레임이 확 떨어졌을까요?

1. 과도한 CPU 사용

 

image = renderer.image { ctx in ... } // 1
image?.draw(in: bounds) // 2

 

 

 

 

UIGraphicsImageRenderer image()

 

Image (actions :) 메소드를 사용하여 이미지 렌더러로 이미지 (UIImage 객체)를 만듭니다. 이 방법은 그리기 동작을 나타내는 클로저를 사용합니다. 이 클로저 내에서 렌더러는 렌더러가 초기화 될 때 제공된 매개 변수를 사용하여 코어 그래픽 컨텍스트를 작성하고이 코어 그래픽 컨텍스트를 현재 컨텍스트로 설정합니다

 

즉 이미지 렌더러로 UIImage 객체를 만듭니다. 

이 작업은 CPU를 많이 사용하는 작업입니다.

 

UIImage draw(in:)

 

지정된 사각형으로 전체 이미지를 그립니다.

이 작업도 CPU 를 많이 사용합니다.

 

2. 디바이스 성능 차이

그렇타고 하더라도 왜 iPhone 6S 는 60fps 이며 IPad Pro 는 17 fps 으로 비교가 되나요?

11 인치 iPad Pro의 해상도는 2388x1668 4 백만 화소

iPhone 6s의 해상도는 1334x750, 1 백만 화소입니다.


IPad Pro의 디스플레이는 120hz

iPhone의 크기는 60hz입니다.

즉, touchesMoved는 iPad에서 두 번 자주 호출됩니다.

 

 

전체적으로 CPU 수요의 최대 8배 증가

 

iPad Pro의 A12X CPU는 확실히 iPhone 6s A9보다 훨씬 발전했습니다.

불행히도 CPU 기반 드로잉은 단일 스레드 작업입니다!

 

단일 스레드 성능을 비교하면 Geekbench에 따르면 A12X는 A9보다 두 배 빠릅니다.

 

 

High performance CPU-based drawing

 

lines 의 Point 들을 저장합니다.

touchesMoved 에서 setNeedsDisplay() 를 호출합니다. (다음 drawing cycle 에 View업데이트하도록 요청)

 

class FreedrawingView: UIView {
    
    // we use a multi-dimensional array to store separate lines, otherwise 
    // all your lines will be connected
    var lines = [[CGPoint]]()
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // create a new line
        lines.append([CGPoint]())
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let newTouchPoint = touches.first?.location(in: self) else { return }
        
        // use `indices` to safetly modify the last element
        if let lastIndex = lines.indices.last {
            lines[lastIndex].append(newTouchPoint)
        }
        
        setNeedsDisplay()
    }
    
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        
        context.setStrokeColor(UIColor.red.cgColor)
        context.setLineWidth(5)
        context.setLineCap(.round)
        
        lines.forEach { (line) in
            for (index, point) in line.enumerated() {
                if index == 0 {
                    context.move(to: point)
                } else {
                    context.addLine(to: point)
                }
            }
        }
        context.strokePath()
    }
}

 

어떻게 됬나요?

iPad 120 FPS부터 시작

CPU 사용률  60% 정도 높은 수준

 

하지만 시간이 지나면 100% 에 도달하고 속도가 10fps 떨어집니다.

 

 

조금 더 개선해보자!

setNeedsDisplay() -> setNeedsDisplay(_rect :)

 

setNeedsDisplay()

수신자의 전체 경계 사각형을 다시 그려야한다고 표시합니다.

 

setNeedsDisplay(_rect :) 를 살펴보겠습니다.

수신자의 지정된 사각형을 다시 그려야 할 것으로 표시합니다.

 

✔️UIKit 및 Core Graphics와 같은 기본 드로잉 기술을 사용하여 컨텐츠를 렌더링하는 뷰에만 사용하도록 설계되었습니다.

 

CPU 사용률을 25% 까지 줄였습니다!

 

Sublayer GPU-based drawing

부모의 CALayer 를 만들고 CAShapeLayer, UIBezierPath 사용하여 Image 를 만듭니다. 

Image 를 업데이트 하는 방법은 부모의 CALayer.addSubLayer() 입니다.

 

이전의 UIImage 를 저장할 수 있는 객체를 생성합니다.

 

부모의 CALayer.sublayer.count 가 400개 초과하면 flattenToImage() 실행합니다.

 

flattenToImage()

CALayer.render(in:)  

레이어와 해당 하위 레이어를 지정된 컨텍스트로 렌더링합니다.

 

UIImage draw(in:)

지정된 사각형으로 전체 이미지를 그립니다.

 

UIGraphicsGetImageFromCurrentImageContext 에서 현재 Context 의 Image 를 가져온 결과를 UIImage 객체에 저장합니다. 

 

마지막으로 부모 CALayer 를 메모리에서 삭제합니다.

 

  var currentTouchPosition: CGPoint?

  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
      guard let newTouchPoint = touches.first?.location(in: self) else { return }
      currentTouchPosition = newTouchPoint
  }
  
  
  override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
      guard let newTouchPoint = touches.first?.location(in: self) else { return }
      guard let previousTouchPoint = currentTouchPosition else { return }
      drawBezier(from: previousTouchPoint, to: newTouchPoint)
      currentTouchPosition = newTouchPoint
  }
  
  
  func drawBezier(from start: CGPoint, to end: CGPoint) {
      setupDrawingLayerIfNeeded()
      let line = CAShapeLayer() 
      let linePath = UIBezierPath()
      line.contentsScale = UIScreen.main.scale
      linePath.move(to: start)
      linePath.addLine(to: end)
      line.path = linePath.cgPath
      line.fillColor = lineColor.cgColor
      line.opacity = 1
      line.lineWidth = lineWidth
      line.lineCap = .round
      line.strokeColor = lineColor.cgColor

      drawingLayer?.addSublayer(line)
      
      if let count = drawingLayer?.sublayers?.count, count > 400 {
          flattenToImage()
      }
  }
  
    func setupDrawingLayerIfNeeded() {
      guard drawingLayer == nil else { return }
      let sublayer = CALayer()
      sublayer.contentsScale = UIScreen.main.scale
      layer.addSublayer(sublayer)
      self.drawingLayer = sublayer
  }
  
    func flattenToImage() {
      UIGraphicsBeginImageContextWithOptions(bounds.size, false, Display.scale)
      if let context = UIGraphicsGetCurrentContext() {

          // keep old drawings
          if let image = self.image {
              image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
          }

          // add new drawings
          drawingLayer?.render(in: context)

          let output = UIGraphicsGetImageFromCurrentImageContext()
          self.image = output
      }
      clearSublayers()
      UIGraphicsEndImageContext()
  }

 

CPU 사용률을 15% 까지 줄였습니다!

 

 

Draw(layer:ctx:) GPU-based

optional func draw(_ layer: CALayer, in ctx: CGContext) 를 재정의하는 것입니다.

(레이어의 CGContext를 사용하여 표시 프로세스를 구현하도록 대리인에게 지시합니다.)

 

touchsMoved() Line의 Point들을 저장하고 CALayer.setNeedsDisplay(_ rect:) 을 호출합니다.

 

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let newTouchPoint = touches.first?.location(in: self) else { return }

    line.append(newTouchPoint)
    let lastTouchPoint: CGPoint = line.last ?? .zero
    let rect = calculateRectBetween(lastPoint: lastTouchPoint, newPoint: newTouchPoint)
    layer.setNeedsDisplay(rect)
}

  override func draw(_ layer: CALayer, in ctx: CGContext) {
      let drawingLayer = self.drawingLayer ?? CAShapeLayer()
      drawingLayer.contentsScale = UIScreen.main.scale
      let linePath = UIBezierPath()
      for (index, point) in line.enumerated() {
          if index == 0 {
              linePath.move(to: point)
          } else {
              linePath.addLine(to: point)
          }
      }

      drawingLayer.path = linePath.cgPath
      drawingLayer.opacity = 1
      drawingLayer.lineWidth = lineWidth
      drawingLayer.lineCap = .round
      drawingLayer.fillColor = UIColor.clear.cgColor
      drawingLayer.strokeColor = lineColor.cgColor
      
      if self.drawingLayer == nil {
          self.drawingLayer = drawingLayer
          layer.addSublayer(drawingLayer)
      }
  }

 

25개의 Line 을 넘어가면 현재 상의 Image 를 최종적으로 업데이트하고 Line 저장공간을 메모리에서 삭제합니다.

 

var line = [CGPoint]() {
    didSet { checkIfTooManyPoints() }
}

 func checkIfTooManyPoints() {
      let maxPoints = 25
      if line.count > maxPoints {
          updateFlattenedLayer()
          // we leave two points to ensure no gaps or sharp angles
          _ = line.removeFirst(maxPoints - 2)
      }
  }
  

 

 

 

  func updateFlattenedLayer() {
    // 1
    guard let drawingLayer = drawingLayer,
        // 2
        let optionalDrawing = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(
            NSKeyedArchiver.archivedData(withRootObject: drawingLayer, requiringSecureCoding: false))
            as? CAShapeLayer,
        // 3
        let newDrawing = optionalDrawing else { return }
    // 4
    self.layer.addSublayer(newDrawing)
}

func emptyFlattenedLayers() {
      guard let sublayers = self.layer.sublayers else { return }
      for case let layer as CAShapeLayer in sublayers {
          layer.removeFromSuperlayer()
      }
  }

 

UIView를 복사하는 것과 같이 intuitive process가 아닌 CALayer를 복사하려고 하기 때문입니다.

이런 식으로 레이어를 복사하면 CGContext를 사용하기위한 요구 사항을 무시할 수 있습니다.

따라서 더 이상 기존 레이어를 컨텍스트로 렌더링 한 다음 컨텍스트에서 비트 맵 이미지를 생성 할 필요가 없습니다.

이를 통해 많은 CPU 사용이 적어집니다.

 

1. drawingLayer 가 nil 아님을 체크합니다. 

2. 해당 drawingLayer의 레이어를 데이터 객체로 인코딩 한 다음 해당 객체를 새로운 레이어 (복사본)로 디코딩합니다.

 

NSKeyedArchiver.archivedData

주어진 루트 객체에 의해 형성된 객체 그래프의 인코딩 된 형태를 포함하는 데이터 객체를 반환합니다.

 

NSKeyedUnarchiver.unarchiveTopLevelObjectWithData 

이전에 보관 된 객체 그래프를 디코딩하고 루트 객체를 반환합니다.

 

3. 객체 그래프에서 디코딩한 객체가 nil 아님을 체크합니다.

 

4. 위 과정에서 디코딩되었던 새로운 Layer 를 추가합니다.

 

 

CPU 사용률을 11% 까지 줄였습니다!

 

전체소스는 여기에서 확인할 수 있습니다.

 

 

참고: 

https://github.com/almaleh/Drawing-Performance

https://medium.com/@almalehdev/high-performance-drawing-on-ios-part-2-2cb2bc957f6