ios 렌더링

개요

실무를 하다보면 uiview나 일반적인 swiftui 뷰로 안될때가 있다.

CG나 CA를 이용하거나 하는데 알아보겠다.

일단 사전지식부터



벡터 vs 비트맵

  • 백터는 데이터가 아니라 명령을 가지고있음(여기서부터 저기까지 선을 그려라)
  • 비트맵은 정사각형의 픽셀을 기반으로 데이터를 가지고 있음(래스터라고도 명칭함)
  • 따라서 벡터를 기반으로 비트맵을 생성하는것을 래스터라이징이라고 함



Immediate Mode vs Retained Mode

GPU가 이해할 수 있는 수준(계속반복해야하면 대충 맞음)의 명령으로 표현 가능한가? 없으면 CPU 잇으면 GPU

CPU는 호텔 셰프, GPU는 신입 보조 셰프 + 웨이터

  • 즉시모드는 상태가 없는 그리기 방식.
  • 화면을 그려야할 때마다 시스템은 빈 캔버스를 개발자에게 줌
  • 개발자는 모든 그리기 명령어(원 그려, 선 그려, 텍스트 써)를 매번 처음부터 다시 실행함(CG)
  • 이 과정은 CPU가 통제함
  • UI가 매번 완전히 바뀌는 동적인 동작이나 복잡한 뷰에 유리 (커스텀뷰)
  • CPU가 비트맵을 만드는 비용은 비쌈 (코어수가 별로없음, CPU->GPU 전송)


  • 유지모드는 상태를 가지고 바뀐 부분만 업데이트하는 그리기 방식.
  • 시스템은 실행되고 있는 전체 객체 목록을 가지고 있음
  • 개발자가 뷰의 속성을 변경하면 프레임워크(CA)는 이 변경을 감지해서 시스템으로 보냄
  • 시스템(렌더서버)는 합성한 데이터를 GPU로 넘기고 GPU는 이를 렌더함
  • UI가 단순한 경우에도 시스템은 합성을 계속 해야됨
  • 그러나 렌더링 관련에서 GPU의 비용이 압도적으로 쌈 (코어 많음, 이부분은 엔비디아에서 설명한 GPU 세션 참고바람)
  • UIKit은 기본적으로 유지모드 시스템, SwiftUI는 더 진화된 유지모드 시스템



한줄요약

  • CALayer (엔진): 나는 비트맵을 움직이고, 합성할 수 있는 고성능 GPU 렌더링 엔진이다.
  • UIKit: 수동기어 차
    • 나는 저 CALayer 엔진을 가져다가, UIView라는 껍데기를 씌우고, 오토 레이아웃과 리스폰더 체인이라는 조작 시스템으로 동작하는것
    • 내가 몇단으로 갈 지 빠르게 갈지 어떻게 갈지를 모두 결정
  • SwiftUI: 테슬라
    • 나도 저 CALayer 엔진을 가져다가, 설계도(View)만 넣으면 알아서 굴러가는 상태 주도(State-Driven) 조작 시스템으로 동작하는것
    • 뷰는 네비게이션 같은 역할



UIView

UIView는 터치이벤트, 레이아웃 관리, UIKit 연동, 뷰컨과의 연동(뷰 생명주기) 위한 고수준 API


GPU 드로잉 (backgroundColor 등):

  • CPU: GPU야, ‘파란색 사각형(명령어)’ 하나만 그려줘.
  • GPU: OK, 재료 필요 없어. 내가 최종 화면에 직접 색칠할게(렌더링)


CPU 드로잉 (draw(rect:)):

  • CPU: 픽셀을 하나하나 계산해서 비트맵(재료)을 만들게.
  • GPU: OK, 그 재료 줘. 내가 최종 화면에 붙여넣을게(합성)


렌더링이란

  • 데이터를 시각적 이미지로 변환하는 모든 과정
  • CPU가 만드는것은 래스터화(rasterization), GPU에 넘길 재료
  • GPU가 만드는것은 합성(compositing), 최종 화면 비트맵


GPU 렌더링 흐름

  • UIView 속성 변경함
  • UIView는 상태변화를 CA로 통보
  • CA는 해당 레이어를 더티상태로 변경 (CATransaction에 큐해놓음)
  • 런루프 한 사이클이 끝나면 CA는 CATransaction을 commit
  • 변경사항이 렌더트리에 반영됨
  • CA는 이 렌더 트리를 렌더서버(시스템 프로세스)에 넘김
  • 렌더서버는 GPU 명령어 생성해서 넘김
  • GPU는 비트맵,벡터등으로 프레임 버퍼를 만들고 그림


CPU 렌더링 흐름

  • draw, setNeedsDisplay호출, bounds가 변경되어 기존 비트맵을 사용할 수 없을때, 이미지뷰
  • CA가 비트맵 버퍼 준비하고 CPU에서 CGContext생성
  • CG로 직접 픽셀 계산
  • 비트맵을 CALayer.contents에 설정하고 그 이후로는 같음


UIView / CALayer

  • UIView는 ‘무엇을’ 할지(이벤트 처리, 레이아웃)를 결정하고, CALayer는 ‘어떻게’ 보일지(시각적 표현, 애니메이션)
  • CALayer의 contents가 있으면 GPU는 캐싱되어있는 비트맵(순수 이미지, CGImage)을 받고 그걸 그대로 뿌려준다
  • contents가 없으면 GPU는 명령어를 받고 그걸 이용해 화면 비트맵을 만든다.


CG는 그리기를 함 그러나 CA는 그리기 안함

  • CAShapeLayer처럼 path(벡터)를 받아 GPU가 직접 그려주는 ‘특별한’ 레이어가 있긴함


draw(rect:) 호출 시점

  • 뷰가 처음 화면에 나타날 때
    • CA가 layer를 그리려고하는데 contents가 없음 -> 나의 델리게이트(UIView)한테 받아와야지
  • 뷰의 bounds가 변경될 때


이벤트 처리 흐름

  • UIWindow라는 루트를 가진 트리로 뷰 계층구조를 관리
  • UIView는 UIResponder를 상속받아 이벤트를 처리하는 객체임
  • 사용자가 화면을 터치하면 UIKit은 firstResponder를 찾아 이벤트 처리를 부탁
  • 하지않으면 루트까지 타고타고 감. 이 경로를 리스폰더 체인이라고 함


UIKit의 렌더링 과정

  • VSYNC
    • 흔히 말하는 프레임 단위.
    • 60Hz는 16.67ms 마다 업데이트를 진행함 1초당 60번 업데이트 한다는 소리.
    • 60Hz 기준으로는 16.67ms가 1 VSYNC
    • 16.67ms안에 이벤트 발생부터 렌더링까지 완료해야함 실패시 다음 프레임으로 넘어감
    • 프레임이 하나 밀리게 되는것을 hitch라고 말하며 쉽게 말하면 ‘버벅임’
    • Event, Commit / Render prepare, Render execute / Display 단계로 렌더링 과정이 진행됨
  • App(Event)
    • 주체: UIView, RunLoop / CPU
    • 앱에서 터치, 네트워크 응답등 화면 업데이트 이벤트가 발생하고 RunLoop가 감지 후 처리
    • 이벤트에 따라 layoutSubviews(), display(), draw(_:), CALayer의 변경 등의 호출이 예약됨
  • App(Commit)
    • 주체: UIView, CALayer / CPU
    • Event의 내용을 CALayer에 반영하고 Core Animation에게 알림
  • Render Prepare
    • 주체: Core Animation, CALayer / CPU -> GPU
    • CALayer의 정보를 기반으로 비트맵 변환 후 GPU에 전달
  • Render Execute
    • 주체: Metal, OpenGL / GPU
    • Core Animation → Metal → OpenGL/Metal → GPU 연산 단계로 진행
  • Display
    • 주체: Core Animation / GPU -> 하드웨어 화면
    • Core Animation이 VSYNC 신호에 맞춰 GPU 버퍼를 교체


주의할 점

  • SetNeedsLayout()은 frame 변경이나 addSubview등 레이아웃을 변경할 때 사용됨
    • GPU 기반 드로잉
    • 내부적으로 layoutSubviews()이 호출됨
    • layoutSubViews()를 직접 호출하지말고 setNeedsLayout()으로 시스템이 최적화된 알고리즘으로 실행하도록해야함
    • layoutIfNeeded()는 layoutSubViews()를 강제로 호출하는 메서드 (사용 지양)
    • layoutIfNeeded()는 런루프를 기다리지 않고 즉시, 동기적으로 업데이트 시킴
  • SetNeedsDisplay()은 draw(rect:) 메서드를 강제로 호출하고 싶을 때만 사용
    • CPU 기반 드로잉
    • 내부적으로 draw(_:)가 호출됨
  • 오프 스크린 렌더링 : GPU가 그리지 못하고 Off-screen buffer를 만든 뒤 그 결과물을 다시 복사해오는 과정
    • 부모에 cornerRadius + masksToBounds에서 주로 발생
    • 부모에 꽉채워 UIImageView를 넣으면 GPU는 자식과 부모를 그리는 동시에 자식을 부모의 cornerradius처럼 자식도 깎아야함
    • 이건 못함 따라서 버퍼에 부모와 자식을 원래대로 그리고 깎음 그 결과물을 다시 가져와서 그림
    • 원래 한 프레임에 끝날 렌더링을 버퍼로 갓다가 오는 과정으로 여러 프레임을 사용하고 이는 버벅임으로 이어짐
    • 메모리 사용, GPU VRAM으로 작업공간 전환등 공간, 시간적 오버헤드 발생
    • 해결책
      • 자식에 직접 cornerRadius를 주기
      • layer.shouldRasterize = true 주기 (첨에 만든 이미지 GPU에 캐싱해놔라~), 뷰가 잘 안바뀔때 씀


frame vs bounds

  • 부모기준 origin과 size (바깥에서 본 나)
  • 내 기준 origin과 내 진짜 size (나 자체)
  • 회전시켰을 때 frame은 (150, 150), bounds는 (100, 100)


AutoLayout LayoutPass

  • 런루프의 Commit단계에서 updateConstraints가 호출된다 (제약조건이 업데이트된다)
  • UIKit은 수집된 모든 제약조건을 Cassowary 알고리즘을 사용하여 Frame을 계산한다. (레이아웃 계산)
  • layoutSubviews가 호출되고 각 계산된 frame으로 세팅이 된다. (레이아웃 적용)
  • updateContraints는 비싼 작업으로 setNeedsLayout을 우선 사용하라
  • 뷰를 다룰때 constraint를 껐다 키는것보다 constant값을 변경하거나 isHidden을 활용하는것이 유리


뷰 레이아웃부터 화면 렌더링 과정

  • UIWindow 생성
  • window.rootViewController가 설정됨
  • viewDidLoad 호출 (자신의 UIView가 메모리에 로드, 이때 CALayer 생성)
  • 뷰 계층에 추가됨
  • viewWillAppear / viewDidAppear 호출
  • LayoutPass 실행
  • RenderPass 실행



SwiftUIView

  • 상태를 가지고 있지않은 화면이 어떻게 보여야 하는지를 설명하는 blueprint
  • @State가 바뀔 때 의존성 그래프를 사용해서 최소한의 변경만 렌더 트리에 적용


레이아웃 방식

  • 부모가 자식에게 크기를 제안 (자식은 GeometryReader로 알수있음)
  • 자식은 그 크기 안에서 제안
  • 부모는 자식이 정한 크기를 존중하여 배치


렌더링 방식

  • @State가 변경됨
  • AttributeGraph는 @State에 의존하는 View의 body를 다시실행하여 blueprint를 받음
  • 뷰마다 ID를 가지고 있으며 이전 설계도와 현재 설계도를 비교하여 Diff함
  • 바뀐 부분만 식별하여 실제 렌더 트리의 CALayer의 속성을 변경함


CPU 드로잉

  • Canvas 뷰를 사용
    • UIKit의 draw(rect)와 동일하게 즉시모드로 동작
    • GraphicsContext(CGContext의 Swift래퍼)를 사용하여 CPU가 재료 비트맵을 그림


GPU 드로잉

  • Text, Rectangle등 SwiftUI의 뷰와 수정자
    • SwiftUI가 CALayer의 속성으로 변경함


최적화 팁

  • hitTest == .contentShape
  • shouldRasterize == drawingGroup
  • .id
    • SwiftUI는 매번 새로 생성되는 Struct로 UIView처럼 메모리주소로 뷰를 구별하지 못함
    • SwiftUI는 body의 구조를 보고 ID를 판단함, 따라서 if문으로 구조가 바뀌면 새로그림
    • 그러나 다른부분은 같은데 처음부터 그리는것은 불필요함 따라서 id를 이용하여 해당 부분은 같은 것임을 명시해줘서 그부분만 다시 렌더링가능


상태 관리 - 뷰가 직접 소유하는 상태

  • @State : 단순한 값을 저장할 때 사용
  • @StateObject : @State의 클래스 버전, 뷰가 뷰모델과같은 복잡한 상태를 직접 생성하고 소유할 때 사용
  • 객체를 @State로 소유하면 안됨
    • @State는 값을 저장하도록 설계되었고 @StateObject는 참조를 저장하도록 설계됨
    • @State는 참조값을 저장했을 때 그 안에서 내부의 변화를 감지할 수 없음, 그러나 @StateObject는 감지 가능
    • @StateObject는 단 한 번만 초기화를 보장

상태 관리 - 남의 상태를 연결하는 상태

  • @Binding : 부모의 @State를 연결받는 two way 통로
  • @ObservedObject : 부모의 @StateObject를 연결받는 two way 통로
  • @Published : 상태 관리를 하고싶은 ObservableObject의 프로퍼티에 사용

상태 관리 - 환경에서 받는 상태

  • @EnvironmentObject : 커스텀 전역 상태
  • @Environment : 시스템 전역 상태

Bindable, Observable (iOS 17 +)

  • ObservableObject, @Published, @StateObject, @ObservedObject를 대체하는 매크로
  • Observable : ObservableObject 프로토콜을 따르고, @Published를 일일이 붙일 필요가 없게 해줌
  • Bindable : Observable 매크로로 만든 객체를 자식에게 바인딩 가능한 상태로 넘겨줄 수 있음

기타 래퍼

  • @AppStorage: UserDefaults에 값을 저장하고 동기화
  • @SceneStorage: 앱이 백그라운드로 갈 때 화면의 상태를 임시 저장
  • @FocusState: 키보드 포커스(예: 어떤 TextField가 활성화되었는지)를 관리
  • @GestureState: 제스처가 ‘진행 중’일 때만 상태를 임시로 저장



왜 UI는 메인스레드에서 업데이트 되어야하는가

  • UIKit, SwiftUI는 Not Thread-Safe함
  • Not Thread-Safe로 설계 되었다는 것은 여러곳에서 수정하는 것에 대한 잠금장치가 없다는것
  • 이는 Race Condition을 만들 수 있고 예측 불가능하게됨
  • 따라서 모든 UI 수정은 메인스레드에서 순서대로 처리한다라는 서로간의 약속을 정함
  • 왜 Thread-Safe하게 하지 않았나
    • Lock을 하면 다음 변경사항은 끝날때까지 계속 기다려야됨
    • 데드락 발생 다수. 스레드 A가 버튼을 잡고있는데 다음 작업이 라벨이고 스레드 B가 라벨을 잡고있는데 다음 작업이 버튼일때 무한 기다림