슬레이트 아키텍처

슬레이트 디자인을 이끄는 핵심 개념입니다.

Choose your operating system:

Windows

macOS

Linux

이 페이지를 읽는 방법

Slate (슬레이트) 디자인을 이끄는 핵심 개념에 대해 알아보실 수 있는 페이지로, 정해진 순서는 없습니다. 확고한 구조나 장대한 이론같은 것은 여기 없으며, 그저 UI 를 빚어가는 경험에서 우러나온 원칙을 모아둔 것일 뿐입니다.

Slate 경험이 쌓여감에 따라 이 페이지를 때때로 다시 읽어보시는 것이 도움이 될 것입니다.

동기

Slate 를 만들게 된 동기는, 현재 나와있는 UI 솔루션에 대해 관찰하다 보니 생기게 되었습니다. 몇가지는 이렇습니다:

  • 위젯에서 UI 를 만드는 것은 이미 대부분의 툴키트에서 쉽게 할 수 있는 작업입니다. 어려운 것은:

    • UI 디자인과 반복작업(iteration).

    • 데이터 흐름 제어: 흔히 위젯(뷰)과 내재된 데이터(모델)끼리 묶어주는 것으로 생각해 볼 수 있습니다.

    • UI 기술(description)을 위한 외국어 습득.

  • IMGUI : Immediate Mode Graphics User Interface

    • 장점:

      • 프로그래머는 UI 기술이 코드와 "비슷하다"는 점을 선호하며, 데이터에서 구하기가 쉽습니다.

      • 실효(invalidation)가 보통 문제되지 않으며, 그냥 데이터를 직접 폴링(poll)합니다.

      • 인터페이스의 절차적 생성이 쉽습니다.

    • 단점:

      • 애니메이션과 스타일링 추가가 어렵습니다.

      • UI 기술이 명령형(imperative) 코드라, 데이터 주도형으로 만들 방법이 없습니다.

  • 바람직한 Slate 특징:

    • 모델의 코드와 데이터에 쉽게 접근할 수 있습니다.

    • UI 절차적 생성을 지원합니다.

    • UI 설명이 잘 꼬이지 않습니다.

    • 애니메이션과 스타일을 반드시 지원합니다.

핵심 원리

가급적 개발자의 효율성을 위해 디자인합니다. 프로그래머의 시간은 비싸지만 CPU 는 빠르고 쌉니다.

  • 불투명 캐시와 중복 스테이트를 피합니다! 역사적으로 UI 는 스테이트를 캐시에 담고 실효(invalidation)는 명시적으로 할 것을 요합니다. Slate 는 다음과 같은 접근법을 사용합니다 (선호도 순):

    1. 폴링(Polling)

    2. 투명 캐시

    3. 불투명 캐시에 드물게(low-grain) 실효(invalidation)

  • UI 구조가 변할 때, 알림(notification)보다 폴링(polling)을 선호합니다 (알림이 필요하다면 세밀한(fine-grain) 편 보다는 드문(low-grain) 편이 좋습니다).

  • 피드백 루프를 회피, 즉 모든 레이아웃은 프로그래머 세팅에서 계산하며, 절대 이전 레이아웃 스테이트에 의존하지 않습니다.

    • 유일한 예외는 UI 스테이트가 모델이 되는 경우, 예로 스크롤바가 UI 스테이트를 시각화시키는 경우입니다.

    • 이렇게 하는 이유는 퍼포먼스 때문이라기 보다는, 올바르면서 프로그래머의 분별을 위해서입니다.

  • 방대한 양의 일회성 작업을 요하는 난잡한 즉석 UI 에 대한 계획을 세웁니다. 그러한 것들을 나중에 용도를 이해하고난 후 깔끔한 시스템으로 일반화시킵니다.

델리게이트와 데이터 흐름 폴링

UI 는 Model 을 시각화시키고 조작합니다. Slate 는 Model 의 데이터를 읽고 쓸 필요가 있는 위젯의 유연한 통로로써 델리게이트(delegate)를 사용합니다. Slate 위젯은 Model 의 데이터를 표시할 필요가 있을 때 읽어들입니다. 사용자가 어떠한 동작을 하면, Slate 위젯은 데이터 수정을 위해 write 델리게이트를 부릅(invoke)니다.

텍스트를 표시하는 Slate 위젯 STextBlock 를 봅시다. 표시할 데이터를 어디서 구할지 STextBlock 에 꼭 알려야 합니다. 데이터를 동적으로 설정할 수도 있습니다만, 델리게이트(, 다른 말로 사용자가 지정한 함수)를 통해 하는 것이 조금 더 유연한 방법입니다. 그를 위해 STextBlock 에서는 Text 라는 델리게이트를 사용합니다.

+------------+                         +--------------+
| STextBlock |                         |           {s}|
+------------+                         | Model Data   |
|            |       /--------\        +--------------+
|     o Text +<---=--+ReadData+---=----+  framerate   |
|            |       \--------/        +--------------+
+------------+                                               

STextBlock 는 framerate 를 문자열(string)로 읽어옵니다.

위의 예에서 Text 는 문자열인 반면, framerate 는 거의 float integer 로 저장된다는 것도 고려해 봅시다. 델리게이트를 사용하면 값을 읽을 때마다 유연하게 변환할 수 있습니다. 그렇다는 것은 바로 퍼포먼스 문제를 떠올리게 되는데, 아래 퍼포먼스 고려사항 에서 다루고 있습니다.

SEditableText 는 입출력 둘 다를 담당하는 Slate 위젯입니다. STextBlock 처럼 Text 델리게이트를 사용하여 데이터를 시각화시킵니다. 사용자가 편집가능 텍스트 필드에 약간의 텍스트를 입력하고서 엔터키를 칠 때, SEditableText 는 OnTextChanged 델리게이트를 부릅니다. 여기서 프로그래머가 입력을 유효화(validate)시키고 Model 의 데이터를 OnTextChanged 로 변형(mutate)시키기에 적합한 함수성을 붙여뒀다는 가정을 합니다.

+-----------------+                         +--------------+
| SEditableText   |                         |           {s}|
+-----------------+                         | Model Data   |
|                 |      /----------\       +--------------+
|         o Text  +<--=--+ ReadData +---=---+              |
|                 |      \----------/       |  item name   |
|                 |      /-----------\      |              |
| o OnTextChanged +---=--+ WriteData +--=-->+              |
|                 |      \-----------/      +--------------+
+-----------------+
[REGION:caption]
    SEditable 텍스트는 item name 을 읽습니다. 사용자가 엔터키를 치면 새로운 텍스트가 OnTextChanged 에 전송되며, 거기서 텍스트를 유효화시키고 적합하다면 item name 에 할당합니다.
[/REGION]

다음 프레임 도중 SEditableText 는 Model 의 데이터에서 읽습니다. 위의 예제에서 item name 은 OnTextChanged 델리게이트에 의해 변형(mutate)되었을 것이며, Text 델리게이트를 통해 시각화에 쓸 내용으로 읽힐 것입니다.

특성(Attribute)과 인수(Argument)

델리게이트를 사용하는 것이 항상 바람직한 것은 아닙니다. 용도에 따라 Slate 위젯에 붙일 인수는 상수값이나 함수여야 할 수도 있습니다. 이러한 개념은 TAttribute< T > 클래스를 통해 캡슐화시킵니다. 이 특성은 상수나 델리게이트로 설정 가능합니다.

퍼포먼스 고려사항

델리게이트와 데이터 흐름 폴링 부분을 읽고 난 이후 퍼포먼스가 걱정될 수가 있습니다.

다음 관찰 내용을 고려해 봅시다:

  • UI 복잡도는 활성 위젯의 수에 귀속됩니다.

  • 콘텐츠의 스크롤 작업은 가능하면 가상화시킵니다. 이로써 대부분 활성 위젯이 화면 밖으로 나가는 것을 막습니다.

    • 화면 밖에 있는 위젯 수가 많으면 Slate 퍼포먼스를 쉽게 망칠 수 있습니다.

  • 가정: 큰 화면 사용자는 우월한 사양의 하드웨어를 사용하므로, 다수의 위젯을 처리해 낼 수 있다 가정합니다.

실효 vs 폴링

가끔 폴링은 퍼포먼스나 기능적으로 맞지 않을 수가 있습니다. 이는 종종 단순하고 사소한 값의 조합으로 표현할 수 없는, 사소하지 않은 값일 경우가 그러한데요. Model 의 구조(체)가 급격히 변하는 상황에서는 보통 실효화(invalidate)시킵니다. 그러면 기존 UI 를 파기하고 재생성하는 것이 이치에 맞겠죠. 아무튼 그렇게 한다면 스테이트는 잃게 될 테니, 꼭 필요하지 않은 한 자제해야 겠습니다.

실효는 빈도도 낮고 드문드문 등장하는(low granularity) 이벤트에 쓰는 것을 규칙으로 삼습니다.

그래프에 노드를 표시하는 키즈멧을 예로 들어 봅시다. 업데이트 요청이 생기면 모든 Graph Panel 위젯을 지운 후 재생성합니다. 그렇게 하는 편이 실효를 세밀하게(fine-grain) 하는 것보다 단순하고 관리하기에 좋습니다.

자손 슬롯

모든 Slate 위젯에서 자손은 (평이한 자손 위젯 배열의 저장과는 반대로) 자손 슬롯에 저장합니다. 자손 슬롯은 항상 유효한 위젯, 기본적으로 시각화나 상호작용이 없는 위젯인 SNullWidget 을 저장합니다. 각 위젯 유형은 자체적인 요구를 충족시키는 자손 슬롯 유형을 별도로 선언할 수 있습니다. SVerticalSlot 가 자손을 배치(arrange)하는 방식이 SCanvas 와는 완전 다르면서, 또 SUniformGridPanel 와는 꽤나 다른 것을 감안해 봅시다. 각 패널 유형은 슬롯을 통해 자손의 배치에 영향을 끼치는 자손별 세팅 집합을 요청할 수 있습니다.

위젯 규칙

위젯은 세 가지 변종이 있습니다.

  • Leaf Widgets 잎 위젯 - 자손 슬롯이 없는 위젯입니다. 예로 STextBlock 은 텍스트를 표시합니다. 텍스트를 어떻게 그릴 것인지 원래부터 알고 있습니다.

  • Panels 패널 - 자손 슬롯의 수가 가변적인 위젯입니다. 예로 SVerticalBox 는 약간의 레이아웃 규칙이 주어진다면 몇 개의 자손도 수직 배치 가능합니다.

  • Compound Widgets 복합 위젯 - 명시적으로 이름붙은 자손 슬롯의 갯수가 고정된 위젯입니다. 예로 SButton 에는 Content 라는 슬롯이 하나 있어, 버튼 안에 몇 개의 위젯이든 포함할 수 있습니다.

레이아웃

Slate 레이아웃은 두 패스로 이루어집니다. 두 패스로 분해한 것은 최적화 때문이며, 아쉽게도 투명한 것은 아닙니다.

  1. Pass 1: Cache Desired Size - 연관된 함수는 SWidget::CacheDesiredSize and SWidget::ComputeDesiredSize

  2. Pass 2: ArrangeChildren - 연관된 함수는 SWidget::ArrangeChildren

보다 자세히:

Pass 1: Cache Desired Size

이 패스의 목표는 각 위젯이 얼마만큼의 공간을 차지하려는지 알아내는 것입니다. 자손이 없는 (잎) 위젯같은 경우 내재된(instrinsic) 프로퍼티에 따라 바람직한 크기를 계산하여 캐시를 하도록 요청받습니다. 다른 위젯과 합쳐지는 위젯(, 즉 복합 위젯과 패널)은 자손 크기 결정 함수로서 바람직한 크기를 결정하는 데 특수한 로직을 사용합니다. ComputeDesiredSize() 를 구현할 때만 각 위젯 유형이 필요하다는 점에 유의하시고, 캐시나 탐색 로직은 Slate 가 구현합니다. 위젯에서 ComputeDesiredSize() 가 호출될 때, 그 자손에는 이미 원하는 크기가 계산 및 캐시되어 있음을 Slate 가 보장해 줍니다. 고로 이것은 상향식(bottom-up) 패스입니다.

아래 예제에서 Horizontal Box 에는 두 자손, 텍스트와 이미지가 배치되어 있는 것을 관찰할 수 있습니다.

.    |<-----------22---------->|
.    +-------------------------+     
.    |     Horizontal Box      |
.    +-------------------------+     
.      +----=----+ +----=----+       
.      |    ?    | |    ?    |       
.      +----+----+ +----+----+       
.           ^           ^            
.           |           |            
.        +--+           +--+
.        |                 |         
.   +----+---------+  +----+--+    
.   |STextBlock    |  |SImage |
.   +---------+----+  +-------+  
.   |<---- 14----->|  |<--8-->|

두 자손, 텍스트와 이미지가 배치되어 있는 Horizontal Box

STextBlock 위젯은 표시중인 문자열을 축정하여 원하는 크기를 계산할 것입니다. SImage 위젯은 표시하려는 이미지 데이터를 기준으로 크기를 결정할 것입니다. 슬레이트 유닛을 기준으로 텍스트 블록 안의 텍스트는 14 유닛, 이미지는 8 유닛 공간이 필요하다 가정합시다. 가로 패널은 위젯의 가로 공간을 배치하므로, 14 + 8 = 22 유닛 공간이 필요하게 됩니다.

Pass 2: ArrangeChildren

ArrangeChildren 은 하향식(top-down) 패스입니다. Slate 는 최상위 창부터 시작해서 프로그래머가 제공한 제약(constraint)에 따라 자손을 배치할 것인지 각 창에 묻습니다. 각 자손에 할당된 공간이 알려지면 Slate 는 재귀를 통해 자손의 자손을 배치할 수 있습니다. 모든 자손이 배치될 때까지 재귀는 계속됩니다.

.     |<---Allotted Space 25--->|
.     +-----------------+-------+
.     |     Horizontal Box      |
.     +-------------------------+
.       +----=----+ +----=-----+
.       |Auto Size| |Fill Width|
.       +----+----+ +----+-----+
.            |           |      
.         +--+           +--+   
.         |                 |   
.         v                 v   
.     +----+-----+----------+---+
.     |STextBlock| SImage       |
.     +----------+--------------+
.     |<---14--->|<-----11----->|

가로 패널은 두 자손, 텍스트 블록과 이미지를 배치합니다.

위 예제에서 패널은 부모에 의해 25 유닛이 할당되었습니다. 첫 슬롯은 자손의 Desired Size 를 사용하고 싶다는 것을 나타내며, 14 유닛 공간이 할당됩니다. 둘째 슬롯은 사용가능한 폭을 채우고 싶다는 것을 나타내며, 나머지 (11 유닛) 공간이 할당됩니다. 실제 SHorizontalBox 위젯에서 SImage 슬롯 안에서의 정렬 방식은, Left, Center, Right, Fill 이 가능한 HAlign 프로퍼티에 의해 주도된다는 점에 유념해 주시구요.

실제적으로 Slate 는 절대 ArrangeChildren 패스를 온전히 수행하지 않습니다. 대신 이 함수성은 다른 함수성을 구현하는 데 사용됩니다. 주요 예제는 힛 디텍션과 페인팅 입니다.

Slate 그리기: OnPaint

페인트 패스 도중 Slate 는 보이는 모든 위젯에 대해 반복하면서 렌더링 시스템이 소비하게 될 그리기 요소 목록을 만듭니다. 이 목록은 매 프레임마다 새로이 만들어집니다.

최상위 창부터 시작하여 계층구조를 따라 내려오면서, 모든 위젯의 그리기 요소를 그리기 목록에 덧붙입니다. 위젯은 페인트 도중 두 가지 작업을 하는 경향이 있습니다: 실제 그리기 요소를 출력하거나, 자손 위젯이 있을 위치를 알아낸 다음 자손 위젯더러 스스로 그리라고 요청합니다. 고로 단순화된 범용 OnPaint 함수는 다음과 같이 생각해 볼 수 있습니다:

.   // 배치된 자손은 위젯과 그에 할당된 지오메트리 입니다.
.   struct ArrangedChild
.   {
.       Widget;
.       Geometry;
.   };
.   
.   OutputElements OnPaint( AllottedGeometry )
.   {
.       // 할당된 지오메트리와 함께 자손 전부를 배치합니다.
.       Array<ArrangedChild> ArrangedChildren = ArrangeChildrenGiven( AllottedGeometry );
.   
.       // 자손을 칠합니다.
.       for each ( Child in ArrangedChildren )
.       {
.           OutputElements.Append( Child.Widget.OnPaint( Child.Geometry ) );
.       }
.   
.       // 테두리를 칠합니다.
.       OutputElements.Append( DrawBorder() );
.   }

SWidget 해부도

Slate 에서 SWidget 의 행위를 정의하는 주요 함수는:

  • ComputeDesiredSize() - 바람직한 크기를 담당합니다.

  • ArrangeChildren() - 부모 할당 영역 내 자손의 배치를 담당합니다.

  • OnPaint() - 출현을 담당합니다.

  • 이벤트 핸들러 - OnSomething 형태의 것으로, 이들은 다양한 시점에서 Slate 가 위젯에서 부를 수 있는 함수들입니다.

Composition

Composition 이란 어느 슬롯도 임의의 위젯 내용을 담을 수 있어야 한다는 개념입니다. 이로써 Slate 사용자들은 엄청난 유연성을 확보할 수 있습니다. Composition 은 가능하면 언제든지, 핵심 Slate 위젯에서 사용됩니다.

위젯에서 라벨로 사용할 문자열 인수를 받고자 생각중인 경우, 먼저 SWidget 을 대신 받는 것이 낫지 않겠는가 스스로에게 여쭤 보시기 바랍니다.

어떤 경우엔 위젯에 특정 유형의 자손을 포함하기도 하는데, 그러한 경우 Composition 조건은 더이상 적용되지 않습니다. 이들은 절대 Slate 코어 속 위젯이라기 보다는, 일정 영역 밖에서 재사용되도록 고안되지는 않은 도메인 전용 위젯입니다.

선언형 문법

Slate 는 코드에서 직접 접근할 수 있었으면 했습니다. 그러기 위해서는 UI 기술에 선언형(declarative) 언어가 필요하지만, 또 한가지 C++ 함수 바인딩에 컴파일 시간 검사가 되도록도 했으면 했습니다.

해법은 C++ 부분집합으로써 선언형 UI 기술 언어를 만드는 것이었습니다.

예제는 코드베이스에 숱하게 많습니다.

언리얼 엔진 문서의 미래를 함께 만들어주세요! 더 나은 서비스를 제공할 수 있도록 문서 사용에 대한 피드백을 주세요.
설문조사에 참여해 주세요
취소