[번역] iOS 레이아웃의 미스터리를 파헤치다

MJ Studio
MJ Studio
Published in
17 min readDec 18, 2019

우리가 처음으로 iOS 애플리케이션을 만들 때, 어쩔 수 없이 맞닥뜨리는 문제는 ViewLayoutContent를 다루는 것입니다. 종종, 이 문제들은 우리가 실제로 UIView가 언제 Update 되는지 잘못 이해하고 있기 때문에 발생합니다. 뷰가 언제 Update 되는지 정확하게 이해하기 위해서는 iOS 애플리케이션의 run loop에 대한 깊은 이해와 그것이 UIView가 제공하는 몇몇의 메서드들과 어떤 관계성을 띄고 있는지 파악하는 것이 중요합니다. 이 포스팅은 이러한 상호작용들과 우리가 원하는 방식으로 뷰를 작동하게 만들기 위해 UIView의 메서드들을 어떻게 사용하여야 할지 다룹니다.

iOS 앱의 Main run loop

iOS 애플리케이션의 main run loop는 유저로부터의 모든 input 이벤트를 받고, 적절한 응답을 해주는 것을 담당합니다. 유저가 발생시킨 모든 상호작용은 event queue에 추가됩니다. 아래의 사진에서 보이는 애플리케이션 객체(Application object)event queue로부터 이벤트를 하나씩 꺼내서 애플리케이션의 다른 객체들에게 전달합니다. 본질적으로, 애플리케이션 객체는 유저로부터의 input 이벤트를 해석하고 그에 상응되는 애플리케이션의 Core object들 안에 있는 핸들러를 호출해줍니다. 이러한 핸들러들은 우리, 개발자들이 쓴 코드를 호출해줍니다. 이러한 메서드들이 반환되면 다시 컨트롤은 main run loop로 돌아가서 Update Cycle이 다시 시작됩니다. Update Cycle은 View 들을 배치하고 다시 그리는 역할을 합니다(다음 섹션에서 설명합니다). 다음은 애플리케이션이 기기와 유저로부터의 input과 상호작용하는지 설명하는 그림입니다.

https://developer.apple.com/library/content/documentation/General/Conceptual/Devpedia-CocoaApp/MainEventLoop.html

Update Cycle

Update Cycle은 애플리케이션이 유저로부터의 모든 이벤트 핸들링 코드를 수행하고 다시 main run loop로 컨트롤을 반환하는 지점입니다. 바로 이 지점에서 시스템은 우리의 View들을 배치하고(layout), 보여주고(display) 제약합니다(constraints). 만약 우리가 이벤트 핸들러들을 처리하는 과정에서 어떤 UIView에 대해 변화를 준다면, 이 UIView는 다시 그려져야(redraw) 한다고 표시됩니다. 다음 Update Cycle에서, 시스템은 이 UIView의 모든 변화를 수행합니다. 유저가 상호작용하는 것과 레이아웃이 변하는 시간의 갭은 유저가 인지하지 못할 정도여야 합니다. iOS 애플리케이션은 초당 60프레임을 보여주기 때문에, 한 번의 Update Cycle은 1/60초 밖에 안 걸립니다. 이렇게 빠르게 업데이트가 되기 때문에, 유저는 UI와 상호작용 간의 차이를 느끼지 못합니다. 그러나, 이벤트가 처리되는 시점과 실제로 View가 다시 그려지는 시점에 차이가 있기 때문에, View는 우리가 View를 업데이트 하기를 원하는 run loop의 특정 시점에 업데이트가 되지 않을 수 있습니다. 이는 다음과 같은 위험을 초래합니다. 만약 우리가 View의 마지막 Layout이나 Content에 대해 계산을 해야 하는 시점이라면, 예전 정보를 갖고 View를 조작할 수 있는 가능성이 생깁니다. run loopupdate cycle 그리고 몇 개의 UIView의 메서드들을 이해하는 것은 위와 같은 문제들을 쉽게 피하고 원인을 분석하게 도와줄 것입니다.
다음은 main run loop가 한 바퀴 돌 때, Update Cycle이 언제 발생하는지를 표현한 그림입니다.

Layout

UIViewLayout 이란 것은, 화면에서 UIView의 크기와 위치를 의미합니다. 모든 View는 frame을 갖고 있고, 이는 부모 뷰의 Coordinate System(좌표계)에서 어디에 위치하고 얼마나 크기를 차지하는지를 나타냅니다. UIView는 시스템에게 UIView의 레이아웃이 변했다고 알려줄 수 있는 메서드나, View의 레이아웃이 다시 계산되는 시점에 특정한 작업을 실행할 수 있게 오버라이딩할 수 있는 콜백 메서드도 제공합니다.

layoutSubviews()

layoutSubviews라는 UIView의 메서드는 View와 자식 View들의 위치와 크기를 재조정합니다. 이는 현재 뷰와 모든 자식 뷰의 위치와 크기를 제공합니다. 이 메서드는 재귀적으로 모든 자식 뷰의 layoutSubviews까지 호출해야 하기 때문에 실행 시에 부하가 큰 메서드입니다. 시스템은 layoutSubviews를 뷰의 frame을 다시 계산해야 할 때 호출하기 때문에 우리는 layoutSubviews를 오버라이딩해서 frame이나 특정한 위치와 크기를 조절할 수 있습니다. 그러나 레이아웃을 업데이트해야 할 때 layoutSubviews직접 호출하는 것은 금지되어 있습니다. 대신에, layoutSubviews를 시스템이 호출하도록 유도할 수 있는 여러 개의 방식들이 존재합니다. 이러한 방식들은 모두 run loop가 돌아가는 동안 layoutSubviews가 실행되는 시점이 다르며, 직접 layoutSubviews를 호출하는 것보다는 부하가 덜합니다.

layoutSubviews가 완료될 때, viewDidLayoutSubviewsView를 소유한 ViewController에서 호출됩니다. layoutSubviewsViewlayout 이 변화했다는 유일한 콜백이기 때문에, 우리는 레이아웃의 크기나 위치와 연관된 로직을 viewDidLoadviewDidAppear가 아닌, viewDidLayoutSubviews에 호출해야 합니다. 이것이 오래된 레이아웃이나 위치 변수를 다른 계산에 사용하는 실수를 막는 유일한 방법입니다.

Automatic refresh triggers

다음과 같은 이벤트들은 자동으로 View가 그것들의 layout에 변화가 생겼다고 표시를 해주어서 개발자가 직접 요청할 필요 없이, layoutSubviews가 다음 기회에 호출이 되게 해줍니다.

  • View를 Resizing
  • SubView를 추가
  • UIScrollView를 스크롤할 때, UIScrollView와 그것의 부모뷰에 layoutSubviews가 호출
  • Device를 회전(orientation change)
  • ViewConstraint를 변경

위의 방법들은 자동으로 시스템이 View의 위치가 변했고, 다시 계산되도록 하여 결국엔 layoutSubviews가 호출되게 해줍니다. 그러나 layoutSubviews를 직접 호출해줄 수 있는 방법들도 존재합니다.

setNeedsLayout

layoutSubviews를 가장 적은 부하로 호출할 수 있는 메서드는 UIViewsetNeedsLayout 메서드입니다. setNeedsLayouts은 시스템에게 이 Viewlayout이 다시 계산되어야 한다고 알려줍니다. setNeedsLayout은 즉시 반환되고, 실제로 View를 업데이트해주는 것은 아닙니다. 대신에, 시스템이 다음 Update Cycle에서 layoutSubviews를 View와 자식 View들에게 호출하게 하고 그 시점에 setNeedsLayout이 호출된 부들은 Update Cycle에서 업데이트가 되도록 해줍니다. 이는 시각적으로 아무런 영향을 끼치지 않습니다. setNeedsLayout이 호출되는 시점과 View가 다시 그려지는 시점이 정확히 일치하지는 않지만, 그걸 유저가 인지할 수 없을 만큼 짧은 시간이기 때문입니다.

layoutIfNeeded

layoutIfNeededUIViewlayoutSubviews를 호출하도록 하는 또 다른 명시적인 메서드입니다. layoutSubviews가 다음 Update Cycle에서 호출되도록 하는 것이 아니라 layoutIfNeeded는 만약 Viewlayout이 재조정되어야 한다면, 즉시 layoutSubviews를 호출해버립니다. 만약 우리가 layoutIfNeededsetNeedsLayout를 호출한 직후나 자동으로 layoutSubviews를 호출해주는 방법들 직후에 호출했다면, layoutSubviews는 뷰에 즉시 호출됩니다. 그러나 우리가 layoutIfNeeded를 호출했는데 View가 재조정되어야 하는 이유가 없다면, layoutSubviews는 호출되지 않습니다. 만약 우리가 동일한 run loop에서 레이아웃의 업데이트 없이 layoutIfNeeded를 두 번 호출했다면, 두 번째 호출은 layoutSubviews를 발생시키지 않습니다.

layoutIfNeeded를 사용한다면, 레이아웃을 하는 것과 자식 View들을 다시 그리는 것은 즉시 실행되고 이 메서드가 반환되기 전에 실행됩니다(애니메이션 상황을 제외하고). setNeedsLayout 과는 다르게, 이 메서드는 다음 Update Cycle까지 뷰의 변화를 기다릴 수 없는 상황에 유용합니다. 그러나, 이러한 상황이 아니라면 그냥 setNeedsLayout을 호출해서 다음 Update Cycle에 뷰가 업데이트되어 run loop 한번 당 View업데이트가 한 번만 이루어지게 하는 것이 이상적입니다.

layoutIfNeededConstraints애니메이션 하는 상황에서 특히 유용합니다. 우리는 애니메이션이 시작하기 전에 layoutIfNeeded를 호출해서 모든 레이아웃 업데이트가 애니메이션 전에 수행되도록 전파할 수 있습니다. 새로운 Constraints를 설정하고, 애니메이션 클로저 안에서는 또 layoutIfNeeded를 호출해서 애니메이션이 올바른 상태로 진행되도록 할 수 있습니다.

Display

Layout 이란 것이 뷰의 위치와 크기를 의미한다면, Display뷰의 속성들 중 크기와 위치나 뷰의 자식 View들에 대한 정보를 갖지 않는 속성들을 포함합니다. 예를 들어, 색, 텍스트, 이미지, Core Graphics 그리기 등이 있습니다. DisplayLayout 과정과 유사한데, 시스템이 자동으로 업데이트가 되게 하는 방식과 우리가 명시적으로 업데이트를 해주게 하는 방식(메서드들)이 존재합니다.

draw(_:)

UIViewdraw 메서드는 Layout 업데이트 과정에서의 layoutSubviews와 같은 역할을 합니다. 그러나 큰 차이점은 draw 메서드는 자식 View들의 draw까지 호출해주지는 않는다는 점입니다. layoutSubviews와 마찬가지로 draw를 직접 사용하는 것은 좋지 않습니다.

setNeedsDisplay()

이 메서드는 setNeedsLayout과 유사합니다. ViewContent가 업데이트 되게 하는 내부 플래그를 활성화시키고 실제로 View가 다시 그리기 전에 메서드는 반환합니다. 그러면, 다음 Update Cycle에 시스템은 이 플래그가 활성화되어있는 View들을 draw를 호출해서 다시 그려줍니다. 우리가 만약 View의 일부분만 다시 그려지길 원한다면, setNeedsDisplay 메서드의 인자로 rect를 전달할 수 있습니다.
대부분, 뷰의 UI 컴포넌트를 업데이트하는 것은 Viewdirty flag를 활성화시켜서 우리가 명시적으로 setNeedsDisplay를 호출하지 않아도 다음 Update Cycle에 뷰가 다시 그려지도록 유도합니다. 그러나, 만약 UI 컴포넌트와 직접적으로 연관되어 있지 않지만 매 Update Cycle마다 다시 뷰를 그려주어야 하는 속성이 있다면 우린 didSet 속성 감시자를 설정하고 setNeedsDisplay를 명시적으로 호출해줄 수 있습니다.

때때로 속성을 설정하는 것이 커스텀 하게 View 그리기를 요구할 수 있습니다. 다음과 같은 예시를 봅시다.

class MyView: UIView {
var numberOfPoints = 0 {
didSet {
setNeedsDisplay()
}
}

override func draw(_ rect: CGRect) {
switch numberOfPoints {
case 0:
return
case 1:
drawPoint(rect)
case 2:
drawLine(rect)
case 3:
drawTriangle(rect)
case 4:
drawRectangle(rect)
case 5:
drawPentagon(rect)
default:
drawEllipse(rect)
}
}
}

numberOfPoints가 변하면 draw(_:) 안에서 View를 그리는 방식이 달라지기 때문에 didSet 블록 안에 setNeedsDisplay를 명시적으로 호출해준 예시입니다.

Layout 과정과 다르게 Display는 즉시 draw(_:)를 호출해주는 메서드는 존재하지 않습니다. 이는 뷰가 다시 그려지기 위해 다음 Update Cycle을 기다리는 것이 아무런 문제가 없기 때문입니다.

Constraints

Auto Layout의 세계에서는 Layout하고 Draw하는것에 대해 3단계의 과정이 있습니다.

  1. Constraints를 업데이트한다 : 시스템이 View에 필요한 Constraints들을 계산하고 설정한다.
  2. Layout 단계 : 레이아웃 엔진이 View들의 Frame과 자식View들의 Frame을 계산하고 배치한다.
  3. Display 단계 : View 컨텐츠를 다시 그리고 필요하다면 draw 메소드를 호출한다.

updateConstraints

이 메서드는 Auto Layout을 이용하는 ViewConstraints를 동적으로 변경할 때 사용될 수 있습니다. Layout 단계에서 layoutSubviewsDisplay 단계에서 draw 같이, updateConstraints는 오직 오버라이딩되어야 하며 명시적으로 호출되어서는 안됩니다. 우리는 보통 updateConstraints에서 동적으로 변해야 하는 Constraints들을 구현합니다. 정적인 Constraints들은 Interface Builder나 View의 생성자나 viewDidLoad에서 정의되어야 합니다.

일반적으로, Constraints 활성화/비활성화하거나 Constraints 우선순위constant를 변경하거나 ViewView 계층에서 삭제하는 것은 updateConstraints를 다음 Update Cycle에서 호출하게 합니다. 그러나, UpdateConstraints를 명시적으로 호출하는 방법 또한 존재합니다.

setNeedsUpdateConstraints

setNeedsUpdateConstraints를 호출하는 것은 다음 Update Cycle에서 Constraint가 업데이트되는 것을 보장해줍니다. 이 메서드는 setNeedsLayout이나 setNeedsDisplay와 비슷하게 작동합니다.

updateConstraintsIfNeeded

이 메서드는 layoutIfNeeded 와 유사합니다. 그러나 오직 Auto Layout을 사용하는 뷰에만 유효합니다. 이 메서드는 Constraint Update Flag(이 Flag는 자동으로 설정되거나, setNeedsUpdateConstraints를 통해 설정되거나, invalidateIntrinsicContentSize를 통해 설정될 수 있습니다.)를 검사합니다. 만약 Constraints가 업데이트가 되어야 하면, updateConstraints를 즉시 호출합니다.

invalidateIntrinsicContentSize

Auto Layout을 사용하는 몇몇 View들은 intrinsicContentSize 속성을 갖습니다. 이는 View가 갖고 있는 Content의 크기입니다. intrinsicContentSize는 전형적으로 View가 갖고 있는 요소들의 Constraints으로 결정되지만, 이것 또한 커스텀 한 동작을 오버라이딩하여 제공할 수 있습니다. invalidateIntrinsicContentSize를 호출하는 것은 View가 갖고 있는 intrinsicContentSize가 낡았으며, 다음 Update Cycle에서 다시 계산되어야 한다고 플래그를 활성화시켜줍니다.

How it all connects

ViewLayoutDisplay 그리고 Constraintsrun loop에서 다른 시점에 어떻게 업데이트되고, 명시적으로 업데이트할 수 있는지에 대해 유사한 패턴을 갖습니다. 각 컴포넌트들은 layoutSubviews, draw, updateConstraints과 같은 실제로 업데이트를 전파하는 메서드들을 가지며, 명시적으로 호출되면 안 되기 때문에 이를 호출하도록 유도할 수 있는 방법들이 있습니다. 이러한 메서드들은 run loop의 마지막에 View의 해당 flag가 활성화되어있으면 시스템이 호출해주는 방식입니다. 몇 가지의 자동적으로 이 Flag들을 활성화해주는 방식들이 있고 명시적으로 활성화시켜주는 방식도 있습니다. LayoutConstraints에 관련된 업데이트들에 대해서는 만약 다음 Update Cycle까지 기다릴 수 없다면, 즉시 업데이트가 되도록 요청하는 메서드들도 존재합니다. 다음과 같은 표는 이러한 각 메서드들이 작동하는 방식입니다.

다음과 같은 표는 Update CycleEvent Loop 그리고 위에서 설명한 메서드들이 Cycle 동안 어떻게 호출되는지 설명합니다. 우리는 명시적으로 layoutIfNeededupdateConstraintsIfNeededrun loop의 아무 시점에서나 호출할 수 있습니다. loop의 끝은 Update Cycle입니다. Update CycleConstraints, Layout 그리고 Display를 해당 플래그가 활성화되어있다면 업데이트해줍니다. 이러한 업데이트들이 완료되면, run loop는 다시 시작합니다.

http://tech.gc.com/demystifying-ios-layout/

이것은 차트와 표를 요약하고 위에서 설명한 메서드들을 더욱 세분화하며 이 메서드들이 iOS의 run loop와 작동하는 방식을 확실하게 설명해줍니다. 이 메서드들을 이해하고 어떻게 효율적으로 적절한 업데이트를 호출시키는 것은 낡은 레이아웃이나 콘텐츠를 이용하거나 예상치 못한 행동으로부터 초래하는 문제들을 쉽게 피할 수 있게 해줄 것입니다.

--

--