캔버스(Canvas)란 회화 표면에 유화를 그릴때 쓰이는 평직물을 뜻한다. 이와 유사한 개념으로 UI 엔진에서 캔버스는 UI 엔진에서 UI 객체를 화면에 출력하기 위한 드로잉 영역으로서 활용된다. 하지만 단순히 드로잉 영역을 제공하는 평직물 이상으로, UI 엔진에서의 캔버스는 이보다 훨씬 더 복잡한 기능을 제공한다. 특히 리테인드(Retained) 그래픽 시스템의 캔버스의 경우 앱 화면을 구성하는 UI 객체의 라이프 사이클을 관리하고 이들이 적절한 시점에 화면에 출력될 수 있도록 도움을 준다. 그뿐만 아니라, UI 객체를 캔버스 내부에서 관리하기 때문에 최적의 렌더링을 위한 복잡한 알고리즘을 내부적으로 수행하기도 한다. 추가로 UI 객체를 관리하는 역할 특성상, 단순히 그래픽 출력 뿐만 아니라 사용자 이벤트 처리 방식까지 관여를 하게 되며 캔버스에 배치된 UI 객체가 사용자와 상호작용을 수행할 수 있도록 도와준다.

이처럼 캔버스는 UI 엔진에서 매우 중요한 역할을 수행한다. 개발자로서 UI 캔버스의 내부 동작을 이해한다면 UI 프레임워크의 전반적인 동작 이해에 큰 도움이 된다. 그뿐만 아니라, 앱 개발 관점에서도 앱 개발 중 발생한 UI 동작 오류는 물론, 보다 최적의 앱을 구현할 수 있는 고급 지식과 이해를 갖추는데 도움이 된다. 이번 장에서는 UI 엔진의 핵심인 캔버스 모델을 자세히 살펴보고 캔버스가 제공하는 기본 기능은 물론, 핵심 기능 구현 방법도 살펴보도록 하자. 이번 장을 학습하고나면 UI 객체가 화면에 출력되는 과정을 전반적으로 이해하는데 도움이 될 것이다.


1. 이번 장 목표

이번 장을 통해 다음 사항을 학습해 보자.

  • 렌더링의 개념과 동작 구조를 이해한다.
  • 리테인드 모드와 이미디어트 모드의 차이를 이해한다.
  • 캔버스 모델의 특징과 캔버스 엔진의 핵심 기능을 구현한다.
  • UIObject 모델을 이해하고 핵심 기능을 구현한다.
  • 씬그래프 기반의 캔버스 렌더링 메커니즘을 이해한다.
  • 레이어를 통한 객체의 렌더링 순서를 조정하는 방안을 살펴본다.


  • 2. 렌더링의 이해

    렌더링(Rendering)이란 컴퓨터 프로그램을 이용하여 입력 데이터로부터 최종 영상을 만들어내는 과정을 의미한다. UI 엔진 역시 UI 요소를 화면에 출력하기 위해 렌더링 엔진을 요구한다. 보다 복잡한 화면 구성일수록 렌더링 과정 역시 복잡하다. 벡터 그래픽스나 3차원 그래픽스처럼 기하학 정보로부터 최종 픽셀을 생성하는 과정의 경우 렌더링 과정에서 많은 연산이 필요하기 때문에 그래픽스 하드웨어 장치의 도움을 받기도 하며 최근의 시스템에서는 UI 출력 역시 그래픽스 하드웨어 장치를 활용하여 렌더링을 수행하기도 한다. 우선은 이해하기 쉽게 단순한 시나리오로 전체적인 맥락을 짚어 보자. UI를 화면에 출력하기 위해 어떠한 렌더링 절차가 필요할까? 다음 그림은 렌더링 과정을 하나의 예로서 간략하게 도식화한다.


    그림 1: UI 렌더링 과정

    UI 렌더링 엔진의 기능과 설계 방식에 따라 구조 및 동작 방식에 차이가 있을 수 있지만 일반적으로 UI 렌더링 엔진은 그림 1와 같이 이미지를 출력하는 이미지 렌더러(Image Renderer), 텍스트를 출력하는 텍스트 렌더러(Text Renderer), 벡터 드로잉을 수행하는 벡터 렌더러(Vector Renderer) 크게 세 부분의 렌더링을 수행한다. 이 세 렌더링 과정을 거치면, 이들이 만들어낸 결과물을 조합(Composition)하여 최종 결과물을 화면에 출력할 수 있다.

    이미지 렌더러는 jpeg, png 포맷과 같은 이미지 파일로부터 데이터를 읽어와 비트맵 이미지를 생성하는 역할을 담당하며 이미지 스케일링(Scaling), 변환(Transform), 색상 모델(Colorspace) 변환 등을 추가로 수행한다. 이 때 이미지 포맷에 따라 데이터 구성 및 디코딩 방식이 다르며 이미지를 불러오는 과정도 다르다. 이미지 로더에서 수행하는 디코딩 작업은 이미지 포맷에 의존하며, 디코딩 작업의 로직도 복잡한 편이다. 그렇기 때문에, 이미지 로더를 UI 렌더링 엔진에서 직접 구현하기 보다는 각 포맷에 해당하는 외부 모듈로 구성하는 것이 더 효율적이다.

    텍스트 렌더러는 폰트 데이터로부터 폰트 정보를 읽어와 글리프(Glyph)를 생성하며 이러한 글리프를 조합/배열하여 출력할 텍스트를 완성한다. 글리프는 텍스트를 구성하는 하나의 문자 이미지를 가리킨다. 글리프를 생성하는 폰트 엔진은 폰트 정보가 저장되어 있는 폰트 파일로부터 폰트 그래픽 디자인은 물론 위치, 문자 사이의 자간 정보 등을 해석하여 최종 텍스트 이미지를 완성한다. 대중적인 폰트 포맷으로 OTF(Open Type Format), TTF(True Type Format), Fnt(Font) 등이 있다. 일반적으로 폰트 엔진 역시 벡터 렌더링을 수행하며 그 자체로도 방대한 기능을 완성하기 때문에 UI 렌더링 엔진과는 별개의 모듈로서 구성하는 것이 설계 관점에서 더 바람직이다. 참고로 프리타입(freetype) 오픈소스 프로젝트는 텍스트를 출력하는 안정적이고 성능이 우수한 무료 소프트웨어를 제공한다.


    그림 2: 폰트 파일에는 글리프(문자)를 그리기 위한 벡터 정보가 기록되어 있다. (FontForge)


    벡터 렌더러는 선, 원, 다각형의 이미지를 그리는 역할을 수행하며 대표적으로 SVG와 같은 벡터 리소스 데이터를 이용하거나 사용자가 입력한 기하 정보로부터 수식을 통해 이미지를 실시간으로 생성한다. UI를 완성하기 위해 이미지와 텍스트를 이용할 수도 있지만 벡터 드로잉 방식을 이용하면 해상도에 영향을 받지 않는 최적의 이미지를 생성할 수 있으며 리소스 데이터 역시 이미지에 비해 매우 작은 편이다. 게다가, 이미지로는 구현하기 어려운 다소 복잡한 형상의 애니메이션을 동적으로 만들어 낼 수 있는 장점도 있다. 다만 벡터 드로잉은 이미지를 생성하는 연산 과정에서 부하가 발생하기도 한다. 이를 보완하기 위해 그래픽스 하드웨어 도움을 받는 것도 고려할 부분이다. 안드로이드의 머티리얼(Material) UI가 벡터 기반 UI의 대표적인 예이다.


    그림 3: 안드로이드 머티리얼 UI

    안드로이드 머티리얼 UI도 이에 해당되지만, 최근에는 마이크로 인터렉션(Micro Interaction) UX 개념이 유행하면서 과거에 비해 벡터 그래픽스가 여러 시스템에서 범용적으로 활용되고 있다. 최근에는 벡터 기반의 애니메이션을 지원하기 위해 바디무빈(Bodymovin)으로 불리는 json 포맷이 활용되고 있으며 이 포맷을 출력하기 위해 에어비엔비(Airbnb)에서 제작한 로띠(Lottie)라는 오픈소스도 존재한다. 기본적으로 바디무빈 데이터는 어도비(Adobe)의 애프터이펙트(After Effect) 툴에서 작업한 벡터 데이터를 추출한 결과물이다.


    그림 4: Airbnb의 오픈소스 프로젝트 Lottie

    사실상, 그동안 업계에서는 벡터 UX의 애니메이션을 지원하기 위한 별다른 표준 포맷이 존재하지 않았다. SVG의 Smil이라는 확장 포맷이 있었지만 이는 SVG의 공식 포맷이 아니었기 때문에 여러 UI 시스템에서 범용적으로 활용되지 못했다. 반면, 바디무빈은 Hernan Torrisi이라는 개인 개발자가 개발한 포맷이며 로띠 프로젝트를 통해 커뮤니티가 매우 잘 활성되었다. 실제로 LottieFiles 사이트에만 가더라도 무료로 활용할 수 있는 벡터 애니메이션 샘플 리소스를 다운받을 수 있으며 마이크로 인터렉션 디자인 트렌드와 함께 안드로이드, iOS, 자마린 등 여러 플랫폼에서 지원하기 시작했다.


    그림 5: LottieFiles에서 다운받을 수 있는 방대한 벡터 애니메이션 리소스

    UI 렌더링 엔진은 호출자에서 요구한 UI 구성 정보 내지 드로잉 구성 정보를 재해석하여 각각의 렌더러로 작업을 전달하며 각각의 렌더러는 요청한 이미지를 생성한다. 생성된 이미지는 사용자가 입력한 UI의 위치, 크기 정보를 토대로 재배치, 조합하여 최종 결과물을 만들어내는 컴퍼지션 작업을 거치게 된다. 완성된 결과물은 32비트 비트맵으로서, 비트맵을 구성하는 각 픽셀(점) 정보는 RGBA(24비트의 경우 RGB) 색상 정보를 갖춘다. 예를 들어, 400x400 크기 이미지의 비트맵이라고 가정하면 400x400x32비트 크기의 비트맵이 필요하며 여기서 하나의 픽셀 데이터 크기는 32비트이다. 이 한 픽셀은 다시 R(적색), G(녹색), B(청색), A(알파) 채널 정보로 구성되는데 각 채널당 8비트의 크기를 구성하며 이는 28 크기에 해당되므로 한 채널당 0 ~ 255의 색상 정보를 보유할 수 있다. 0 ~ 255 값의 각 채널을 조합하면 실제로 하나의 픽셀이 나타낼 수 있는 색상의 수는 16,581,375개가 된다. 결국 이러한 픽셀은 모니터 화소에 1:1로 매핑됨으로서 하나의 색상 점으로서 출력되며 최종 결과물인 비트맵을 생성하는 과정까지가 렌더링 엔진이 담당한다. 이 후의 비트맵은 디스플레이 출력을 담당하는 드라이버로 전달됨으로로써 최종적으로 화면에 보여질 수 있다.


    그림 6: 비트맵을 구성하는 픽셀 도식화

    비트맵을 구현하기 위해서는 일련의 메모리 공간을 필요로 하는데, 비트맵에 기록하는 각 픽셀의 채널 순서는 시스템 환경에 따라 다르게 지정될 수도 있다. 일반적으로 픽셀 채널은 R, G, B, A 순으로 구성한다.

    /* bitmap은 4바이트 크기의 데이터 타입 배열이라고 가정하자. 이 경우, 가로 10, 세로 10 크기의 비트맵 데이터를 가리킨다. */
    bitmap[10][10];
    bitmap[0][0] = 0xff0000ff;   //첫 번째 라인의 첫 번째 픽셀 색상은 적색
    bitmap[0][1] = 0x00ff00ff;   //첫 번째 라인의 두 번째 픽셀 색상은 녹색
    bitmap[0][2] = 0x0000ffff;   //첫 번째 라인의 세 번째 픽셀 색상은 청색
    ...
    bitmap[1][0] = 0xffff00ff;   //두 번째 라인의 첫 번째 픽셀 색상은 황색
    bitmap[1][1] = 0xff00ffff;   //두 번째 라인의 첫 번째 픽셀 색상은 보라색
    bitmap[1][2] = 0xffffffff;   //두 번째 라인의 세 번째 픽셀 색상은 흰색
    ...
    bitmap[7][9] = 0x000000ff;   //열 번째 라인의 여덟 번째 픽셀 색상은 검정
    bitmap[8][9] = 0x00ffffff;   //열 번째 라인의 아홉 번째 픽셀 색상은 청록색
    bitmap[9][9] = 0xffffffff;   //열 번째 라인의 열 번째 픽셀 색상은 흰색
    
    코드 1: 비트맵에 직접 색상 값을 채우는 예

    UI를 출력하기 위한 렌더링은 앱의 호출에 의해 발생할수도 있지만 보다 고급의 엔진에 가깝다면 UI 엔진 스스로 렌더링을 호출하기도 한다. 전자의 경우를 이미디어트(Immediate) 렌더링이라고 하고 후자는 리테인드(Retained) 렌더링이라고 한다. 이미디어트 렌더링은 리테인드 렌더링과 비교하면 보다 원초적으로 동작하는데 그만큼 앱이 렌더링에 더 많은 것을 관여할 수도 있다. 이미디어트 렌더링의 경우 앱이 드로잉 커맨드를 호출할 때마다 실제로 드로잉 대상 버퍼에 드로잉 작업이 수행되기 때문에 드로잉 호출에 보다 신중해야 한다. 앱은 불필요한 드로잉을 피해야 하며, 윈도우 시스템에 의해 무효(Invalid) 영역이 발생했을 때 화면을 새로 갱신하기 위한 드로잉 컨텍스트 관리 작업 등이 필요하다.


    그림 7: 이미디어트 렌더링

    /*
     * 이 예제에서는 이미지를 교체하고 버튼의 위치를 이동한다.
     * 버튼과 이미지의 인스턴스는 이전에 이미 생성, 초기화 되었다고 가정하자. 
    */
    update()
    {
        ...
    
        //새 버튼 이미지를 불러온다.
        img.open(NEW_IMG);
    
        //이미지를 화면에 다시 그리기 위해 무효 영역을 설정한다.
        invalidateArea(img.geometry());
    
        //버튼의 변화를 위해 이전 영역을 무효 영역으로 설정한다.
        invalidateArea(btn.geometry());
    
        //버튼의 위치가 (200, 200)에서 (300, 300)으로 이동하였다고 가정하자.
        btn.move(300, 300);
    
        //버튼의 새로운 위치를 기준으로 화면을 다시 그려야 한다.
        invalidateArea(btn.geometry());
    
        //무효 영역을 모두 지정한 이후, 화면을 갱신하기 위한 렌더링을 요청한다.
        render();
    
        //이 시점에서 화면에서 이미지와 버튼 위치가 바뀐 것을 확인할 수 있다.
        ...
    }
    
    코드 2: 이미디어트 렌더링 사용 예

    전통적인 그래픽스 시스템인 그놈(Gnome)의 GTK+, 마이크로소프트의 GDI, GDI+가 이미디어트 렌더링 엔진을 제공하며 3차원 그래픽스 인터페이스인 OpenGL과 Direct3D 역시 기본적으로 이미디어트 렌더링을 제공한다. 모바일 플랫폼인 안드로이드 역시 이미디어트 렌더링을 수행한다. 이미디어트 렌더링의 경우 호출자가 비교적 렌더링에 적극적으로 관여할 수 있기 때문에 할 수만 있다면 보다 복잡하고 최적화된 렌더링도 구현이 가능하다. 하지만 그만큼 구현이 복잡해지는 단점도 존재한다. 특히 게임과 같이 특수 효과 및 최적화가 중요 요소인 경우 자유도가 높은 이미디어트 모드의 렌더링이 보다 용이할 수도 있다.

    반면, 리테인드 렌더링의 경우 엔진 스스로 많은 작업을 알아서 처리해 준다. 차폐된 렌더링 객체를 걸러내고 클리핑(Clipping)을 통해 드로잉 영역을 최소화하면서 적절한 시점에 렌더링을 수행한다. UI 요소의 컨텍스트를 내부적으로 보유하고 있기 때문에 윈도우 시스템에 의해 무효 영역이 발생하더라도 엔진 스스로 해당 영역을 다시 그리는 작업을 수행할 수 있다. 이러한 이유로 앱 개발자는 드로잉과 관련된 작업보다 앱의 순수 로직에 더 집중하여 개발할 수 있는 장점을 제공한다. 동시에 같은 이유로, 드로잉과 관련된 전반적인 컨텍스트 관리는 물론 UI 요소들의 정보를 엔진이 내부적으로 유지하고 있어야 하기 때문에 이미디어트 모드에 비해 엔진 구현이 보다 복잡하고 어려운 편이다. 이는 곧, 엔진 내부적으로 드로잉과 관련된 모든 정보가 은닉되어 있어서 앱 개발자는 드로잉과 관련된 작업에 직접적인 관여를 하기가 어렵다. 하지만 최근 기본 리테인드 모드의 그래픽스 시스템을 살펴보면 리테인드 렌더링 뿐만 아니라 이미디어트 렌더링 기능도 추가로 제공한다. 리테인드 모드 그래픽스 시스템에서의 이미디어트 렌더링 기능은 필요에 따라서 이미지 캡처 및 디버깅 등의 목적으로 활용될 수 있다. 마이크로소프트의 WPF와 그보다 최신인 UWP 그리고 애플의 IOS는 리테인드모드를 제공한다. 타이젠에 탑재된 Enlightenment Foundation Libraries(EFL) 역시 마찬가지이다.


    그림 8: 리테인드 렌더링

    /*
     * 이 예제에서는 이미지를 교체하고 버튼의 위치를 이동한다.
     * 버튼과 이미지의 인스턴스는 이전에 이미 생성, 초기화 되었다고 가정하자. 
    */
    update()
    {
        ...
        
        //새 버튼 이미지를 불러온다.
        img.open(NEW_IMG);
    
        //버튼의 위치가 (200, 200)에서 (300, 300)으로 이동하였다고 가정하자.
        btn.move(300, 300);
    
        /* 이미디어트 렌더링에 비해 코드가 매우 간소하다. 렌더링을 직접 요청하지도 않는다.
           하지만, 이 시점에서 화면에서 이미지와 버튼 위치가 바뀌었을까?... 
           앱 개발자 입장에서는 알 수가 없다. */
        ...
    }
    
    코드 3: 리테인드 렌더링 사용 예


    3.캔버스 엔진

    캔버스는 렌더링의 상위 개념이다. 우리는 캔버스 엔진 학습에 있어서 이미디어트보다 더 고급 개념인 리테인드 방식의 캔버스 엔진에 집중할 것이다. 리테인드 방식의 캔버스는 렌더링 대상인 UI 객체를 다루면서 필요시 렌더링을 수행한다. 일반적으로 하나의 출력 영역을 갖는 앱은 하나의 캔버스를 가지며 캔버스는 출력 영역에 그려질 화면을 생성한다. 1장에서 살펴보았듯이, 전통적인 시스템의 UI 앱은 윈도우를 통해 출력 영역을 확보하며 윈도우에 자신이 출력할 캔버스의 출력 버퍼를 매핑한다.


    그림 9: 캔버스 출력 버퍼 윈도우 매핑 도식화

    캔버스 엔진을 구현하기에 앞서, 우선 UI앱의 윈도우가 생성될 경우 윈도우와 매핑될 캔버스를 생성하고 초기화하는 구현부를 구축해보자. 현재 캔버스는 블랙박스인 채로 이해해도 무방하다.

    /*
     * UIWindow는 앱의 출력 영역을 결정하는 객체이다.
     * UIWindow 내부적으로 캔버스를 생성하고 초기화한다.
     * 생성한 캔버스는 UI엔진과 연동한다.
     * UIWindow의 동작은 윈도우 관리자의 관리를 받기 때문에 일반 UIObject와는 다른
     * 방식으로 동작을 수행해야 한다.
    */
    UIWindow
    {
        /*
         * 생성할 윈도우 타입을 정의한다. 윈도우 관리자는 윈도우 타입에 따라 관리 정책을
         * 다르게 적용한다. 일부 선택 옵션은 앱의 권한에 따라 허용이 불가할 수 있다.
         *
         * Basic: 일반 UI앱을 위한 윈도우
         * Desktop: 윈도우 관리자가 데스크탑 화면을 출력하기 위한 윈도우
         * Popup: 임시적으로 컨텍스트 전환을 위한 윈도우. 다른 윈도우보다 우선순위를 높다. 
         * Notification: 사용자에게 어떤 정보를 알리기 위한 윈도우
         * ... 
         * 필요에 따라 그 외 다른 타입의 윈도우를 정의/설계한다.
        */
        UIWindowType = {TypeBasic, TypeDesktop, TypePopup, TypeNotification, ... };
        UIWindowType type;       //윈도우 타입 
        UICanvas canvas;         //윈도우에 매핑된 캔버스 객체 
    
        /* 윈도우 시스템에서 제공하는 네이티브 윈도우 객체. 윈도우 관리자와 데이터를
           주고받는 등의 통신을 수행하기 위한 포트(Port) 역할을 수행하며 윈도우 상태를
           요청하거나 전달받는다. */
        NativeWindow window;
    
        /*
         * 생성자.
         * 앱 개발자는 윈도우를 생성할 때 윈도우의 타입을 결정할 수 있다.
         * 윈도우 타입의 기본 값 설정은 BASIC이다.
        */
        constructor(type = UIWindow.TypeBasic)
        {
            //type의 유효성 검사
            ...
    
            //네이티브 윈도우의 타입을 결정한다.
            NativeWindowType windowType;
    
            switch(type)
            {
                case UIWindow.TypeBasic:
                case UIWindow.TypeDialog:
                case UIWindow.TypeDock:
                case UIWindow.TypeView:
                case UIWindow.TypeDesktop:
                    windowType = NativeWindow.TypeTopLevel;
                    break;
                case UIWindow.TypeMenu:
                case UIWindow.TypeNotification:
                case UIWindow.TypePopup:
                case UIWindow.TypeTooltip:
                    windowType = NativeWindow.TypeMenu;
                    break;
                case UIWindow.TypeDnd:
                    windowType = NativeWindow.TypeDnd;
                    break;
                default:            
                windowType = NativeWindow.TypeDefault;
            }
    
            //네이티브 윈도우를 생성한다. 네이티브 윈도우는 내부적으로 캔버스를 생성한다. 
            self.window = new NativeWindow(windowType);
    
            /* 윈도우 관리자에 의해 네이티브 윈도우의 상태가 변경될 수 있으므로 이 경우
               이벤트를 등록해 UIWindow의 상태도 동시에 변경해야 한다. */
            self.window.addEventCb(NativeWindow.EventResize,
                                   //EventResize 이벤트 발생 시 아래 코드가 수행된다. 
                                   lambda(NativeWindow window) { ... }
                                  );
            self.window.addEventCb(NativeWindow.EventShow,
                                   //EventShow 이벤트 발생 시 아래 코드가 수행된다. 
                                   lambda(NativeWindow window) { ... }
                                  );
            self.window.addEventCb(NativeWindow.EventHide,
                                   //EventHide 이벤트 발생 시 아래 코드가 수행된다. 
                                   lambda(NativeWindow window) { ... }
                                  );
            //그 외에 더 많은 이벤트가 존재한다. 
            ...
           
            //캔버스를 생성한다. 
            self.canvas = UICanvas();
    
            //캔버스 엔진을 초기화한다. 매핑할 윈도우의 정보를 전달한다. 
            self.canvas.setupEngine(self.window, ...);
    
            //캔버스를 엔진과 공유한다. 
            UIEngine.canvas(self.canvas);
        }
    
        /*
         * 윈도우의 크기를 변경하면 캔버스의 크기도 변경한다. 
         * 캔버스는 출력 버퍼의 크기를 최신의 크기로 재조정한다.
        */ 
        resize(w, h)
        {
            //캔버스의 크기를 변경한다. 
            self.canvas.resize(w, h);
    
            //윈도우 시스템에게 윈도우 버퍼 크기 변경 사실을 알려야만 한다. 
            self.window.requestSize(w, h);
            ...
        }
    
        /*
         * 윈도우를 화면에 보인다.
        */ 
        show()
        {
            //윈도우 시스템에게 윈도우가 화면에 나타나도록 요청한다. 
            self.window.requestShow();
            ...
        }
    
        /*
         * 윈도우를 화면에서 감춘다.
        */ 
        hide()
        {
            //윈도우 시스템에게 윈도우가 화면에서 사라지도록 요청한다. 
            self.window.requestHide();
            ...
        }
    
        ...
    }
    
    코드 4: 캔버스를 생성하는 윈도우

    코드 4를 보면 UIWindow 내부적으로 NativeWindow가 존재한다. NativeWindow는 윈도우 시스템 인터페이스를 구현하며 서버 역할을 수행하는 윈도우 관리자/컴퍼지터와 통신을 수행하는 포트(Port) 역할을 담당한다고 가정한다. NativeWindow는 UI앱과 윈도우 관리자/컴퍼지터간의 통신 규약을 준수하며 동작 신호를 주고 받을 수 있다. 실제로 리눅스 시스템에서는 윈도우 시스템으로서 X Window와 Wayland을 대표적으로 활용한다.


    그림 10: NativeWindow와 윈도우 관리자 사이의 메시지 통신

     X Window와 Wayland


    X Window는 1984 MIT대학에서 고안한 윈도우 시스템으로 현재 버전 11까지 개발되었으며 긴 역사만큼 많은 기능들을 소화한다. 그에 반에 Wayland는 2012년 릴리즈한 윈도우 시스템으로인 만큼 최신 트렌드 기능 중심으로 X Window보다 경량화된 윈도우 시스템이다. 특히 불편하고 복잡한 인터페이스를 개선하고 X Window 시스템에서 사용되지 않은 불필요한 요소를 제거하여 릴리즈 시 여러 시스템 개발자들로 하여금 많은 관심을 모았다. Wayland는 클라이언트와 컴퍼지터간 캔버스 버퍼를 직접 공유하고 IPC의 보안 취약 요소를 제거하여 보다 안정적이고 효율적이다. 최근 몇 년간 많은 시스템이 X Window시스템으로부터 Wayland 시스템으로 전환하였으며 GNOME, KDE, EFL 등의 리눅스 UI 시스템 역시 Wayland를 지원한다.


    코드 4의 92번째 라인을 보면, UIWindow는 화면 출력을 위해 UICanvas 인스턴스를 하나 생성하여 UIEngine에 전달해 준다. UIEngine은 전달받은 UICanvas를 이용하여 적절한 시점에 렌더링을 요청한다.

    /*
     * UIEngine은 UI 엔진을 구동하는 클래스. UI 앱의 메인루프를 구동하며 시스템, 사용자
     * 이벤트를 처리하고 캔버스를 통해 렌더링이 발생할 수 있게 한다. 하나의 프로세스(앱)은
     * 반드시 하나의 UIEngine을 구동한다. 그렇기 때문에 UIEngine은 싱글턴(singleton) 또는
     * 정적 객체로 구현하는 것이 가능하다.
    */
    UIEngine
    {
        bool stop = true;       //엔진 동작 여부
        UICanvas canvas;        //캔버스 객체
        ...
    
        /*
         * 렌더링을 수행할 캔버스를 지정한다.
        */
        canvas(UICanvas canvas)
        {
            /* 기존에 이미 캔버스가 설정되어 있는 경우 별다른 처리가 필요하다. 하나의
               엔진이 반드시 하나의 캔버스가 보유해야 할까? 사실 여러 개의 윈도우를 보유한 
               앱이 존재할 수도 있다... */        
            if (self.canvas != null)
            {
                ...
            }
    
            self.canvas = canvas;     //멤버변수로 캔버스 객체를 전달한다.
        }
    
        /*
         * 엔진 초기화 작업을 수행한다.
        */
        init()
        {
            /* 비정상 호출. 이미 엔진이 가동 중이다... 발생해선 안된다. 에러 메시지 등의
               적절한 예외처리를 수행한다. */
            if (self.stop == false)
            {
                 System.printError(...);
                 return false;
            }
    
            self.stop = false;
            self.canvas = null;
    
            ...
        }
    
        /*
         * 엔진을 구동한다. 앱의 메인루프에 해당하는 무한루프가 발생하며 일렬의 작업을
         * 지속 수행한다. 그림 1.14 참고
        */
        run()
        {
            //stop()이 호출되면 메인루프도 종료되어야 한다.
            while(self.stop == false)
            {
                //이벤트 대기
                ...
                //이벤트 처리
                ...        
                //캔버스 업데이트
                self.canvas.update();
    
                //캔버스 렌더링 수행
                self.canvas.render();
            }
        }
    
        /*
         * 엔진 가동을 중지한다.
        */
        stop()
        {
            self.stop = true;
            ...
        }
    
        /*
         * 엔진 리소스를 정리하는 작업을 수행한다.
        */
        term()
        {
            //음? 아직 엔진이 가동 중이다... 정상적인 호출일까?
            if (self.stop == false)
            {
                self.stop();
            }
            self.canvas = null;
            ...
        }
    }
    
    코드 5: 엔진의 캔버스 렌더링 수행 코드

    캔버스는 기본적으로 윈도우에 매핑할 출력 버퍼를 생성하고 초기화하는 작업을 수행한다. UI앱의 캔버스 버퍼는 컴퍼지터와 공유되므로 단순히 프로세스에 종속된 메모리 이상으로, 공유 메모리의 특성을 가져야 한다. 다음 코드는 UICanvas에서 버퍼를 초기화하는 과정이다.

    /*
     * UICanvas은 UI 객체의 라이프사이클은 물론 동작을 통제한다. 동시에 렌더링할 
     * 대상의 버퍼를 설정하고 씬그래프(Scene-Graph)를 통해 활동 중인 객체를 렌더링
     * 한다. 캔버스에 입력된 사용자 입력을 좌표값 및 포커스를 통해 올바른 UI 객체로 
     * 전달한다. 하나의 UICanvas 인스턴스는 UIWindow 인스턴스와 1:1로 매핑된다.
    */
    UICanvas
    {
         /* 캔버스 엔진 정보... */
    
         NativeDisplay displayInfo = null;    //디스플레이 정보
         NativeWindow window = null;          //네이티브 윈도우
         NativeSurface surface = null;        //윈도우 서피스 (네이티브 윈도우에 종속)
         NativeBuffer buffer = null;          //캔버스 버퍼 (서피스에 종속)
         RenderContext ctx = null;            //렌더링 컨텍스트
         Size size = {1, 1};                  //캔버스의 크기
         var rotation;                        //화면 회전 각도
         var depth;                           //화면 깊이 정보
         bool alpha;                          //알파 채널?
    
         /* 이하 생략... */
         ...
    
        /*
         * 캔버스 화면 출력
        */
        flush()
        {
            /* 그림을 완성했다고 컴퍼지터에게 신호를 보낸다. 신호를 받은 컴퍼지터는 해당
               윈도우의 버퍼를 이미지로서 컴퍼지팅할 수 있다. */
            self.window.commit(NativeWindow.CommitAsync);
        }
    
    public:
        /*
         * 캔버스 엔진을 설정한다. 전달받은 디스플레이 정보는 출력 형식 정보를 제공하며
           이를 토대로 캔버스 버퍼를 생성한다..
        */
        setupEngine(NativeWindow window, ...)
        {
            self.displayInfo = window.displayInfo();
            self.window = window;
            self.surface = window.createSurface(...);
    
            /* 새로운 크기로 버퍼를 할당한다. 전달받은 displayInfo는 출력장치 정보를
               제공하며 이를 토대로 캔버스 버퍼를 생성한다고 가정하자. */
            self.buffer = NativeBuffer(self.surface, self.width, self.height, RGBA32, IO_WRITE + IO_READ ...);
    
            /* NativeBuffer는 공유하는 기능을 제공한다. 인터페이스에 맞춰 여러 설정 작업을
               수행한다. */
            self.buffer.shareInfo(IPC_PRIVATE | IPC_CREAT | 0600, ...);
    
            // 아래 코드는 현재 중요하지 않다...
            self.ctx = self.surface.context(...);
            self.rotation = window.rotation();
            self.depth = self.surface.depth();
            self.alpha = self.surface.alpha();
    
            ...
        }
    
        /*
         * 캔버스 크기 설정. 주어진 크기로 버퍼를 할당한다.
        */
        resize(width, height)
        {
            //의미없는 인자
            if (width <= 0 || height <= 0) 
            {
                 System.printError(...);
                 return false;
            }
     
            //역시 의미없는 인자
            if (self.size.w == width && self.size.h == height) return false;
    
            self.size.w = width;
            self.size.h = height;
    
            /* 새로운 크기로 버퍼를 재할당한다. 캔버스 버퍼는 더이상 유효하지 않으므로
               이 후 반드시 드로잉을 다시 수행해야 할 것이다. */
            self.buffer.realloc(width, height);
    
            ...
        }
    
        /*
         * 캔버스 업데이트. 렌더링을 수행하기 전에 캔버스에 존재하는 UI 객체를 대상으로
         * 어떠한 사전 준비를 수행한다.
        */
        update()
        {
            //TODO: 캔버스에 존재하는 UI객체를 업데이트한다.
        }
    
        /*
         * 캔버스 렌더링 수행. 캔버스 버퍼에 UI를 그리는 작업을 수행한다.
        */
        render()
        {
             //TODO: 캔버스 버퍼에 UI를 그리는 작업을 수행한다.
    
            self.flush();    //그림 완료 신호를 보낸다.
        }
    }
    
    코드 6: UICanvas 초기화 코드

    코드 6은 캔버스 엔진이 UI객체를 그릴 버퍼를 할당하고 이와 관련된 부수적인 초기화 작업을 수행한다. 이 과정은 setupEngine()에 구현되어 있으며 전달받은 NativeWindow를 통해 필요한 정보를 요청할 수 있다. NativeWindow는 윈도우 관리자/컴퍼지터 사이의 통신 프로토콜 역할을 수행하면서도 UICanvas의 버퍼를 위한 NativeSurface를 제공한다. NativeSurface는 실제로 윈도우 관리자/컴퍼지터와 공유되는 자원으로서 실제 버퍼는 NativeBuffer를 통해 접근이 가능하다. 본 예제에서는 NativeBuffer라는 리소스를 정의하였지만, 실제로 이같은 공유 자원은 각 그래픽스 시스템에서 제공하는 리소스 타입으로 대처할 수 있다. 일반적으로 임베디드 시스템에서는 openGL ES를 활용하여 드로잉 작업을 수행하며 eglSurface를 통해 드로잉 대상을 지정한다. eglSurface가 가리키는 실제 버퍼 메모리는 다른 프로세스와 공유하여 불필요한 메모리 복사 과정을 줄인다. 이는 앞서 살펴본 NativeSurface/NativeBuffer와 개념적으로 동일하다.

    UICanvas.flush()가 발생하면, UI앱(클라이언트)는 윈도우 윈도우 관리자/컴퍼지터로 그림을 완성했다는 메세지를 보낸다. 윈도우 관리자/컴퍼지터 공유받은 버퍼를 기반으로 추가적으로 윈도우 효과를 적용하거나 다른 윈도우 화면과 함께 최종적으로 디스플레이에 출력을 한다. 앞서 살펴본 코드에서 캔버스와 컴퍼지터 사이의 버퍼의 공유 구조를 도식화 하면 다음과 같다.


    그림 11: UI앱과 컴퍼지터 사이의 캔버스 버퍼 공유 구조

    사실상 앱 개발자는 캔버스의 생성/소멸, 렌더링 여부를 확인하기가 어렵다. 불필요한 코어 기능 노출은 사용자로 하여금 치명적인 오류를 유발할 기회를 제공하므로 이와 관련된 일련의 작업은 윈도우 및 엔진 클래스로 감추었다. 이러한 구현 컨셉은 리테인드 렌더링 특성에 부합하기도 하다. 엔진 및 윈도우는 프레임워크 기본 구조를 바탕으로도 다르게 설계할 수 있다. 대표적으로 안드로이드 시스템의 경우에는 윈도우 관리지와 더불어 서피스플링거(SurfaceFligner)가 컴퍼지터 역할을 수행하며 각 UI 앱의 화면을 조합하여 최종적으로 화면에 출력한다.

    출력 버퍼 설정을 완료했다면, 캔버스는 실제로 그림을 그리기 위한 도구 준비를 마친 셈이다. 하지만 캔버스가 그려야 할 대상은 무엇일까? 캔버스가 렌더링을 수행하기 위해서는 렌더링할 정보가 필요하다. 그 정보는 캔버스에 거주하는 UI 객체에 저장되어 있다. 캔버스는 생성된 UI 객체를 대상으로 렌더링을 수행할 수 있으며 실제로 앱에서 생성한 UI 컨트롤 객체 역시 캔버스가 내부적으로 관리하며 적절한 시점에 이들을 이용해 렌더링을 수행한다.

    기본적으로 UICanvas는 생성된 객체를 리스트로 관리하며 이들의 상태를 추적하며 렌더링을 할지 말지를 결정할 수 있다. 캔버스는 수시로 화면 갱신(보통 초당 60번)을 해야 하므로 객체를 관리하는 방식 역시 가급적 효율적이어야 한다. 특히나 UI앱의 특성 상, 화면을 구성하는 UI가 수시로 변경될 가능성이 크기 때문에, UI 객체 역시 생성/삭제가 빈번히 발생한다는 점을 염두해야 할 것이다. 보통 캔버스에 거주할 객체의 개수는 런타임 시 결정되므로, 객체를 리스트 형태로 구성하는 것도 무난하다.


    그림 12: UI 객체를 대상으로 렌더링을 수행하는 캔버스

    UICanvas { ... List<UIObject> objs; //생성된 오브젝트 목록 /* * 캔버스에 새로운 오브젝트를 추가한다. */ addObj(obj) { /* 오브젝트가 캔버스 외부에서 삭제되는 것을 방지하기 위해 참조되고 있다는 사실을 기록한다. */ obj.ref(); self.objs.append(obj); } /* * 캔버스에서 기존 오브젝트를 제거한다. */ removeObj(obj) { self.objs.remove(obj); obj.unref(); //더 이상 참조되지 않는다. } /* * 소멸자 */ destructor() { ... //소멸되기 전 역시 오브젝트 참조를 해제한다. foreach(self.objs, obj) obj.unref(); ... } public: /* * 캔버스 업데이트. 렌더링을 수행하기 전에 캔버스에 존재하는 UI 객체를 대상으로 * 어떠한 사전 준비를 수행한다. */ update() { //캔버스에 존재하는 UI객체를 업데이트한다. foreach(self.objs, obj) obj.update(); } /* * 캔버스 렌더링 수행. 캔버스 버퍼에 UI를 그리는 작업을 수행한다. */ render() { /* 캔버스 버퍼에 UI를 그리는 작업을 수행한다. 이 로직이라면, 생성된 모든 오브젝트를 그리게 된다... */ foreach(self.objs, obj) obj.render(); self.flush(); //그림 완료 신호를 보낸다. } }

    코드 7: UIObject를 다루는 UICanvas 코드

    UICanvas의 update()와 render()를 보면, 생성된 모든 객체에 대해 처리하는 것을 볼 수 있다. 실제로 UI앱의 구현에 따라, 캔버스에 추가된 객체의 개수는 상당히 많을 수 있다. 생성된 객체가 실제로 화면에 모두 보인다고 가정하기 어렵기 때문에 앞선 구현 로직은 다소 비효율적으로 보인다. 하지만, 렌더링 로직은 추후에 개선하도록 하고 일단은 여기서는 UICanvas와 UIObject가 어떻게 연동되고 이들 간의 렌더링이 어떻게 호출되는지 이해하는 것만으로도 충분하다.


    4. 오브젝트 모델

    캔버스 버퍼가 화면에 UI를 출력하기 위한 도화지라면, 2.3절의 구현을 통해 UICanvas는 화면에 무언가를 그릴 조건을 갖춘 셈이다. 실제로 UIEngine은 UICanvas.render()를 호출하여 그리는 작업을 수행할 것이다. 하지만 화면에 존재하는 수많은 UI 객체를 효율적으로 그리기 위해서는 데이터를 체계화 할 필요가 있다.

    오브젝트 모델은 UI 객체의 데이터를 구조화 한다. HTML의 도큐먼트 오브젝트 모델 트리(Document Object Model Tree)처럼 오브젝트를 부모-자식 관계로 연결하고 오브젝트 사이의 관계 및 특성을 타입별로 구분할 수 있다면 오브젝트를 탐색하고 업데이트하며 렌더링을 보다 안정적이고 빠르게 처리할 수 있다.


    그림 13: HTML 도큐먼트 오브젝트 모델 트리 (W3C)

    UICanvas가 UI 객체를 엔진 내부적으로 동일하게 처리할 수 있도록 뼈대에 해당하는 기반 클래스를 제공한다면, 추후 다양한 타입의 UI 컨트롤을 쉽게 확장해 나갈 수 있을 것이다. 이는 UICanvas의 수정없이 UI 컨트롤을 확장할 수 있기 때문에 설계 관점에서 필수이다. 그뿐만 아니라, 다양한 UI 객체의 공통 특성 및 동작을 재사용할 수 있으며, 앱 개발자 역시 다양한 UI 객체를 동일한 방식으로 구현하여 앱의 UI를 구현할 수 있다. 실제로 많은 UI 시스템에서는 UI 컨트롤를 상속구조로 확장/구현한다. IOS의 경우 NSObject라는 기저 클래스를 구현하고 이를 상속하여 다양한 UI 컨트롤을 확장한다.


    그림 14: IOS Cocoa 클래스 상속 구조 (일부)

    본 시스템에서의 UIObject는 UI 컨트롤의 기본 클래스로서 UI 객체가 수행해야할 기본 동작의 인터페이스를 제공한다. UIObject를 구현함으로써 다양한 타입의 UI 객체를 확장하여 정의할 수 있다. 1장의 예제로서 잠깐 활용했던 UISearchbar 및 UIButton 역시 UIObject의 자식 클래스로 확장 가능하다.


    그림 15: UIObject 클래스 상속 예

    그림 15는 UIObject와 하위 UI컨트롤 사이의 관계의 복잡도를 최대한 단순화시킨 예이지만, 앞으로 살펴볼 UIObject가 어떤 위치에 있는지를 명확하게 보여준다.

    다음 그림은 UICanvas에서 동작하는 UIObject의 클래스 다이어그램이다. 주요 속성과 동작만 정의해 보자.


    그림 16: UIObject 클래스 다이어그램

  • type: 오브젝트 타입 이름. UIButton의 경우 type은 “UIButton” 을 보유한다.
  • refCnt: 현재 객체에 접근하고 있는 참조 횟수.
  • geom: 위치, 크기 지오메트리 정보
  • layer: 레이어 위치. 레이어의 값이 클수록 상단에 표시된다.
  • canvas: 오브젝트가 종속된 UICanvas의 인스턴스
  • parent: 부모 UIObject
  • children: 자식 UIObject 리스트
  • visible: 가시 상태 여부 (화면에 보이는가?)
  • changed: 내부 상태 변화가 발생했는지 여부
  • deleted: UICanvas로부터 삭제 요청을 받은 경우 true이며 이 경우, 객체는 더 이상 유효하지 않다. deleted가 true인 상황에서 객체에 접근이 발생할 경우, 에러 메시지를 출력할 수 있다. C 언어와 같은 저수준 프로그래밍 언어처럼 안전한 메모리 접근 메커니즘을 제공하지 않는 경우에는 태그 식별자를 이용하여 메모리의 유효성을 추가로 검증할 수 있다.

  •  레이어(layer)


    쉽게 포스트잇 용지를 생각하면 이해하기 쉽다. 어떤 용지가 최상단에 위치하고 있는가? 화면 상에 겹겹이 쌓인 UI 객체의 경우, 레이어의 값을 통해 스택 순서를 결정한다. 레이어의 값을 상향 또는 하향 조율하여 객체의 위치를 변경할 수 있다.



     태그(tag) 식별자


    프로그래밍 언어가 런타임으로 동작하는 비교적 안전한 메모리 관리 메커니즘을 제공하더라도 UICanvas 스스로 UIObject의 메모리를 꼼꼼하게 통제하고 싶다면 tag는 유효하다. tag는 오브젝트의 특정 필드에 유니크한 값을 기록하고 이후 오브젝트의 메모리에 접근할 때 마다 이 필드의 값을 확인하여 메모리가 유효한지 검증한다. 메모리가 유효하지 않다면 엔진 레벨에서 오브젝트 접근을 방지할 수 있다. UI 시스템 독자적인 메모리 공간(Memory Pool)을 사용하는 경우에는 메모리 공간이 보장되기 때문에 이러한 방지가 가능하다. 하지만 태그 식별자를 사용하더라도 엔진 독자적인 메모리 공간을 사용하지 않는다면 간헐적으로 앱의 메모리 사용 위반에 문제가 발생할 수도 있다. 특히 가상 메모리 시스템에서는 이미 해제된 사용자 영역의 메모리일지라도 커널 레벨에서의 해당 주소의 페이지가 보존되고 있다면 해당 메모리 접근 시 세그멘테이션 폴트(Segmentation Fault)가 발생하지 않는다. 이 경우, 태그 식별자를 통해 메모리 접근 오류 메시지를 출력할 수 있다. 반면, 페이지가 삭제되어 더이상 존재하지 않는 메모리 주소인 경우에는 커널은 세그먼테이션 폴트를 발생시킨다. 이 경우 프로세스가 바로 중단되기 때문에 프로세스가 크래시(crash)로부터 더 이상 안전하지 않다. 이러한 동작 여부는 커널의 메모리 관리 유닛(MMU)에 달려있으며 이로 인해 발생하는 일관적이지 않는 동작은 오히려 문제를 감추거나 앱 개발자를 혼란에 빠뜨릴 수도 있다.



    다음은 UIObject 구현부이다.

    /* * UIObject는 모든 UI 컨트롤의 기저(base) 클래스에 해당하며 UI 객체의 기본 동작 및 * 속성을 구현한다. UICanvas에 종속되며 여기서 보여주는 예시는 모델을 매우 간소화하여 * 핵심만 보여주고자 함을 이해하자. */ UIObject { //friends 지정으로 UICanvas는 UIObject의 내부 기능에 접근 가능하다. friends UICanvas; String type; Var refCnt = 0; Geometry geom = {0, 0, 0, 0} Var layer = 0; UICanvas canvas; UIObject parent; List<UIObject> children; Bool visible = false; Bool changed = true; Bool deleted = false; /* * 생성자 * canvas: UIObject가 종속된 캔버스 * type: 객체의 타입 이름 * parent: 생성할 UIObject의 부모 객체. 지정하지 않으면 최상위 객체이다. */ constructor(canvas, type, parent = null) { if (canvas.ready() == false) { //캔버스가 준비되어 있지 않다... 예외 처리를 수행한다. } self.canvas = canvas; self.type = type; //캔버스에 오브젝트를 추가한다. canvas.addObj(self); self.parent(parent); //부모를 지정한다. } /* * 레퍼런스 카운팅 메커니즘을 흉내낸다. 자바처럼 가비지 컬렉터 위에 레퍼런스 * 카운팅이 내장되어 있는 경우 따로 구현할 필요가 없다. ref()는 컴파일러의 * 해석으로 객체를 생성하거나, obj = obj2; 와 같이 객체 레퍼런스 복사가 발생할 때 * 호출된다고 가정한다. */ ref() { //이미 삭제를 요청받은 인스턴스이다. 더 이상의 레퍼런스는 허용하지 않는다. if (self.deleted == true) return; ++refCnt; } /* * 레퍼런스 카운팅 메커니즘을 흉내낸다. 자바처럼 가비지 컬렉터 위에 레퍼런스 * 카운팅이 내장되어 있는 경우 따로 구현할 필요가 없다. unref()는 컴파일러의 * 해석으로 obj = null; 처럼 객체를 제거하거나 스택 영역이 종료될 때 호출된다고 * 가정한다. */ unref() { --refCnt; //더 이상 레퍼런스가 존재하지 않는다. dispose()로 제거하자. if (refCnt == 0) dispose(); } /* * 부모를 재지정한다. * parent: 지정할 부모 객체. null인 경우 현재 객체는 독립한다. */ parent(UIObject parent) { if (self.deleted == true) return; //부모와 이 객체의 캔버스가 다르다? 허용하지 않는다! if (parent && parent.canvas != self.canvas) { System.printError(“...”); return; } //기존 부모로부터 현재 객체를 제거한다. if (self.parent) self.parent.removeChild(self); //새로운 부모가 null일 수도 있다!! if (parent) parent.addChild(self); self.parent = parent; self.changed = true; } /* * 자식 객체를 추가한다. * child: 추가할 자식 객체 */ addChild(UIObject child) { if (self.deleted == true) return; //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. if (self.children.exists(child) == true) return; self.children.add(child); child.parent(self); self.changed = true; } /* * 자식 객체를 제거한다. * child: 제거할 자식 객체 */ removeChild(UIObject child) { if (self.deleted == true) return; //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. self.children.remove(child); child.parent(null); self.changed = true; } /* * 이 객체를 캔버스에 그리는 작업을 수행한다. */ render() abstract { if (self.deleted == true) return; //TODO: 어떻게?... } /* * 이 객체의 상태를 새로 갱신하다. */ update() abstract { if (self.deleted == true) return; //변경된 사항이 없다. if (self.changed == false) return; //TODO: 어떻게?... } public: /* * 화면(캔버스)에 보인다. */ show() { if (self.deleted == true) return; self.visible = true; self.changed = true; } /* * 화면(캔버스)에서 감춘다. */ hide() { if (self.deleted == true) return; self.visible = false; self.changed = true; } /* * 객체의 위치를 지정한다. * x: x 좌표값 * y: y 좌표값 */ move(x, y) { if (self.deleted == true) return; self.geom.x = x; self.geom.y = y; self.changed = true; } /* * 객체의 크기를 지정한다. * w: 가로 크기값 * h: 세로 크기값 */ resize(w, h) { if (self.deleted == true) return; self.geom.w = w; self.geom.h = h; self.changed = true; } /* * 레이어의 값을 변경한다. * layer: 새 레이어 값 */ restack(layer) { if (self.deleted == true) return; self.layer = layer; self.changed = true; } /* * UI 객체가 더 이상 필요없는 경우 호출하여 캔버스로부터 제거한다. */ dispose() { if (self.deleted == true) return; self.deleted = true; --refCnt; //부모가 있는 경우, 부모로부터 연결을 끊는다. if (self.parent) self.parent.removeChild(self); self.children.clear(); //자식이 있는 경우 자식도 제거한다. //캔버스에서 오브젝트를 제거한다. self.canvas.removeObj(self); /* 더 이상 사용하지 않는 인스턴스이므로 Memory Management Unit에게 메모리 반환을 요청한다. 예시로 보여줄 뿐, 자바의 가비지 컬렉터처럼 메모리 관리 시스템이 따로 있는 경우 이러한 구현은 필요없다. */ if (refCnt <= 0) System.MMU.retrieve(self); } /* * 부모 객체를 반환 */ const parent() { if (self.deleted == true) return; return self.parent; } /* * 자식 목록을 반환 */ const children() { if (self.deleted == true) return; return self.children; } /* * 객체의 지오메트리 값을 반환한다. */ const geometry() { if (self.deleted == true) return; return self.geom; } }

    코드 8: UIObject 클래스

    2절에서 설명했던 이미지, 텍스트, 벡터 출력 역시 역시 UIObject를 확장하여 구현하며 실제로 UIObject는 모든 UI 객체의 기반이 되는 동작을 수행하므로 반드시 눈여겨 보도록 하자.

    하나의 UIObject 객체는 다수의 자식을 보유할 수 있는데, 실제로 하나의 UI 컨트롤은 이미지, 텍스트 등 여러 요소가 모여서 하나의 컨트롤을 구성한다. 그림 1.3의 검색 상자의 경우에도 검색 상자를 표현하는 배경 이미지, 가이드 텍스트 그리고 아이콘 등 다수의 UIObject가 모여서 하나의 컨트롤을 구성할 수 있다.


    그림 17: UISearchBar 계층 구성도

    UIObject는 다수의 자식을 보유함으로써 트리(tree)를 구축한다. UIObject가 내부적으로 parent와 children를 구현하고 있는 이유이다. 이를 위해 parent(), addChild(), removeChild()를 구현한다. 부모는 자식을 소유함으로써 자식 객체의 위치 및 크기는 물론, 자식 고유의 동작도 제어하고 관리할 수 있다. 만일 자식에 해당하는 UI 객체의 인스턴스를 UI앱이 직접 접근할 수 있다면, 부모 객체와 UI앱 둘 모두 자식 객체에 접근이 가능하므로 동작 충돌에도 염두해야 한다. 예를 들면, UI앱이 UISearchbar의 아이콘을 멋대로 변경하거나 삭제해 버린다면 UISearchbar 본연의 기능을 제대로 보여주지 못할 수도 있다. 프레임워크 개발 관점에서는 이러한 예외 가능성을 염두하여 UI 컨트롤의 인터페이스를 디자인하거나 내부 동작을 구현시 다양한 가능성을 염두하여 오류가 최대한 발생하지 않도록 주의해야 한다.

    기본적으로 UIObject는 dispose()를 통해 캔버스로부터 제거되며 레퍼런스 카운트가 0이 될때야 비로소 메모리로부터 삭제된다. 이는 안전한 메모리(safe memory) 메커니즘을 모방하고 프로세스의 크래시(crash)를 방지하기 위한 자체적인 방편이다. 만약 dispose()가 요청되었음에도 불고하고 레퍼런스 카운트가 0보다 크다면 deleted만 태깅한 후, 레퍼런스(refCnt)가 0이 될 때까지 삭제를 보류한다. 이 후 어디선가 해당 인스턴스의 기능에 접근한다면 그 어떠한 동작도 수행하지 않도록 방어한다. 이를 위해 다음과 같은 코드를 UIObject의 메서드에 추가한 것을 확인할 수 있다.

    if (self.deleted == true) return;

    코드 9: 지연 삭제를 위한 방어 코드

    사실 이미 dispose()가 요청된 인스턴스에 어떤 기능 요청이 발생한다면 이것은 프로그래밍 로직의 오류에 해당한다. 개발자에게 이러한 사실을 알려준다면 프로그램 개발에 큰 도움이 될 것이다. 기본적으로 오류 메시지를 출력하거나, 보다 강건한 프로그램을 작성해야 한다면 abort()를 발생시킬 수도 있다.

    if (self.deleted == true) { //개발 단계에서만 동작한다. #if DEVEL_MODE System.printError(“This object is invalid! please debug your program! …”); abort(); #endif return; }

    코드 10: 지연 삭제를 위한 보다 용이한 방어 코드

    이러한 코드를 매 메서드마다 추가하기보다는 시스템의 로깅 또는 디버깅 시스템을 구축한 후 일괄 적용하는 것이 더 바람직하다.

    if (self.deleted == true) { /* SystemLog는 요청한 메시지를 파일 내지 콘솔에 출력하거나, 심지어 네트워크를 통한 메시지를 전달할 수 있는 기능을 구현한다. 로그 레벨로(1, 2, 3 ...) abort() 여부를 결정할 수 있으며 해당 프로세스와 관련된 부가 정보 및 콜스택 정보도 같이 출력하여 개발자의 디버깅 작업에 도움을 줄 수 있다. 제품 릴리즈시에는 내부 동작을 비활성화 시킬 수 있는 옵션도 제공 가능하다. */ SystemLog.printError(SystemLog.LOG_LEVEL1, “This object is invalid! please debug your program! …”); return; }

    코드 11: 개발자를 위한 로그 시스템 활용

    UIObject 자체는 화면 출력을 위한 비주얼 정보를 보유하고 있지 않으므로 update()와 render()는 별다른 구현이 존재하지 않는다. 대신 UIObject를 확장하여 update()와 render()를 오버라이드하거나, 혹은 자식이 존재한다면 이들의 update()와 render()를 통해 비주얼 정보를 보여줄 수 있다. 두 메서드를 추상 메서드(abstract)로 지정한 이유도 거기에 있다.


    5. 씬그래프

    씬그래프(Scene-Graph) 또는 장면 그래프라고도 하며 일반적으로 하나의 가상 공간에서 여러 객체를 순차적으로 렌더링할 때 응용할 수 있는 하나의 자료 구조이다. 씬그래프는 각 객체(노드)를 통해 객체의 지역 공간 내 장면을 구축하고 이러한 객체들의 조합하여 최종 스크린을 생성하기 위한 하나의 메커니즘으로서 활용된다. UIObject 하나의 객체가 자신만의 뷰(View) 또는 룩(Look)을 구성한다면, UICanvas도 씬그래프 기법을 활용하여 이러한 객체들을 조합, 최종 장면을 구축할 수 있다. 이해를 돕기 위해, 1장에서 보았던 크롬 브라우저의 구글 페이지를 다시 살펴보자.


    그림 18: 크롬 브라우저 구글 페이지

    그림 18의 구글 페이지 화면을 씬그래프로 구성한다면 다음과 같다.


    그림 19: 크롬 브라우저 구글 페이지 씬그래프

    그림 19의 씬그래프 예제는 편의를 위해 눈에 보이지 않는 여러 계층을 생략하거나 하나의 요소로 합쳐서 표현하였기 때문에 실제 구글 페이지의 내부 구성과 다를 수 있지만 일반 UI 앱을 구현하는데 있어서 씬그래프를 활용한 화면 구성과 개념적으로는 다르지 않다. UIWebView는 보유하고 있는 자식들을 순회하면서 각 자식 객체의 비주얼 결과물 혹은 뷰를 조합하며 최종적으로 그림 18과 같은 크롬 브라우저 화면을 표현할 수 있다.

    이러한 개념을 구현하기 위해 UIObject는 내부적으로 부모-자식 관계를 구축하고 update(), render()를 오버라이딩 가능하도록 인터페이스를 설계함으로써 씬그래프(Scene-Graph)를 구현한다. 실제로 코드에서 update()와 render()를 abstract로 지정한 이유도 거기에 있다.

    객체지향 관점에서 씬그래프를 위한 별도의 인터페이스 또는 클래스를 UIObject로부터 분리하여 정의하는 것이 관리 및 확장 측면에서 더 효율적이기 때문에 실제로 UIObject 모델을 다음과 같이 조정한다.


    그림 20: UIObject로부터 씬그래프 분리

    UISGNode의 멤버를 UIObject에게 개방함으로써 UIObject는 parent 및 children에 직접 접근이 가능하도록 하고 최소한의 메서드만 추가했다. 사실, 이들의 접근 제한자를 private로 지정하였다면 이들 멤버에 접근하기 위한 보다 많은 메서드가 요구되었을 것이다. 너무 많은 예제 코드의 분량은 맥락을 이해하는데 걸림돌이 될 것 같아서 접근 제한을 낮추고 구현도 생략하였다. 하지만, 본질적으로 독립적이면서 안정적인, 재사용이 가능한 UISGNode를 설계하기 위해서는 디자인 원칙에 기반하여 접근 제한자를 지정해야 한다.

    본 예제에서는 UISGNode를 UIObject의 상속(is-a) 관계로 설계하였지만 사실 소유(have) 관계로 구축하더라도 별다른 문제는 없어 보인다. 이는 설계자의 심사숙고한 디자인 철학에 맡긴다.

    /* * UISGNode: UI Scene-Graph Node * 씬그래프 트리를 구축한다. * 부모와 자식들 간의 접근을 통해 트리를 순회할 수 있다. * UISGNode에 연결된 객체 타입은 템플릿 형식으로 지정하여 실제 객체와 UISGNode간의 * 상호 의존성을 제거한다. */ template <class T> UISGNode { T parent = null; List children; /* * 노드의 상태를 초기화 한다. */ reset() { //부모가 있는 경우, 부모로부터 연결을 끊는다. if (self.parent) self.parent.removeChild(self); //자식이 있는 경우 자식도 제거한다. self.children.clear(); } public: /* * 부모를 재지정한다. * parent: 지정할 부모 객체. null인 경우 현재 객체는 독립한다. */ parent(T parent) { //기존 부모로부터 현재 객체를 제거한다. if (self.parent) self.parent.removeChild(self); //주의: 새로운 부모가 null일 수도 있다!! if (parent) parent.addChild(self); self.parent = parent; } /* * 자식 객체를 추가한다. * child: 추가할 자식 객체 */ addChild(T child) { //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. if (self.children.exists(child) == true) return; self.children.add(child); child.parent(self); } /* * 자식 객체를 제거한다. * child: 제거할 자식 객체 */ removeChild(T child) { //child가 null인 경우 예외처리가 필요하다. null은 허용하지 않는다. self.children.remove(child); child.parent(null); } }

    코드 12: UISGNode(UI Scene-Graph Node) 구현부

    UISGNode 클래스를 정의하였으므로 이제 UIObject는 씬그래프 트리와 관련된 기능을 UISGNode에게 위임하기만 하면 된다.

    /* 신그래프 노드를 확장한다. UISGNode를 통해 부모와 자식에 접근 가능하며 코드 7에서 UIObject가 구현하고 있던 트리 기능을 UISGNode의 기능으로 대체한다. */ UIObject extends UISGNode<UIObject> { String type; Var refCnt = 0; Geometry geom = {0, 0, 0, 0} Var layer = 0; UICanvas canvas; Bool visible = false; Bool changed = true; Bool deleted = false; ... }

    코드 13: UIObject로부터 씬그래프 기능 분리(멤버 선언부)

    본래 UIObject에서 구현하던 parent, children 멤버는 UISGNode로 대처하였기 때문에 UIObject에서 직접 보유하던 parent, children은 더 이상 필요가 없다. 반면 이들을 위한 일부 메서드는 다음과 같이 수정한다.

    UIObject extends UISGNode<UIObject> { ... /* * 생성자 * canvas: UIObject가 종속된 캔버스 * type: 객체의 타입 이름 * parent: 생성할 UIObject의 부모 객체. 지정하지 않으면 최상위 객체이다. */ constructor(canvas, type, parent = null) { if (canvas.ready() == false) { //캔버스가 준비되어 있지 않다... 예외 처리를 수행한다. } self.canvas = canvas; self.type = type; //주의: 이 코드는 더이상 필요없다. 대신 parent() 코드를 보자. //canvas.addObj(self); self.parent(parent); //부모를 지정한다. } /* * 부모를 재지정한다. * parent: 지정할 부모 객체. null인 경우 현재 객체는 독립한다. */ parent(UIObject parent) override { //부모와 이 객체의 캔버스가 다르다? 허용하지 않는다! if (parent && parent.canvas != self.canvas) { System.printError(“...”); return; } super(parent); /* 주의: 기본적으로 부모가 자식을 관리하지만, 부모가 없다면 캔버스에서 오브젝트를 관리한다. 부모가 없는 경우에만 캔버스에 추가하자. */ if (!parent) self.canvas.addObj(self); self.changed = true; } /* * 자식 객체를 추가한다. * child: 추가할 자식 객체 */ addChild(UIObject child) override { super(child); self.changed = true; } /* * 자식 객체를 제거한다. * child: 제거할 자식 객체 */ removeChild(UIObject child) override { super(child); self.changed = true; } /* * UI 객체가 더 이상 필요없는 경우 호출하여 캔버스로부터 제거한다. */ dispose() { if (self.deleted == true) return; self.deleted = true; --refCnt; //노드 상태를 초기화한다. self.reset(); //부모가 없다면, 캔버스에서 오브젝트를 제거한다. if (!self.parent) self.canvas.removeObj(self); /* 더 이상 사용하지 않는 인스턴스이므로 Memory Management Unit에게 메모리 반환을 요청한다. 예시로 보여줄 뿐, 자바의 가비지 컬렉터처럼 메모리 관리 시스템이 따로 있는 경우 이러한 구현은 필요없다. */ if (refCnt <= 0) System.MMU.retrieve(self); } /* * 부모 객체를 반환 */ const parent() { if (self.deleted == true) return; return self.parent; } /* * 자식 목록을 반환 */ const children() { if (self.deleted == true) return; return self.children; } ... }

    코드 14: UIObject로부터 씬그래프 기능 분리(메서드 구현부)

    실제로 씬그래프 기반으로 렌더링이 올바르게 수행되기 위해 우리는 UIObject의 update()와 render() 코드를 다음과 같이 확장한다. 구현 핵심은 부모가 자식들을 순회하면서 렌더링을 재귀적으로 호출할 수 있도록 알고리즘을 구현하는 것이다.

    /* * 이 객체를 캔버스에 그리는 작업을 수행한다. */ UIObject.render() abstract { if (self.deleted == true) { SystemLog.printError(...); return; } //TODO: 해당 객체의 렌더링을 수행한다... 어떻게? //이어서, 자식들이 렌더링을 수행할 수 있도록 render()를 호출해 준다. foreach(self.children, child) { //기본적으로 visible 상태가 아니면 렌더링을 할 필요가 없다. if (child.visible) child.render(); } } /* * 이 객체의 상태를 새로 갱신하다. */ UIObject.update() abstract { if (self.deleted == true) { SystemLog.printError(...); return; } //우선, 자식들의 상태를 업데이트 한다. foreach(self.children, child) child.update(); //변경된 사항이 없다. if (self.changed == false) return; //TODO: 렌더링 위한 준비작업 또는 객체 특성에 따른 무언가를 수행한다. }

    코드 15: UIObject의 render()와 update() 구현

    UIObject 클래스 자체는 어떠한 비주얼 결과물을 만들어내는 클래스가 아니기 때문에 아직은 render() 및 update()가 실제로 무엇을 처리하는 지 고민하지 말자. 다만, UIObject는 씬그래프를 통해 자식들을 재귀적 순회하면서 render() 및 update()를 호출한다.

    씬그래프의 구조는 그림 12의 캔버스의 오브젝트 리스트에도 변화를 가져온다. 이제는 모든 생성 객체가 캔버스의 리스트에 선형적으로 연결된 구조가 아닌, 최상단 부모만 캔버스의 오브젝트 리스트에 추가되며 자식들은 각 부모가 관리하는 구조로 바뀐다. 만약, 부모가 렌더링 대상이 아니라면 그 자식은 검토할 필요도 없는 문제이다. 이는 캔버스가 업데이트 및 렌더링을 위해 모든 객체를 스캔하는 부담을 줄일 수 있으므로 성능 차원에서도 큰 도움이 된다.


    그림 21: 씬그래프 기반의 데이터 연결 구조

    이제 UI 객체의 render()가 어떤 이미지 결과물을 만들어 낸다고 가정했을 때, 어떤 UI 객체의 render()를 호출할 경우 다음 그림처럼 트리를 순회하며 이미지를 완성할 것이다.

    그림 22: 씬그래프 기반 렌더링 수행

    그림 22의 각 화살표의 번호는 렌더링 순서를 가리킨다. 씬그래프 기반에서 오브젝트는 자식 트리를 전위순회(Pre-order Traversal)하면서 렌더링을 요청한다. 각 자식은 렌더러를 통해 드로잉을 수행하며 생성된 드로잉 결과물은 부모 객체의 렌더링 일부가 된다. 최종적으로 UICanvas가 직접 접근하는 오브젝트, 즉 최상위 부모는 완성된 하나의 렌더링 결과물을 생성할 수 있다.

    씬그래프의 렌더링 순서를 이해하면, 어떤 객체가 어떤 객체의 위에 그려지는 지 이해할 수 있다. 캔버스에 종속된 최상위 부모의 렌더링 순서는 캔버스의 UIObject List에 추가된 순서에 영향을 받으며 자식 객체들의 렌더링 순서는 부모의 children 리스트에 추가된 순서에 영향을 받는다. 최상위 부모의 객체 드로잉의 순서를 변경하고 싶다면, 레이어의 순서를 조절해야 한다. 앞서 UIObject 속성 중 layer 를 추가한 이유이기도 하다. 기본적으로 obj.layer(); 를 통해 레이어 순서를 지정할 수도 있지만, 특정 객체를 대상으로 위, 또는 아래를 지정할 수도 있을 것이다. 이를 위해 above(), below(), mostTop(), mostBottom()와 같은 유용한 메서드를 추가로 제공할 수 있다. 주의할 점은, 레이어의 영향 범위를 자신이 존재하는 공간으로 제한해야야 한다. 캔버스에 직접 추가된 객체는 UIObject List를 대상으로 레이어 순서를 경합하고 부모 객체의 children으로 추가된 객체는 해당 children을 대상으로 레이어 순서를 경합한다. 새로운 오브젝트를 추가할 때마다 레이어의 순서를 참고하여 각 리스트의 연결 순서를 지정할 수 있다. 이미 추가된 오브젝트의 레이어의 순서가 변경될 경우에는 리스트의 연결 순서를 중간에 바꿔야 한다. 레이어의 순서가 특별히 지정되지 않는 경우(기본 값인 경우)에는 리스트의 맨 끝에 추가함으로써 해당 공간에서의 최상단에 위치하게 할 수 있다.

    obj.above(obj2); //obj를 obj2 바로 위로 이동한다. obj.below(obj2); //obj를 obj2 바로 밑으로 이동한다. obj.top(); //obj의 레이어를 한칸 상승시킨다. obj.bottom(); //obj의 레이어를 한칸 하강시킨다. obj.topMost(); //obj를 가장 최상단으로 이동시킨다. obj.bottomMost(); //obj를 가장 최하단으로 이동시킨다.

    코드 16: UIObject의 레이어 변경(렌더링 순서 변경)


    그림 23: 레이어 순서 조절 결과

    코드 16의 실제 메서드 구현와 더불어, 레이어와 관련된 기능 구현은 생략한다. 개념적으로 이들은 연결 리스트의 노드 이동에 불과하며 구현 역시 크게 어렵지 않다.


    6. 정리하기

    이번 장에서 우리는 UI 렌더링의 개념과 캔버스 엔진을 살펴보는 시간을 가졌다. 캔버스는 UI 앱이 화면에 UI를 출력할 수 있는 기능을 제공하였다. 기본적으로 윈도우에 매핑될 출력 버퍼를 생성하고 생성된 버퍼가 출력 시스템과 연동될 수 있는 기본 설정 작업을 수행했다. 캔버스 엔진은 동작 컨셉에 따라 리테인드 혹은 이미디어트 렌더링 방식을 구현하며 이미디어트 방식은 사용자가 원하는 시점에, 원하는 영역에 UI를 그릴 수 있도록 인터페이스를 제공하였다. 반면, 리테인드 방식은 렌더링과 관련된 모든 기능을 감추고 백그라운드에서 렌더링을 알아서 수행하는 컨셉을 제공하였다. 리테인드 방식은 이미디어트 대비 렌더링과 관련된 섬세한 작업이 불가능하지만 앱 개발자가 렌더링에 깊은 이해가 없어도 쉽고 빠르게 앱의 UI를 개발할 수 있는 장점을 제공함을 알 수 있었다. 이러한 동작 컨셉을 위해 캔버스 엔진은 기본적으로 캔버스 상에 동작하는 UI 오브젝트 모델을 설계하며 이러한 모델을 기반으로 다양한 컨트롤을 확장할 수 있는 인터페이스를 구현하였다. 그뿐만 아니라, 캔버스 엔진은 캔버스에 추가된 UI 객체를 효율적으로 다루기 위해 씬그래프 데이터 구조를 통해 오브젝트 트리를 구성하였다. 캔버스 엔진은 씬그래프 트리를 주기적으로 탐색하면서 UI 객체의 상태를 업데이트하고 렌더링을 수행할 수 있음을 알 수 있었다. 마지막으로 UI 객체의 렌더링 순서를 조작하기 위해 레이어의 개념을 살펴보았다.



    최근 사용자 UI의 중심에는 다양한 종류의 모바일 기기가 존재한다. 과거의 데스크탑 환경처럼 단순히 여러 해상도를 고려하는 앱을 개발하는 시대는 이미 오래 전의 이야기이다. 앱 개발자는 더 많은 사용자 확보를 위해 다양한 기기와의 호환성을 갖춘 앱을 제작하길 희망한다. 이를 위해 해상도는 물론 dpi(dots per Inch) 그리고 터치 스크린과 같은 입력 장치 등을 고려하여 앱을 설계, 제작해야 한다.

    호환성을 갖춘 앱 제작에 있어서 비교적 어려운 문제 중 하나는 다양한 스크린 환경과 입력 장치에 대응하는 스케일러블한 UI를 구현하는데 있다. 스케일러블 UI(Scalable UI)란 다양한 해상도 및 크기의 화면에 대응하는 UI를 의미한다. 모바일 기기부터 데스크탑, TV까지 다양한 크기의 화면에서 모두 동작하는 호환성을 갖춘 UI를 표현하기 위해 앱 개발자는 스케일러블 UI를 구성하길 원한다. 하지만 앱 개발자는 전혀 예상치 못한 환경의 디바이스 장치에서 무참히 무너지는 앱의 UI를 보고 좌절하기 쉽상이다. 더 많은 환경의 기기에서 테스트를 해야 하며 문제를 발견할 때마다 이에 대응하는 코드를 추가해야만 한다.

    UI 프레임워크는 앱 개발자들이 이러한 문제를 보다 쉽고 단순하게 대응하기 위한 발판을 마련해 줘야한다. 스케일러블 UI의 원칙을 정의하고 이를 위한 견고한 인터페이스와 가이드를 제공하여 앱 디자이너는 물론 개발자들이 보다 직관적이고 단순한 설계로 호환성을 갖춘 UI를 구현할 수 있도록 도와줘야 한다.

    이번 장에서는 스케일러블 UI를 구현하기 위한 개념과 메커니즘에 대해 이해하고 UI 프레임워크에서 갖춰야할 기능들이 무엇인지 살펴보도록 하자.


    1. 이번 장 목표

    이번 장을 통해 다음 사항을 학습해 보자.

  • 스케일러블 UI을 위한 UI의 핵심 원칙에 대해 이해한다.
  • 스케일러블 UI를 위한 절대 좌표계, 정규 좌표계, 상대 좌표계, 크기 제약에 대해 이해한다.
  • 컨테이너의 기본 개념을 이해하고 정렬과 가중치 그리고 사용 방식을 살펴본다.
  • 더 높은 호환성 UI를 위한 고급 기법들을 이해한다.


  • 2. 좌표계 이해

    스케일러블 UI를 위한 가장 기본적인 방법으로는 종횡비(Aspect Ratio)를 유지하는 UI가 있으며 상황에 따라서는 레이아웃을 재배치하는 방법까지 스케일러블 UI을 위해 동원할 수 있는 방법은 여러가지가 존재한다. 스케일러블 UI을 지원하기 위해서 우리는 먼저 UI의 좌표계 및 좌표 단위를 이해할 필요가 있다.


    2.1 절대 좌표계

    절대 좌표계(Absolute Coordinates)는 원점(origin)으로부터 지정한 거리만큼 떨어진 위치를 가리키는 방식이다. 일반적으로 앱에서 원점은 앱 화면의 좌측 상단에 해당되며 UI 시스템 특성에 따라 화면의 좌측 하단 또는 화면 중심이 원점이 될 수도 있다. 이 때 거리의 단위는 일반적으로 픽셀에 해당되며 서브픽셀 정밀도(sub-pixel precision) 가 가능한 UI 시스템인 경우 소수점 이하로도 좌표 지정이 가능하다.

    다음 예제는 UI 앱 개발의 기본 이해에서 보았던 버튼을 구현하는 코드이며 절대 좌표(픽셀 단위)를 이용한 방식을 보여주었다.

    UIButton myBtn; //버튼 생성 … myBtn.move(50, 50); //버튼 위치 (단위는 픽셀이다.) myBtn.resize(100, 100); //버튼 크기 (단위는 픽셀이다.)

    코드 1: 버튼의 위치 및 크기 설정

    버튼의 위치와 크기를 지정하는 위 구현은 매우 자연스러워 보인다. 코드 1로부터 앱 개발자는 x, y 좌표 50, 50 픽셀 위치로부터 가로, 세로 크기가 100x100 픽셀인 버튼이 화면 상에 나타나길 기대한다. 당장의 동작에는 큰 문제는 없어 보이지만 호환성 측면에서는 어떠할까? 데스크탑에서는 하나의 앱이 사용자에 의해 다양한 크기의 윈도우로 구동될 수 있으며 스마트폰의 경우에는 기기마다 해상도가 제각기 다르다. 단위가 픽셀인 까닭에 버튼은 앱 화면의 원점을 기준으로 부터 (50, 50) 떨어진 픽셀 위치에서 100x100 픽셀 크기만큼 배치된다. 하지만, 앱이 보여지는 화면의 크기(해상도)는 가변적이기 때문에 이러한 픽셀 단위의 좌표와 크기는 일부 상황에서는 유용하지 않을 수도 있다.


    그림 1: 화면 크기별 절대 좌표의 출력 결과


    그림 1를 확인해 보면, 300x300 화면 크기는 그래도 봐줄만 하지만 100x100의 경우에는 용납할 수 없는 출력 결과이다. 원점이 화면의 중심이면 더 나을뻔도 했지만 결과적으로 다른 문제는 발생할 것이다. 변화가 없는 매우 특수한 환경의 전용 앱을 개발하는 것이 아니라면, 앱의 호환성을 위해 앱 개발자는 보다 나은 방법을 이용하여 UI 객체를 배치해야 한다.


    2.2 정규 좌표계

    UI 객체를 배치할 때 사용할 수 있는 다른 방법으로는 정규 좌표계(Normalized Coordinates)를 이용하는 방법이 있다. 정규 좌표계는 좌표 단위가 픽셀이 아닌, 정규화된 범위의 공간 내에 UI 객체를 배치하는 방식이다. 예를 들면, 화면 크기 가로, 세로의 범위가 각각 0 ~ 1 사이의 범위로 정규화되었다고 가정하자. 이 때 좌표 (0, 0)은 화면의 맨끝 좌측 상단에 해당되며 (0.5, 0.5)는 화면의 중앙, (1, 1)은 화면의 맨끝 우측 하단에 해당된다. 정규 좌표를 이용하면 앱 개발자는 임의의 화면 크기에 대응하는 UI 객체를 배치할 수 있으며 앞선 절대 좌표계의 픽셀 단위로 UI 객체를 배치할 때의 문제점을 피할 수가 있다. 정규 좌표계를 사용하면 가변적인 앱의 화면 크기에 대응하는 UI를 구성할 수 있다는 측면에서 절대 좌표계보다 조금 더 유리하다.

    정규 좌표값을 구하는 식은 매우 단순하다. 배치하고자 하는 UI 객체의 가로, 세로 픽셀 위치를 앱의 화면 크기로 나눠주면 정규화된 좌표값을 구할 수 있다.


    그림 2: 정규 좌표계식. (nx: 화면 가로 크기,ny: 화면 세로 크기)

    다음 예제는 정규 좌표를 이용한 버튼의 위치 및 크기를 지정하는 예이다.

    myBtn.relative1(0.25, 0.25);           //버튼의 좌측 상단 위치 (50/200, 50/200)
    myBtn.relative2(0.75, 0.75);           //버튼의 우측 하단 위치 (150/200, 150/200)
    
    코드 2: 정규 좌표를 이용한 버튼 배치


    그림 3: 정규 좌표를 이용한 버튼 배치


    정규 좌표를 이용하면 최종 화면 크기에 비례하여 버튼의 크기 역시 조정이 된다. 화면이 큰 환경에서는 상대적으로 버튼 크기가 커질 것이고 작은 환경에서는 버튼 크기도 작아질 것이다. 만약 앱의 화면 크기가 200x200인 경우, 버튼의 위치는 (50,50), 크기는 100x100이 되며 앱의 화면 크기가 300x300인 경우, 버튼의 위치는 (75, 75), 크기는 150x150이 된다. 화면의 크기가 어떻든 간에 버튼은 그 화면 영역 (0.25, 0.25) 위치로부터 (0.75, 0.75) 위치의 영역 내에 배치될 것이다.


    그림 4: 화면 크기에 따른 정규 좌표계 버튼 출력 결과


    확장성을 갖춘 UI를 구현하기 위해서 정규 좌표계를 무조건 사용하는 것이 만사는 아니다. 대체로 위치나 레이아웃의 영역을 지정하는 방면으로 정규 좌표계가 유용하며 콘텐츠의 크기의 경우는 픽셀 단위의 크기가 더 많이 요구된다. 예로, 다음 그림과 같이 정규 좌표계를 이용하여 콘텐츠 크기를 지정한 경우 콘텐츠가 훼손되는 경우가 발생할 수도 있다.


    그림 5: 정규 좌표계 오용 예

    그림 5와 같이 UI 컨트롤의 종횡비가 유지되거나 크기가 고정되어야 하는 경우가 있다. 앱 개발자가 가이드를 완전히 무시한 경우가 아니라면, UI 컨트롤은 어떠한 경우라도 외양이 훼손되지 않도록 동작 컨셉을 갖추고 있어야 하며 앱 개발자는 이러한 문제로부터 자유롭게 사용할 수 있어야 한다. UI 컨트롤을 직접 디자인하고 구현하는 측면에서는 이러한 부분을 염두해야 한다.

    정규 좌표계를 이용하는 경우, UI 엔진이 해야할 일은 물론 조금 더 많아진다. 렌더링을 수행하기 전, UI 엔진은 사용자가 지정한 UI 컨트롤의 정규 좌표를 최신 앱 화면 크기를 기준으로 픽셀 단위의 좌표로 역산해야 한다. 하지만, 일반적으로 앱의 화면 크기는 빈번히 바뀌지 않기 때문에 한번 계산한 픽셀 좌표는 캐싱하여 재사용하는 것도 좋은 방법이다.

    /*
     * UIObject는 모든 UI 컨트롤의 기저(base) 클래스에 해당된다.
     * UI 컨트롤의 기본 동작 및 속성을 구현한다.
    */
    UIObject
    {
        Geometry geom = {0, 0, 0, 0};      //오브젝트의 지오메트리(위치 및 크기)
        Point relative1 = {0, 0};          //relative1 속성
        Point relative2 = {0, 0};          //relative2 속성
        Bool updateGeom = false;           //true인 경우 지오메트리를 새로 갱신한다.
        ...
    
        /*
         * relative1의 좌표 지정.
         * x, y의 타입은 float/double 모두 가능하다.
        */
        relative1(x, y)
        {
            //이전 값과 동일한 좌표가 넘어오면 바로 종료한다.
            if (x == self.relative1.x && y == self.relative1.y) return;
    
            //새로운 좌표를 저장한다.
            self.relative1.x = x;
            self.relative1.y = y;
    
            //지오메트리가 새로 갱신되어야 함을 기록한다.
            self.updateGeom = true;
            ...   
        }
    
        /*
         * relative2의 좌표 지정.
         * 핵심은 relative1()과 완전히 동일하다.
        */
        relative2(x, y)
        {
            if (x == self.relative2.x && y == self.relative2.y) return;
            self.relative2.x = x;
            self.relative2.y = y;
            self.updateGeom = true;
            ...    
        }
    
        /*
         * 오브젝트를 새로 갱신한다.
         * 매 프레임마다 UI 엔진에 의해 호출된다.
        */
        update(...)
        {
            //지오메트리가 새로 갱신되어야 할 경우,
            if (self.updateGeom == true || output.changed)
            {
                /* output은 오브젝트가 출력되는 영역이다. update()의 인자로 전달되었거나,
                   메서드 자체적으로 얻어왔다고 가정하자. 정규 좌표계식을 역으로 계산하여
                   지오메트리 픽셀 값을 구한다. 만약 output 크기 자체가 변경되어도
                   지오메트리는 새로 갱신되어야 한다. */
                self.geom.x = output.w * self.relative1.x;
                self.geom.y = output.h * self.relative1.y;
                self.geom.w = (output.w * self.relative2.x) - self.geom.x;
                self.geom.h = (output.h * self.relative2.y) - self.geom.y;
    
                //더 이상 지오메트리가 갱신될 필요가 없다.
                self.updateGeom = false;
            }        
            ...
        }
    }
    
    코드 3: 정규 좌표로부터 픽셀 좌표값 구하기


    2.3 상대 좌표계

    상대 좌표계(Relative Coordinates)는 원점이 기준이 아닌 다른 UI 객체를 기준으로 좌표를 지정하는 방식이다. 특정 객체의 위치와 크기에 의존하는 경우, 상대 좌표계를 이용하면 매우 편리하다. 상대 좌표를 지정하기 위해서는 상대 좌표의 대상 객체를 지정하는 인터페이스도 같이 제공되어야 한다.

    //이미지 생성. 여기서 좌표는 화면을 기준으로 한다. UIImage myImg; myImg.open(“./res/star.png”); myImg.relative1(0.0, 0.0); myImg.relative2(0.25, 0.25); myImg.show(); //이미지 생성. myImg2의 좌표 공간은 myImg을 기준으로 한다. UIImage myImg2 = UIImage(); myImg2.open(“./res/star.png”); myImg2.relativeTo(myImg); //상대 좌표 대상 지정 myImg2.relative1(1.0, 0.0); myImg2.relative2(2.0, 1.0); myImg2.show();

    코드 4: 상대 좌표 지정


    그림 6: 코드 4 출력 결과 도식화


    다양한 유스케이스를 만족하기 위해 relativeTo()를 좀 더 세분화하여 relative1과 relative2에 대해 각각 상대 좌표 대상을 지정할 수 있는 인터페이스를 제공하는 것도 고려해볼만 하다.

    myObj.relative1To(target1);           //좌측 상단 위치의 상대 좌표 대상 지정.
    myObj.relative1(1.0, 1.0);            //target1의 우측 하단 꼭지점을 가리킨다.
    myObj.relative2To(target2);           //좌측 상단 위치의 상대 좌표 대상 지정.
    myObj.relative2(0.0, 0.0);            //target2의 좌측 상단 꼭지점을 가리킨다.
    
    코드 5: 특정 대상을 기준으로 하는 상대 좌표


    그림 7: 코드 5 출력 결과 도식화

    특히나 구현 시점에 대상 컨트롤의 크기 및 위치가 결정되지 않는 경우에 상대 좌표는 더욱 필수적이다. 예를 들면, 길이가 가변적인 텍스트의 우측에 어떤 아이콘을 배치한다고 가정해 보자. 구현 시점에 텍스트의 길이를 알 수 없으므로 아이콘의 위치 또한 결정하기 어렵다. 이 경우, 상대 좌표는 반드시 필요하다.


    그림 8: 상대 좌표 필요 예


    3. 크기 제약

    2.2 정규 좌표계 절에서 UI 컨트롤은 어떠한 경우라도 외양이 훼손되지 않도록 동작 컨셉을 갖추고 있어야 한다고 언급했었다. 이 문제를 좀 더 자세히 짚어보기 위해 코드 2의 정규좌표계를 이용한 버튼 예제를 다시 한번 살펴보고자 한다. 다만 이번엔 화면 크기를 100x100으로 축소해서 그 결과물을 확인해 보자.


    그림 9: 100x100 화면 크기에서의 상대 좌표를 이용한 버튼 출력 결과

    그림 9에서 확인할 수 있듯이, 100x100 크기의 화면에서 또 다른 문제점이 드러났다. 상대 좌표를 이용하여 버튼의 크기는 줄어들었지만 버튼 안의 텍스트는 줄어들지 않아서 텍스트가 버튼 영역을 벗어나 버렸기 때문이다. 사실 위의 문제는 버튼의 텍스트를 … 과 같이 생략하여 글자의 길이를 줄임으로써 이 문제를 회피할 수도 있다. 일반적으로 UI 프레임워크에서는 텍스트 일립시스(ellipsis) 기능을 제공하여 출력 영역이 부족할 경우 텍스트를 자동으로 생략하는 기능을 수행할 수 있게끔 도와준다.

    myBtn.textEllipsis(true); //텍스트 일립시스 기능 사용

    코드 6: 텍스트 일립시스 사용


    그림 10: 텍스트 일립시스 적용 결과

    텍스트 일립시스는 이 문제를 해결할 수 있는 가장 쉽고 단순한 방식처럼 보이지만 더 깊이 생각해 보면, UI 컨트롤 안의 내용물이 텍스트만 존재한다고 가정할 수도 없을 뿐더러, 글자 길이를 줄이는 방식이 항상 옳은 것도 아니다. 사실 그림 10의 버튼만 보더라도 버튼의 텍스트가 무얼 전달하고자 하는지 사용자는 이해하기 어렵다.


    그림 11: 텍스트 일립시스의 문제점


    많이 엉성하지만, 그림 11은 온라인상에서 물건 구매를 위한 결제 승인을 요청하는 화면이라고 가정한다. 금액이 지불될 수 있다는 점에서 사용자는 다소 신중한 선택이 필요할 수도 있다. 하지만 오른쪽 이미지의 경우 화면 영역의 부족으로 화면 하단의 세 버튼의 텍스트가 일립시스 처리되었다. 사용자는 어떤 버튼을 눌러야 구매 취소가 되는지 사전에 눌러보지 않고서는 알기가 애매해다. 이 시나리오는 조금 극단적이지만 유사한 시나리오는 충분히 있을 수 있다.

    우리는 이 문제를 보다 근본적으로 해결하기 위해 UI 컨트롤의 크기 제약에 대해 이해해 보고자 한다. 이해를 돕기 위해 이번엔 다른 테마의 버튼을 도입했다.


    그림 12: 버튼 UI

    앞서 보았던 버튼과 비교하면 그림 12 버튼의 경우 아이콘과 텍스트 두 보조 콘텐츠를 제공할 뿐만 아니라 버튼의 모서리가 라운드 처리되어서 보다 부드러운 느낌을 제공한다. 버튼 내부의 아이콘 및 길이가 가변적인 텍스트는 일단 제외하더라도, 만약 화면 영역의 변화에 따라 버튼의 크기도 비례하여 변한다면 어떨까?


    그림 13: 크기 변화로 인해 훼손된 외양

    그림 13과 같이 크기가 가변적이라고 해서 무조건 크기를 상대적으로 변경했더니 버튼 외곽 라운드 외양에 문제가 생겼다. 우리는 위 예제를 통해 변경이 가능한 가변 영역(Resizable Area)과 변경이 되면 안되는 고정 영역(Fixed Area)이 필요함을 알 수 있다.


    그림 14: 버튼의 가변 영역과 고정 영역


    고정 영역은 크기 조정이 불가능한 영역이다. 고정 영역에 변화가 발생하면 UI 컨트롤의 외양이 훼손되거나 사용에 문제가 발생하기 때문에 절대적 크기를 보장해야만 한다. 다시 말하면, 컨트롤의 크기에 상관없이 항상 일정한 크기를 보장해야 한다.

    이미지 보더(Image Border)는 그림 13과 같은 문제를 회피하기 위해서 한 장의 이미지에서 테두리 영역을 구분하는 개념이다. 이미지 보더를 이용하면 이미지를 스케일링(Scaling)하는 과정에서 보더 영역을 제외한 가변 영역만 스케일링 작업을 수행하여 이미지가 훼손되는 것을 방지할 수 있다. 그림 14의 고정 영역이 바로 보더 영역과 일치한다. 이미지 보더 대신 나인패치(9-patch) 방식을 이용하는 방식도 존재하지만 나인패치는 하나의 완성된 이미지를 위해 9장의 이미지 리소스를 따로 준비한 후, 구현 단계에서 이들을 조합해서 사용해야 한다는 측면에서 다소 작업량도 많고 구현도 복잡하다.


    그림 15: 나인패치 이미지

    그에 비해 이미지 보더는 한 장의 이미지에 보더 영역을 지정해 주면 UI 엔진에서 이미지를 스케일링하는 과정에서 보더 영역은 제외하고 스케일링을 수행하여 이미지 훼손을 방지한다. 이미지 보더를 지정하기 위해서는 한 장의 이미지에 좌, 우, 상, 하 영역에 대한 보더 크기를 지정할 수 있다.

    UIImage myImg; myImg.path(“./res/button.png”); myImg.borderArea(w1, w2, h1, h2); //이미지 보더 영역 지정(좌, 우, 상, 하) myImg.resize(100, 100); //보더 영역은 영향을 받지 않는다. myImg.show();

    코드 7: 이미지 보더 지정

    안드로이드 시스템의 경우 이미지 자체의 좌측과 상단에 가변 영역(Stretchable area)을 블랙 라인으로 표시함으로써 보더 영역을 구분한다. 별도의 코드 작성없이 이미지 보더를 적용할 수 있다는 점에서 개발과 디자인 작업의 의존성을 제거할 수 있다. 하지만, 이미지를 불러오는 과정에서 블랙 라인의 길이를 통해 보더 영역을 구분하고 이미지 스케일링 과정에서 엔진이 별도로 처리한다는 사실은 동일하다.


    그림 16: 안드로이드 이미지 보더

    UI 컨트롤을 디자인할 시, 컨트롤 특성에 맞게 크기가 고정인 영역을 정의해야 하며 하나의 컨트롤에 존재하는 고정 영역의 합은 결과적으로 해당 컨트롤의 최소 크기라고 정의할 수 있다.


    그림 17: 버튼의 최소 크기

    버튼에 포함된 아이콘의 경우 종횡비를 유지하는 선에서 버튼 크기에 따라 크기 조절이 가능하지만 디자인 컨셉에 따라 크기를 고정시킬 수도 있다. 텍스트 출력 영역의 경우 기본적으로 가변적이지만 이 역시 컨트롤의 최소 크기에 영향을 미친다. 만약 사용자가 그림 11과 같은 문제로 인해 텍스트 일립시스 기능을 사용하고 싶지 않은 경우에는 텍스트 출력 길이가 컨트롤의 최소 크기에 포함되어야 한다. 그렇지 않으면, 그림 9처럼 텍스트가 버튼 외곽 테두리를 벗어날 것이다. 설사, 텍스트 일립시스가 동작하더라도 최소한 … 의 출력은 컨트롤의 최소 영역에 포함시켜야 한다.


    그림 18: 아이콘, 텍스트가 포함된 버튼의 최소 크기

    기본적으로 UI 컨트롤은 각 컨트롤마다의 기본 동작 컨셉을 기반으로 가변 영역과 고정 영역을 잘 정의하고 구현해야 한다. 앱 개발자는 이들에 대한 자세한 사항을 모를지라도 UI 컨트롤은 최종 환경에서 사용자로 하여금 사용에 문제가 없도록 자체적으로 스마트한 동작을 보장해야 한다. UI 컨트롤을 직접 구현하는 관점에서 우리는 버튼의 UI를 다음과 같은 방식으로 구현해 볼 수 있다.

    /* * 그림 18의 버튼의 UI를 구성하는 함수(혹은 메서드) * w1 = 10, w2 = 25, w3 = 가변, w4 = 10, h1 = 10, h2 = 10, h3 = 25, h4 = 가변 * 다소 코드가 복잡하게 느껴진다면, 그림을 그려가면서 이해해 보자. */ composeButtonUI() { //버튼 배경 이미지 UIImage bg; bg.path(“./res/button.png”); bg.borderArea(10, 10, 10, 10); //이미지 보더 영역 지정 (좌, 우, 상, 하) bg.show(); //버튼 아이콘 UIImage icon; icon.margin(10, 0, 0, 0); //좌, 우, 상, 하 마진 설정 (w1, 0, 0, 0) icon.relativeTo(bg); icon.align(0, 0.5); //아이콘 원점을 bg의 좌측 중심으로 변경한다. icon.resize(25, 25); icon.path(“...”); //실제로는 앱 개발자가 요청한 이미지 리소스를 명시해야 한다. icon.show(); //버튼 텍스트 (크기는 텍스트 출력 결과에 의존한다.) UIText text; text.margin(35, 10, 0, 0); //좌, 우, 상, 하 마진 설정 (w1+w2, w4, 0, 0) text.relativeTo(bg); text.align(0, 0.5); //텍스트 원점을 bg의 좌측 중심으로 변경한다. text.fontName(“Sans”); //폰트 이름 text.fontSize(20); //폰트 크기 text.text(“...”); //실제로는 앱 개발자가 요청한 문자열을 명시해야 한다. text.show(); //주의! 버튼 배경 이미지의 크기는 버튼 구성 요소에 상대적으로 변화한다. bg.relative1To(icon); bg.relative1(0, 0); bg.relative2To(text); bg.relative2(1, 1); ... }

    코드 8: 버튼 컨트롤 UI 구현

    버튼과 마찬가지로 대부분의 UI 컨트롤이 크기 제약을 가지고 있다면, 앱 개발자는 그 크기를 벗어난 크기를 지정할 수가 없을 것이다. 하지만, 이러한 사실을 앱 개발자는 앱 구현 시점에 어떻게 알 수 있을까? 물론 UI 프레임워크에서는 UI 컨트롤의 최소/최대 크기를 앱 개발자가 알 수 있도록 인터페이스를 제공할 수 있겠지만, 사실 앱 개발자는 그러한 정보까지 고려하면서 UI를 구현하고 싶지 않을 것이다. 그렇다면, 앱 개발자는 UI 컨트롤의 크기를 어떻게 결정해야 할지 조금은 난해할 것이다. UI 컨트롤의 크기 제약을 모르기 때문에 뭔가 더 좋은 방법이 필요하다.


    4. 컨테이너

    컨테이너(Container)는 앱 화면을 구성하기 위한 레이아웃의 틀을 제공한다. 일반적으로 UI 프레임워크는 다양한 컨테이너를 제공하며 컨테이너마다 서로 다른 레이아웃 구성 특성을 가지고 있다. 사용자는 컨테이너의 특성을 이해하고 다양한 컨테이너를 조합하여 앱 화면을 적절히 구성할 수가 있다. 특히, 앱이 스케일러블 UI를 지원하기 위해서는 UI 컨트롤을 직접 화면에 배치하는 것보다 컨테이너를 활용하는 것이 보다 쉽고 안전하다. 최종 사용자의 디바이스의 스크린 환경은 물론, 각 컨트롤마다 그 특성이 다르므로 상황에 따라 컨트롤이 어떻게 화면에 나타날지 앱 개발자가 모두 이해하기 어렵기 때문이다. 게다가 2.4절에서 살펴본 컨트롤의 크기 제약을 앱 개발자가 알기 어렵기 때문에 호환성 높은 앱을 개발하기 위해서는 컨테이너를 활용하는 것은 필수에 가깝다. 컨테이너는 이러한 UI 컨트롤의 특성을 이해하고 최적의 화면 구성을 보장해 준다.

    기본적으로 UI 프레임워크는 비컨테이너(Non-container)와 컨테이너(Container) 두 부류의 UI 컨트롤을 제공한다.

  • 비컨테이너: 앞서 살펴본 버튼처럼, 앱의 UI를 구성하는데 있어서 시각적 외양을 통해 사용자와 상호작용을 수행하는 UI 컨트롤
  • 컨테이너: 비컨테이너 컨트롤을 효율적으로 화면에 배치하기 위한 레이아웃 정보를 제공한다. 일반적으로 컨테이너 컨트롤은 시각적 외양이 없으며 스케일러블 UI에 대응하기 위한 레이아웃 특성을 제공한다.


  • 그림 19: 컨테이너 종류 예시


    앱 개발자의 개발 편의를 위해 제공되는 컨테이너의 종류도 다양하다. 그 종류 및 기능은 UI 프레임워크마다 다르지만, 핵심 기능상 리니어 레이아웃(그림 19의 좌측 상단)과 유사한 기능을 수행하는 컨테이너는 대부분 존재한다.

    리니어 레이아웃의 동작 방식을 한번 살펴보자. 리니어 레이아웃의 사용 예를 통해 우리는 컨테이너의 사용 및 동작 방식의 핵심을 이해할 수 있을 것이다. 다음은 우리가 구현하고자 하는 화면 구성이다.


    그림 20: 리니어 컨테이너 사용 예

    /* 수직 리니어 레이아웃 생성. 레이아웃은 화면에 가득 출력된다고 가정한다. 
       myWnd는 윈도우 객체이다. */
    UIVerticalLinearLayout myLayout(myWnd);
    myLayout.show();
    
    //버튼1 생성
    UIButton myBtn1;
    myBtn1.text(“Button 1”);
    myBtn1.show();
    myLayout.push(myBtn1);       //레이아웃에 버튼1 추가
    
    //버튼2 생성
    UIButton myBtn2;
    myBtn2.text(“Button 2”);
    myBtn2.show();
    myLayout.push(myBtn2);       //레이아웃에 버튼2 추가
    
    //버튼3 생성
    UIButton myBtn3;
    myBtn3.text(“Button 3”);
    myBtn3.show();
    myLayout.push(myBtn3);       //레이아웃에 버튼3 추가
    
    코드 9: 리니어 레이아웃 사용 예

    코드 9은 수직 리니어 레이아웃을 하나 생성한 후, 세 개의 버튼을 순차적으로 레이아웃에 추가한다. 이 코드에서는 레이아웃 자체에 대한 영역 정의는 정확하게 보여주지 않는다. 다만 레이아웃 객체를 생성시 윈도우 객체를 전달하면서 레이아웃은 윈도우와 사이즈가 동일시된다고 가정하자.

    수직 리니어 레이아웃은 윈도우 크기에 맞춰 공간을 확보하며 레이아웃에 추가된 세 개의 버튼에 대해서 동일한 공간을 균등히 분할해서 버튼을 배치하는 작업을 수행한다. 버튼은 할당받은 공간에 대해 크기를 조정하여 최종적으로 화면에 출력된다. 결국, 위 코드의 세 버튼은 레이아웃 영역을 삼등분하여 동일한 크기로 출력될 수 있다. 수평 리니어 레이아웃이라면, 추가한 버튼은 수평 방향으로 추가될 수 있다.

    하지만, 여기서 우리는 다음과 같은 질문을 던져볼 수 있는데, 만약 앱 개발자가 버튼의 크기를 다르게 조정하고 싶다면 어떻게 해야할까? 이에 대한 해답으로 UI 프레임워크는 가중치(weight)와 정렬(align)이라는 개념을 추가로 제공할 수 있다.

    가중치는 UI 객체가 할당받을 수 있는 공간에 대한 가중치로서 동작한다. 만약 세로 공간의 크기가 200인 수직 리니어 레이아웃에 두 개의 버튼을 추가한다고 가정하자. 여기서 추가하는 두 버튼의 가중치가 모두 1.0이라면 두 버튼이 할당받는 공간의 높이는 각각 100이 된다. 하지만 이 중 하나는 0.5, 다른 하나는 1.0이라면 50과 150의 공간으로 재조정될 수 있다. 만약 세 버튼이 공존하며 하나는 0.5 다른 두 버튼은 1.0이라면 50, 125, 125의 크기로 분할될 수 있다.


    그림 21: 가중치 0.5, 1.0로 배치된 두 버튼의 예


    리니어 레이아웃에 있어서 가중치 설정의 핵심은 컨테이너의 공간을 차지하는 UI 컨트롤 간의 영역 싸움이라고도 볼 수 있다. UI 프레임워크는 가중치 설정을 위한 방법으로 다음과 같은 인터페이스를 제공할 것이다.

    /* 넓이(1.0), 높이(0.5)에 대한 가중치를 설정한다. 가중치의 값이 0.0의 경우 버튼의 최소 크기를 보장해야 한다. */
    myBtn.weight(1.0, 0.5);
    
    코드 10: 가중치 설정 예

    반면, 리니어 레이아웃은 자신의 영역에 추가된 컨트롤의 크기를 결정하기 위해 다음과 같은 핵심 로직을 구현한다.

    /* * 수직으로 UI 오브젝트를 담는 컨테이너 기능을 수행한다. * UIVerticalLayout는 UILayout을 상속받는다. */ UIVerticalLayout extends UILayout { //사실 아래 두 멤버 변수는 부모 클래스에 정의되어 있을 것이다. List<UIObject> contents; //레이아웃에 추가된 콘텐츠 리스트 Geometry geom; //레이아웃의 지오메트리 정보 /* 오브젝트를 새로 갱신한다. 레이아웃이 보유한 콘텐츠의 크기 및 위치를 결정한다. 여기서는 쉬운 이해를 위해서 크기를 구하는 기본 로직만 구현하며 그 외 여러 경우의 로직은 모두 무시한다. */ updateContentSize() { ... Size totalSize; //Layout의 최종 크기 Size totalWeight; //콘텐츠가 요구하는 가중치 축적치 Size contentSize[contents.count]; //Layout에 추가된 콘텐츠의 크기 Bool calcSize = true; //콘텐츠 계산 완료 여부

    //무한루프에 주의할 것! while (calcSize) { totalSize = {0, 0}; totalWeight = {0, 0}; foreach(self.contents, content, idx) { Size weight = content.weight; //콘텐츠 가중치 /* 가중치는 0 ~ 1 사이의 값만 허용한다. 이해를 돕기 위해 추가한 예외 코드. */ if (weight.w < 0) weight.w = 0; if (weight.w > 1) weight.w = 1; if (weight.h < 0) weight.h = 0; if (weight.h > 1) weight.h = 1; //콘텐츠 가중치를 기반으로 크기를 결정한다. contentSize[idx].w = (self.geom.w / self.contents.count) * weight.w; contentSize[idx].h = (self.geom.h / self.contents.count) * weight.h; //콘텐츠 크기를 최신으로 갱신한다. content.updateContentSize(); //콘텐츠의 최소 크기를 보장한다. if (content.minW > contentSize[idx].w) { contentSize[idx].w = content.minW; } if (content.minH > contentSize[idx].h) { contentSize[idx].h = content.minH; } //Layout의 최종 크기를 구한다. totalSize.w += contentSize[idx].w; totalSize.h += contentSize[idx].h; totalWeight.w += weight.w; totalWeight.h += weight.h; ++idx; } /* Layout의 최종 크기가 현재 크기보다 더 크면 최종 크기를 기준으로 다시 계산을 시도한다. */ if (totalSize.w > self.geom.w) { self.geom.w = totalSize.w; calcSize = false; } if (totalSize.h > self.geom.h) { self.geom.h = totalSize.h; calcSize = false; } } /* 실제 레이아웃 크기 대비 콘텐츠가 요구하는 크기의 차를 구한다. 만약 레이아웃의 공간에 여분이 존재하면 남은 공간을 재분배한다. */ Size diff = {self.geom.w - totalSize.w, self.geom.h - totalSize.h}; foreach(self.contents, content, idx) { if (diff.w > 0) { contentSize[idx].w += diff.w * (totalWeight.w/content.weight.w); } if (diff.h > 0) { contentSize[idx].h += diff.h * (totalWeight.h/content.weight.h); } //계산한 크기를 콘텐츠의 크기로 지정한다. content.resize(contentSize[idx].w, contentSize[idx].h); ++idx; } ... } }

    코드 11: 가중치를 기반으로 콘텐츠 크기 계산 로직

    정렬은 컨테이너로부터 할당받은 공간에 대해 UI 객체가 어떤 방향에 위치할 것인지를 결정한다. 좌우, 상하, 가운데 중 어느 방향으로 정렬될 것인지, 또는 그림 20처럼 할당받은 공간을 가득 채우는 동작을 수행할 수 있다.


    그림 22: 컨테이너 공간 내에 UI 컨트롤 정렬 예

    myBtn.align(0, 0);                              //좌측, 상단
    myBtn.align(1, 1);                              //우측, 하단
    myBtn.align(0.5, 0.5);                          //가운데 정렬
    myBtn.align(UIObject.FILL, UIObject.FILL);      //할당받은 공간을 가득 채운다.
    
    코드 12: 정렬 설정 예

    /* * 수평으로 UI 오브젝트를 담는 컨테이너 기능을 수행한다. * UIHorizontalLayout는 UILayout을 상속받는다. * 코드 11의 UIVerticalLayout와 동일하며 정렬 구현을 추가로 보여준다. */ UIHorizontalLayout extends UILayout { //사실 아래 두 멤버 변수는 부모 클래스에 정의되어 있을 것이다. List<UIObject> contents; //레이아웃에 추가된 콘텐츠 리스트 Geometry geom; //레이아웃의 지오메트리 정보 /* 오브젝트를 새로 갱신한다. 레이아웃이 보유한 콘텐츠의 크기 및 위치를 결정한다. 여기서는 쉬운 이해를 위해서 크기를 구하는 기본 로직만 구현하며 그 외 여러 경우의 로직은 모두 무시한다. */ updateContentSize() { ... Size totalSize; //Layout의 최종 크기 Size totalWeight; //콘텐츠가 요구하는 가중치 축적치 Size contentSize[contents.count]; //Layout에 추가된 콘텐츠의 크기 Bool calcSize = true; //콘텐츠 계산 완료 여부 //무한루프에 주의할 것! while (calcSize) { //코드 11과 동일 ... } /* 실제 레이아웃 크기 대비 콘텐츠가 요구하는 크기의 차를 구한다. 만약 레이아웃의 공간에 여분이 존재하면 남은 공간을 재분배한다. */ Size diff = {self.geom.w - totalSize.w, self.geom.h - totalSize.h}; /* 레이아웃에 추가될 콘텐츠의 가로 위치 값. 레이아웃 위치가 기준이다. VerticalLinearLayout의 경우 세로 위치 값이 필요하다. */ Var contentPosDiff = geom.x; foreach(self.contents, content, idx) { if (diff.w > 0) { contentSize[idx].w += diff.w * (totalWeight.w/content.weight.w); } if (diff.h > 0) { contentSize[idx].h += diff.h * (totalWeight.h/content.weight.h); } //콘텐츠의 정렬이 Fill인 경우 계산한 크기를 콘텐츠의 크기로 지정한다. if (content.align.w == UIObject.Fill) { content.resizeW(contentSize[idx].w); content.moveX(contentPosDiff); } //콘텐츠의 가로 위치를 결정한다. else { Var x = (contentSize[idx].w - content.size.w) * content.align.w; content.moveX(x + contentPosDiff); } if (content.align.h == UIObject.Fill) { content.resizeH(contentSize[idx].h); content.moveY(geom.y); } //콘텐츠의 세로 위치를 결정한다. else { Var h = (contentSize[idx].h - content.size.h) * content.align.h; content.moveY(h + geom.y); } //다음 콘텐츠의 시작 가로 위치를 결정한다. contentPosDiff += contentSize[idx].w; ++idx; } ... } }

    코드 13: 가중치과 정렬 계산 로직

    다음은 코드 9을 수정하여 정렬 기능을 활용한다.

    /* 수평 리니어 레이아웃 생성. 레이아웃은 화면에 가득 출력된다고 가정한다.
       myWnd는 윈도우 객체이다. */
    UIHorizontalLinearLayout myLayout = UIHorizontalLinearLayout(myWnd);
    myLayout.show();
    
    //버튼1 생성
    UIButton myBtn1;
    myBtn1.text(“Button 1”);
    myBtn1.show();
    myLayout.push(myBtn1);
    
    //버튼2 생성
    UIButton myBtn2;
    myBtn2.text(“Button 2”);
    myBtn2.align(0.5, 0.5);            //중앙 정렬한다.
    myBtn2.relative1(0.25, 0.25);      //상대 좌표를 이용하여 위치를 지정한다.
    myBtn2.relative2(0.75, 0.75);      //상대 좌표를 이용하여 크기를 지정한다.
    myBtn2.show();
    myLayout.push(myBtn2);
    
    //버튼3 생성
    UIButton myBtn3;
    myBtn3.text(“Button 3”);
    myBtn3.align(1, 0.5);        //우측 정렬한다.
    myBtn3.show();
    myLayout.push(myBtn3);
    
    코드 14: 정렬을 이용한 리니어 레이아웃 배치

    정렬을 지정하지 않으면 기본적으로 할당받은 레이아웃 영역에 UI 컨트롤이 가득 채우는 동작을 수행할 수 있다. 코드 14의 버튼1은 정렬을 지정하지 않았으므로 할당받은 공간을 가득 채우고 버튼2와 버튼3은 요청받은 대로 정렬을 수행한다. 이 때 크기를 지정하지 않은 버튼의 크기는 기본 크기(최소 크기)로 출력된다.


    그림 23: 정렬을 이용한 리니어 레이아웃 배치


    가중치와 정렬을 이용하면 앱 개발자는 컨테이너 정책의 범위 내에서 UI 컨트롤을 원하는대로 배치할 수 있다. 물론 가중치와 정렬은 하나의 컨셉일 뿐 UI 프레임워크마다 가중치와 정렬의 개념과 동작 방식이 다를 수는 있으며 아예 다른 방식으로 그 기능이 제공될 수도 있다. 하지만, 앞서 살펴 본 예제처럼 컨테이너에 UI 컨트롤을 배정할 때 위치 및 크기를 결정할 수 있는 메커니즘은 반드시 필요하며 컨테이너는 사용자가 고려하지 못한 경우에 대해서도 스케일러블한 UI를 보장하고 UI 컨트롤이 최소 크기 이하로 작아지는 등의 훼손 문제를 방지해야 한다.

    리니어 레이아웃 외로 테이블(Table) 컨테이너는 임의의 행과 열을 예약한 후, 각 행과 열이 가리키는 셀(Cell)마다 UI 컨트롤을 배치할 수 있는 컨테이너이다. 일반적으로 엑셀(Excel) 또는 스프레드 시트(SpreadSheet)와 같은 레이아웃을 앱이 구성할 수 있도록 UI 프레임워크는 테이블 컨테이너를 제공할 수 있다.

    앱 개발자는 UI 프레임워크에서 제공하는 다양한 컨테이너를 적재적소에 사용할 수 있어야 하며 컨테이너 안에 컨테이너를 배치하는 식으로 컨테이너를 조합하면 보다 효율적인 화면 구성도 가능하다.


    그림 24: 컨테이너를 조합한 화면 구성


    5. 더 높은 완성도를 위해

    앱의 뷰를 구성하는 콘텐츠의 최소 크기의 합이 앱 출력 화면 크기보다 더 큰 경우도 있지 않을까? 일반적인 경우는 아니지만 다양한 크기의 디바이스를 고려해 본다면 충분히 발생할 수 있는 문제이기도 하다. 극단적이긴 하지만 데스크탑에서 앱의 윈도우의 크기를 매우 작게 줄여보면 짐작할 수 있는 문제이기도 하다. 사실 이 경우 콘텐츠의 최소 크기보다 윈도우 크기가 줄어드는 것을 방지하는 정책이 필요할 수도 있다. 하지만 미처 고려하지 못한 소형의 모바일 기기에 앱을 구동하는 경우라면 디스플레이 크기의 제약으로 부득이하게 이 문제를 피할 수 없을 것이다. 호환성이 높은 앱이라면 다양한 화면 크기를 고려하여 각 해상도에 최적화된 뷰를 따로 디자인하여 해상도별 뷰를 출력하는 것도 가능하다. 하지만 이 경우 앱의 개발 비용과 난이도도 그만큼 상승할 것이며 모든 앱이 모두 대응한다고 보장할 수도 없다. 실용성은 떨어지지만 최소한 사용이 불가능한 앱은 되지 않도록 UI 프레임워크에서 도움을 주면 더 좋다.


    5.1 디바이스 독립적인 픽셀

    dpi(dots per inch)란 1인치의 범위 내에 존재하는 물리적 점의 개수를 의미하는데 원래는 프린터 출력 성능을 가리키는 용어 중 하나이다. dpi가 높을수록 1인치의 범위 내에 더 많은 점을 찍을 수 있으며 정교한 출력이 가능하다. 비슷한 용어 중 ppi(pixels per inch)는 디스플레이 장치에서 1인치 범위 내에 출력하는 픽셀의 수를 의미하며 PPI 역시 수치가 높을수록 더 정교한 출력이 가능해진다. 일반적으로 디스플레이 장치에서는 ppi 뿐만 아니라 dpi 개념 역시 통용된다.

    일반적으로 dpi/ppi가 높다는 것은 그만큼 고해상도 출력이 가능하다는 것을 의미하는데 예를 들면 물리적으로 동일한 크기의 출력 장치이지만 dpi/ppi 수치가 다르다면 두 출력 장치간의 해상도의 차이가 존재한다고도 볼 수 있다.


    그림 25: dpi에 따른 물리적 출력 크기 차이


    상대 좌표를 사용하는 경우는 해당되지 않지만, 절대 좌표를 이용한 위치 또는 UI 객체의 크기를 지정하기 위해서 사용하는 픽셀 단위의 수치는 그림 25과 같은 실제 출력의 차이를 발생시키며 이는 사용성에도 영향을 미칠 수 있다. dpi와 상관없이 사용자는 물리적으로 동일한 크기의 UI를 사용할 수 있어야 한다.

    이 문제를 해결하기 위해서는 앱 개발자가 지정한 UI 객체의 위치 및 크기의 단위는 디바이스에 독립적으로 동작하여야 한다. 이러한 개념을 디바이스 독립적인 픽셀(Device-Independent-Pixel) 또는 밀도 독립적인 픽셀(Density-Independent-Pixel) 이라고도 하는데 UI 프레임워크에서는 이러한 기능을 제공하여 앱 개발자로 하여금 보다 호환성이 높은 UI를 구성할 수 있도록 도움을 줄 수 있다. 실제로 안드로이드에서도 이러한 개념을 기본 구현 사항으로서 제공하고 있다.

    //디바이스 독립적인 픽셀을 최종 픽셀로 변환해주는 함수
    public static int dpToPx(int dp) {
        /* 안드로이드 시스템에서 정한 기준 dpi는 160이므로 160dpi에서 반환되는 density의 
           값은 1이다. */
        float density = Context.getResource().getDisplayMetrics().density;
        return Math.round((float) dp * density);
    }
    
    ...
    
    //dpToPx()을 이용한 크기 지정 예
    LinearLayout layout = new LinearLayout(this);
    LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) layout.getLayoutParams();
    layoutParams.height = dpToPx(400);
    layoutParams.width = dptoPx(400);
    layout.setLayoutParams(layoutParams);
    
    코드 15: 안드로이드의 디바이스 독립적인 픽셀 지정 방법 예

    안드로이드 시스템은 160으로 기준 dpi로 지정하였기 1dip는 160dpi에서 1픽셀과 매칭되며 이때 반환되는 디스플레이 density의 값은 1이 된다. 160보다 높은 dpi에서는 반환되는 density의 값도 높아진다.


    그림 26: dpi별 크기 차이


    그림 27: dip에 따른 물리적 출력 크기 차이



    5.2 스케일 팩터

    스케일 팩터(Scale Factor)는 앱 화면의 해상도를 변경하는 앱 단위 해상도 조정 변수 정도로 해석해 볼 수 있다. UI 프레임워크는 앱 화면을 구성하는 UI 콘텐츠의 최소 합이 윈도우 크기보다 큰 경우 스케일 팩터를 조정하여 앱 화면을 구성하는 UI 콘텐츠의 크기를 재조정할 수 있다.


    그림 28: 스케일 팩터를 이용한 콘텐츠 크기 변경


    얼핏 보기엔 상대좌표를 이용한 구현 결과물과 크게 다를바 없어 보일 수도 있지만, 스케일 팩터는 상대좌표에 영향을 받지 않는 폰트 크기에 영향을 주는 것은 물론, 콘텐츠의 고정 영역 및 최소 크기에도 똑같이 크기 영향을 줘야 한다. 그렇기 때문에 출력 영역의 크기에 상관없이 앱의 뷰 구성은 완전히 동일하게 출력될 수 있다.

    UI 엔진은 각 앱마다 다른 스케일 팩터값을 이용하여 앱의 UI 크기의 단위를 조정할 수 있다. 스케일 팩터는 앱의 UI 화면 구성을 보존할 수 있기 때문에 본래의 디자인을 보장한다는 장점이 있다. 하지만 터치스크린 환경에서 무작정 스케일을 낮추다 보면 사용자가 선택하기 어려울 정도로 UI 컨트롤이 작아질 수도 있다. 그림 28의 스케일 팩터가 3.3인 경우가 그러하다. 사용자의 입력을 받는 UI 컨트롤의 경우 사용자가 입력이 가능한 수준의 크기는 최소한으로 보장해야 한다. 이를 위해 핑거 사이즈(Finger Size)의 개념을 도입할 수 있으며 터치스크린 환경에서 사용자 입력을 받는 UI 컨트롤은 핑거 사이즈 이상의 크기를 절대적으로 보장해야만 한다. 핑거 사이즈는 UI 엔진 내에서 시스템 환경 변수를 통해 얻어올 수 있으며 UI 컨트롤의 크기를 계산할 때 핑거 사이즈보다 작은지만 추가로 고려해주면 된다. 실제로 Enlightenment Foundation Libraries UI 프레임워크는 이러한 기능을 구현하고 있다.

    /*
     * UIObject는 모든 UI 컨트롤의 기저(base) 클래스에 해당된다.
     * UI 컨트롤의 기본 동작 및 속성을 구현한다.
     * 코드 3을 기반으로 추가 작성한다. 핵심은 31라인을 확인하면 된다.
    */
    UIObject
    {
        Geometry geom = {0, 0, 0, 0};      //오브젝트의 지오메트리(위치 및 크기)
        Point relative1 = {0, 0};          //relative1 속성
        Point relative2 = {0, 0};          //relative2 속성
        Bool updateGeom = false;           //true인 경우 지오메트리를 새로 갱신한다.
        ...
    
        /*
         * 오브젝트를 새로 갱신한다.
         * 매 프레임마다 UI 엔진에 의해 호출된다.
        */
        update(...)
        {
            //지오메트리가 새로 갱신되어야 할 경우,
            if (self.updateGeom == true || output.changed) {
                /* output은 오브젝트가 출력되는 영역이다. update의 인자로 전달되었거나,
                   메서드 자체적으로 얻어왔다고 가정하자. 정규 좌표계식을 역으로 계산하여
                   지오메트리 픽셀 값을 구한다. 만약 output 크기 자체가 변경되어도
                   지오메트리는 새로 갱신되어야 한다. 최종적으로 스케일 팩터를 통해 크기를       
                   재조정 한다. */
                self.geom.x = (output.w * self.relative1.x) * UIConfig.scaleFactor;
                self.geom.y = (output.h * self.relative1.y) * UIConfig.scaleFactor;
                self.geom.w = (output.w * self.relative2.x) * UIConfig.scaleFactor - self.geom.x;
                self.geom.h = (output.h * self.relative2.y) * UIConfig.scaleFactor - self.geom.y;
    
                //핑거 사이즈보다 작으면 크기를 강제로 키운다.
                if (self.geom.w < UIConfig.fingerSize) {
                   self.geom.w = UIConfig.fingerSize;
                }
                if (self.geom.h < UIConfig.fingerSize) {
                   self.geom.h = UIConfig.fingerSize;
                }
    
                //더 이상 지오메트리가 갱신될 필요가 없다.
                self.updateGeom = false;
            }
            
            ...
        }
    }
    
    코드 16: 핑거 사이즈를 고려한 UI 컨트롤 크기 결정

    사실, dpi에 대응하여 스케일 팩터를 조정한다면 앞서 살펴본 디바이스 독립적인 픽셀과 동일한 해결책을 제공할 수도 있다. 실제로 Enlightenment Foundation Libraries에서는 동일한 물리적 크기를 보장하기 위해 스케일 팩터를 사용하기도 한다. 조금 더 고민해 본다면 UI 프레임워크 내부적으로 시스템 구동 환경(dpi)에 따라 scaleFactor를 조정한다면 앱 개발자에게 보다 편리한 개발 환경을 제공할 수도 있을 것이다.


    5.3 자동 스크롤

    앱 화면 출력 영역이 콘텐츠의 최소 크기보다 작은 경우 스케일 팩터를 이용하여 크기를 조정할 수도 있지만 앱 화면에 스크롤 기능을 활성화하면 이러한 문제를 피할 수도 있다. 실제로 앱 개발시 비교적 출력 내용이 많은 주 콘텐츠 영역에는 스크롤 기능이 활용되지만, 앱 개발자가 이를 미처 고려하지 못한 경우를 대비하여 UI 프레임워크가 자동으로 스크롤 기능을 추가해 줄 수도 있다. 스케일 팩터로 앱의 UI 스케일을 조정할지라도 최소한 핑거 사이즈는 보장은 되어야 하므로 자동 스크롤은 최악의 경우에도 대안책으로 사용될 수 있다.


    그림 29: 스크롤 기능 활성


    UI 엔진에서는 현재 앱 화면을 구성하는 컨텐츠의 최종 크기를 계산한 후, 컨텐츠의 크기가 윈도우 출력 영역보다 큰 경우 스크롤 기능을 앱 화면 전체 또는 주 컨텐츠를 대상으로 추가해 주면 된다. 사용자 관점에서 사용성이 좋지는 않겠지만 최소한 사용자는 앱 화면을 스크롤하면서 콘텐츠를 이용할 수 있다.


    5.4 어댑티브 UI

    스케일러블 UI는 다양한 화면 크기의 디바이스에서 앱의 동일한 경험을 사용자에게 제공하기 위한 현실적인 방법 중 하나이지만 사용자에게 최고의 경험을 제공할 수 있는 방법은 결코 아니다. 예를 들어, 휴대폰을 위해 디자인한 앱 UI는 디바이스의 특성상 컴팩트한 UI를 고려해야 하는 반면, 데스크탑과 같은 비교적 화면 공간의 여유가 있는 앱은 좀 더 다양한 기능을 사용자들이 동시에 사용할 수 있도록 UI를 구성할 수 있다. 달리 말하면, 휴대폰을 위한 UI는 데스크탑에서는 최선의 디자인이라고 하긴 어렵다. 이를 위해 앱은 어댑티브 UI 방식을 적용하여 해상도 또는 디바이스마다 다른 UI를 구성할 수 있다. 앱은 해상도에 특정 구간을 지정하고 해당 구간의 해상도에서 출력할 UI 레이아웃을 미리 구현할 수 있다. 또는 디바이스 종류에 따라 출력할 UI를 따로 구현할 수 있다. 모바일 기기에서는 단순히 세로 모드(Portrait), 가로 모드(Landscape)에 따라 다른 UI를 구현하기도 한다. 이러한 어댑티브 UI를 이용하는 방식은 사용자에게 화면에 최적화된 UI를 제공하는 것은 물론, 최소한 디바이스 종류마다 프로젝트를 다르게 구성하고 패키지를 제공하는 것보다는 관리 차원에서도 더 효율적이다.


    그림 30: 자마린(Xamarin Forms)의 어댑티브 UI 예

    //디바이스 타입에 따라 앱 요구사항에 맞는 레이아웃을 구성한다.
    switch (UIConfig.deviceProfile)
    {
       //데스크탑 환경의 UI를 구현한다.
        case DeviceProfile.DESKTOP:
            ... 
            break;
        //모바일 환경의 UI를 구현한다.
        case DeviceProfile.MOBILE:
            ...
            break;
        //태블릿 환경의 UI를 구현한다.
        case DeviceProfile.TABLET:
            ...
            break;    
    }
    
    코드 17: 프로파일별 어댑티브 UI 구현 예

    //해상도에 따라 앱 요구사항에 맞는 레이아웃을 구성한다.
    switch (UIConfig.screenResolution)
    {
       case ScreenResolution.WVGA:
       case ScreenResolution.WSVGA:
       case ScreenResolution.HD:
            ... 
            break;
       case ScreenResolution.1080P:
       case ScreenResolution.WUXGA:
            ...
            break;
       case ScreenResolution.2K:
       case ScreenResolution.UWHD:
       case ScreenResolution.WQHD:
            ...
            break;
       default:
            ...
            break;    
    }
    
    코드 18: 해상도별 어댑티브 UI 구현 예

    코드 17, 18처럼 실제로 코드 상에서 디바이스, 해상도별 경우를 따지며 UI 레이아웃을 구성하는 방법도 존재하지만 앱이 해상도 또는 디바이스별로 UI 리소스를 보유하되 이를 폴더별 또는 파일명으로 구분하는 방식도 가능하다. 이 경우 프레임워크에서는 현재 구동 중인 디바이스의 환경에 가장 근접한 리소스를 불러와 화면에 출력할 수 있다. 이 경우 시스템이 컴파일된 앱 바이너리를 실행하여 앱 UI를 구성하는 방식이 아닌 런타임시에 앱 UI를 구성하는 텍스트 정보를 실시간으로 해석하여 구성하는 별도의 리소스(XML, XMAL, JavaScript, JSON과 같은 스크립트 기반의 UI 구성 정보)를 앱이 작성할 수 있도록 UI 프레임워크에서는 기반을 제공해 주어야 한다. 이러한 구현 방식에 대해선 앱과 UI 프레임워크간의 사전 약속이 필요하지만 앱 개발자는 디바이스, 해상도별 경우의 수를 고려한 코드를 직접 작성하지 않아도 되므로 어댑티브 UI 구현이 비교적 안정적일 수 있다. 보다 구체적인 스크립트 기반의 UI 구성 방안은 이후에 따로 언급하도록 한다.


    6. 정리하기

    이상으로 우리는 앱 호환성을 위한 스케일러블 UI의 개념과 구현 방식에 대해서 살펴보았다. 기본적으로 앱 UI를 구성하기 위해서는 절대 좌표계를 이용할 수 있으며 보다 높은 호환성을 위해 정규 좌표계와 상대 좌표계를 사용할 수 있었다. 정규 좌표계는 UI의 위치와 크기를 디바이스 출력 장치에 비례하여 출력할 수 있는 기본적인 방법이며 상대 좌표계는 다른 UI 컨트롤의 크기와 위치에 비례하여 출력할 수 있는 방법이다. 정규, 상대 좌표계의 제약 사항을 보완하기 위해 UI의 크기 제약의 개념도 살펴보았다. UI의 크기 제약을 이용하면 UI의 최소/최대 크기를 보장할 수 있으며 UI가 외양 측면에서 손상되는 상황을 방지할 수 있었다.

    다소 복잡한 UI 레이아웃을 보다 쉽고 안전하게 구성하기 위해 UI 프레임워크에서 제공하는 컨테이너의 개념과 이들의 특성을 살펴보았다. 컨테이너는 다양한 컨셉과 특성을 제공하며 여러 컨테이너를 조합하면 다양한 형태의 레이아웃을 구성할 수 있음을 알 수 있었다. 그뿐만 아니라, 컨테이너는 앱 개발자가 실수할 수 있는 여지를 프레임워크 단에서 방지해 주는 장점을 가지고 있었다. 컨테이너에 배치하는 UI 컨트롤의 크기와 위치를 지정하기 위해 가중치와 정렬 개념도 함께 살펴보았다.

    추가로 보다 완성도 높은 앱을 개발하기 위한 고급 기법들을 살펴보았으며 여기에는 디바이스 독립적인 픽셀을 이용하여 dpi에 영향을 받지 않고 동일한 물리적 크기를 출력하는 방식부터 UI의 크기를 동적으로 조정할 수 있는 스케일팩터와 핑거 사이즈 그리고 화면 공간의 부족으로 사용이 불가능한 문제를 해결하기 위한 자동 스크롤과 같은 기능이 있음을 알 수 있었다. 마지막으로 어댑티브 UI를 통해 디바이스에 최적화된 UI 레이아웃을 동적으로 구성할 수 있음을 알 수 있었다.


    최근 사용자 경험은 소프트웨어에 있어서 매우 중요하다. 기능적으로 유사한 소프트웨어가 많기 때문에 더 세련된 디자인과 사용하기 편한, 즉 사용자 경험이 보다 뛰어난 소프트웨어가 사용자에게 매력을 더 어필할 수 있다. 물론, 빠르고 부드러운 비주얼 인터렉션은 당연지사이다. 앱 개발에 있어서 UI는 결코 사소한 요소에 해당되지 않는다. 앱 개발자는 보다 쉽고 빠르게 앱을 구현하기 위해 뛰어난 UI 프레임워크를 선호한다.

    UI 프레임워크는 앱 개발자가 쉽고 빠르게 앱 화면에 UI를 배치하고 사용자와 앱 간의 상호작용을 수행할 수 있도록 도와준다. UI 프레임워크는 고성능의 화려한 비주얼 효과를 제공하기 위한 뛰어난 그래픽스 처리 엔진은 물론, 사용자 앱의 주 로직과 UI 처리 로직 간의 자연스러운 통합을 위한 메인루프(main loop)와 같은 핵심 로직도 제공한다. 게다가, 보다 쉬운 앱 개발을 위해 프로그래밍 인터페이스는 더욱 더 정교하게 설계되어 제공된다.

    UI 프레임워크를 이해하는 가장 단순한 방법은 직접 앱 개발자가 되어서 필요한 기능을 사용해 보는 것이다. 이번 장에서는 앱 개발 관점에서 직접 앱을 구현하면서 UI 프레임워크의 기본 기능에 대해 짚어보고 한편으로는 프레임워크 개발 관점에서 어떻게 그러한 기능을 제공할 수 있는지 알아보고자 한다. 만약 여러분이 앱을 개발해 본 적이 없다면, 어쩌면 이번 장은 여러분에게 매우 적합한 장이 될 것이라고 생각한다.


    1. 이번 장 목표

    이번 장을 통해 다음 사항을 학습해 보자.

  • UI의 기본 원소와 UI 컨트롤에 대해 이해한다.
  • 앱의 UI를 생성하는 기본 메커니즘을 살펴본다.
  • 버튼과 이를 다루는 이벤트 사용 메커니즘을 배운다.
  • 앱의 기본 구조 및 라이프사이클를 이해한다.
  • UI 엔진의 개념과 메인루프에 대해서 살펴본다.


  • 2. UI의 기본 원소

    본격적인 시작에 앞서 우리는 UI의 기본 원소를 먼저 살펴볼 것이다. UI의 기본 원소는 앱 UI를 구성하는데 있어서 가장 기본이 되는 원소에 해당한다. 다음과 같은 앱의 UI를 구성하기 위해서 우리는 어떤 리소스가 필요할까?


    그림 1: 앱 화면 예

    그림 1은 우리가 잘 알고 있는 크롬(Chrome) 브라우저의 구글 페이지 화면이다. 크롬 역시 하나의 UI 앱으로 간주할 수 있는데 얼핏 보기에 UI가 복잡해 보이지만 사실 UI를 조목조목 뜯어보면 결국에는 이미지와 텍스트 두 요소로 구성되어 있음을 확인할 수 있다.


    그림 2: 앱 화면을 구성하는 기본 원소

    앱의 화면을 구성하는 기본 원소는 화면을 화려하게 장식해 주는 이미지와 문맥 정보를 전달하는 텍스트 두 가지로 축약해 볼 수 있다. 다소 기능이 원시적일지라도, 사실 이 두 기능만 존재한다면 어떠한 종류의 앱 화면도 정확히 구현할 수 있다. 이를 증명하기 위해 코드 1은 그림 1 중에서 검색 상자(Search Box)를 어떻게 구현할 수 있는지를 보여준다.


    그림 3: 구글의 검색 상자

    //검색 상자 UIImage searchBox; //이미지 생성 searchBox.open(“./../SearchBox.png”); //이미지 리소스 searchBox.move(80, 300); //이미지 위치 searchBox.resize(350, 50); //이미지 크기 searchBox.show(); //이미지 출력하기 //검색 상자 가이드 텍스트 UIText guideText; //텍스트 생성 guideText.text(“Search Google or type URL”); //텍스트 설정 guideText.color(“lightgray”); //텍스트 색상 guideText.move(90, 310); //텍스트 위치 guideText.resize(130, 40); //텍스트 크기 guideText.show(); //텍스트 출력하기 //검색 상자 음성 아이콘 UIImage icon; //이미지 생성 icon.open(“./../VoiceRecognition.png”); //이미지 리소스 icon.move(400, 310); //이미지 위치 icon.resize(20, 25); //이미지 크기 icon.show(); //이미지 출력하기

    코드 1: 앱 화면 구성 예

    코드 1처럼 이미지와 텍스트를 이용한다면 그림 1의 다른 부분도 똑같이 구현할 수 있다.

    누군가는 텍스트마저도 미리 준비된 이미지로 대처할 수 있지 않을까 생각할 수도 있다. 틀린 생각은 아니지만 언어, 폰트(Font) 등 시스템 설정에 맞게 텍스트가 유연하게 변경되기 위해서는 텍스트를 이미지로 출력하는 것은 여러 측면에서 한계가 많다.

    안드로이드, IOS, 윈도우와 같은 최신 플랫폼에서 제공하는 UI 기능을 살펴보면, 사실 앱 개발자는 이와 같은 원시적인 방법으로 UI를 구현하는 것은 상상할 수 없다. 사실 모든 플랫폼의 UI 프레임워크는 화면을 구성하는 공통된 기능과 특성을 UI 컨트롤(또는 위젯)로서 정의하여 제공하며 프로그래밍 관점에서 UI 컨트롤은 UI 객체(UI Object)로서 통용된다.


    그림 4: 다양한 종류의 UI 컨트롤 (Polaris UI)


    앱 개발자는 UI 프레임워크에서 제공하는 UI 컨트롤를 조합하여 보다 쉽고 빠르게 다양한 앱 화면을 구축할 수 있다.

    //검색 상자 UI 컨트롤 UISearchBox searchBox; //검색 상자 생성 searchBox.text(“Search Google or type URL”); //가이드 텍스트 설정 searchBox.icon(“./../VoiceRecognition.png”); //음성 아이콘 설정 searchBox.move(80, 300); //검색 상자 위치 searchBox.resize(350, 50); //검색 상자 크기 searchBox.show(); //검색 상자 출력하기

    코드 2: UI 컨트롤을 이용한 앱 화면 구성

    같은 목적을 수행하는 코드 1과 코드 2를 비교해 보면 구현이 얼마나 간단해 지는지 알 수 있다. 하물며 UI 컨트롤은 화면을 출력하는 기능 뿐만 아니라, UI 컨트롤과 사용자와의 상호작용을 위한 기능 동작까지 제공한다. 검색 상자에 사용자가 입력한 텍스트가 실시간으로 출력되는 기능을 생각해본다면, 앞서 이미지와 텍스트를 이용한 원시적 구현 방식과는 비교할 수 없을 정도로 구현 분량이 축소된다.

    사용자로부터 단순한 클릭 입력을 받는 버튼을 예로 들어보자. 버튼은 사용자의 클릭 이벤트를 전달받고 앱에게 그 상태를 전달한다. 버튼은 클릭이라는 이벤트를 가시적으로 표현하기 위해 클릭에 대한 상태 정의(Normal 상태, Press 상태) 및 각 상태별 이미지를 출력할 수 있어야 한다. 만약 상태 전이간 애니메이션까지 존재한다면 버튼의 구현은 훨씬 더 복잡하다.



    그림 5: 버튼의 클릭 상태 전이

    원시적 방식을 이용하여 버튼의 기능을 모두 구현한다면, 구현량은 물론 개발 난이도까지 급상승한다. 기본적으로 UI 컨트롤은 앱이 작성해야 할 코드 분량을 줄여주는 것은 물론, 룩앤필(Look & Feel)을 갖춘 테마 특성, 애니메이션, 이벤트, 예상치 못한 사용자 입력 처리 등 생각보다 많은 작업을 대신 정의하고 구현한다.

    일반적으로 UI 컨트롤의 동작 및 룩앤필의 특성은 프레임워크에서 정의한 테마에 따라 다르다. 달리 말하면, 앱이 어떤 UI 프레임워크를 기반으로 작성되었느냐에 따라 앱의 UI 특성은 완전히 달라진다. 게다가, 각 UI 프레임워크가 갖춘 다양한 테마에 따라 앱의 그래픽 출력 결과도 완전히 달라질 수 있다.



    그림 6: 테마에 따른 UI 컨트롤의 룩앤필 차이



    그림 7: 테마에 따른 동일 앱 UI의 룩앤필 차이 (Tizen)


    만약 제공되는 UI 컨트롤 중 디자인한 앱과 부합하지 않거나 또는 필요한 UI 컨트롤이 존재하지 않는다면 코드 1과 같은 방식으로 이미지와 텍스트를 가지고 화면을 직접 구성할 수 있다. 프로토타입, 테스트 목적 등 앱의 완성도 및 호환성이 그다지 중요하지 않는 상황이라면 충분히 고려할 만하다. 그렇지 않다면, UI 컨트롤의 테마를 직접 수정하거나 새로 작성하는 방법을 고민해 볼 수 있다. 일반적으로 UI 프레임워크에서 제공하는 UI 컨트롤 테마 커스터마이징 기능은 기본 사항에 해당된다.


    3. 첫 번째 예제: 버튼과 이벤트 핸들링

    앞 절에서는 우리는 UI 컨트롤이 무엇인지 대략적으로 살펴보았다. 이번 절에서 우리는 지구 상에서 가장 단순한 UI 컨트롤을 하나 이용해 볼 예정이다. 이 예제로부터 우리는 UI 컨트롤과 앱간의 상호작용을 어떤 식으로 구현할 수 있는지 살펴볼 것이다.



    그림 8: 버튼

    버튼(Button)은 어떤 UI 프레임워크를 막론하고 동일하게 제공되는 UI 의 기본 형태 중 하나이다. 사용자는 버튼을 클릭함으로써 앱에게 신호를 전달한다. 앱은 버튼이 사용자로 하여금 클릭되었는지 신호를 감지할 수 있으며 그 신호에 상응하는 어떤 적절한 동작을 취할 수 있도록 구현을 한다. 이 예제에서는 버튼이 선택되었을 때 버튼 메세지를 변경한다.


    그림 9: 버튼 선택 메세지

    버튼을 앱 화면에 배치하기 위해 앱 개발자는 대략 다음과 같은 버튼 생성 코드를 작성할 수 있을 것이다.

    UIButton myBtn; //버튼 생성 myBtn.text(“My Button”); //버튼의 출력될 텍스트 myBtn.move(50, 50); //버튼 위치 myBtn.resize(100, 100); //버튼 크기 myBtn.show(); //화면에 나타내기

    코드 3: 버튼 생성

    화면에 버튼을 추가했지만, 사용자 입력 신호는 어떻게 처리할 수 있을까? 프로그래밍 관점에서 보면, 이벤트 리스너(Event Listener) 혹은 콜백 함수(Callback Function)와 같은 메커니즘을 이용하면 가능하다. 버튼이 클릭된 시점에 앱 개발자가 등록한 함수가 호출된다면, 앱 개발자는 해당 함수 내에서 원하는 동작을 추가 구현할 수 있다.

    //버튼에 clicked 이벤트 핸들링을 추가한다. myBtn.addEventCb(UIButton.EventClicked, //EventClicked 이벤트 발생 시 아래 코드가 수행된다. lambda(UIObject obj) { myBtn.text(“Button Pressed”); } );

    코드 4: 클릭 이벤트 등록

    앱 개발자는 해당 이벤트 함수가 어떤 과정을 통해 호출되는지 전혀 알지 못한다. 하지만, 버튼이 클릭되었을 때 반드시 이벤트 함수가 불린다는 사실을 보장받아야 한다.

    눈치챘겠지만, UI 컨트롤을 잘 활용하기 위해서는 앱 개발자는 해당 컨트롤이 제공하는 이벤트의 종류를 이해할 수 있어야 한다. 버튼의 경우 “클릭” 이벤트를 제공하지만, 추가로 “눌림(Pressed)”, “눌림 해제(Unpressed)”, “롱프레스(Longpressed)” 와 같은 다른 이벤트도 제공할 수 있다. 앱 개발자는 각 이벤트에 대한 명세 내용을 정확히 이해하고 사용자 시나리오에 맞게 그 기능을 잘 활용할 수 있다.

    myBtn.addEventCb(UIButton.EventPressed,
                     //EventPressed 이벤트 발생 시 아래 코드가 수행된다.
                     lambda(UIObject obj) { ... }
                    );
    
    myBtn.addEventCb(UIButton.EventUnpressed,
                     //EventUnpressed 이벤트 발생 시 아래 코드가 수행된다.
                     lambda(UIObject obj) { ... }
                    );
    
    myBtn.addEventCb(UIButton.EventLongpressed,
                     //EventLongpressed 이벤트 발생 시 아래 코드가 수행된다.
                     lambda(UIObject obj) { ... }
                    );
    
    코드 5: 버튼에 여러 이벤트 등록

    한편, UI 프레임워크에서는 각 UI 컨트롤에 대한 정확한 동작 정의는 물론, 해당 기능을 앱 개발자가 이해할 수 있도록 명확한 인터페이스 설계 및 문서화가 필수적이다.

    /**
     * @defgroup UIButton Button
     * @ingroup UIFramework
     *
     * This is a push-button. Press it and run some function. It can contain
     * a simple label and icon object and it also has an autorepeat feature.
     *
     * This widget inherits from the @ref Layout one, so that all the
     * functions acting on it also work for button objects.
     * …
     * This control emits the following signals, besides the ones sent from Layout.
     * @li EventClicked: the user clicked the button (press/release).
     * @li EventPressed: button was pressed.
     * @li EventUnpressed: button was released after being pressed.
     * @li EventLongpressed: the user pressed the button without releasing it.
     * ...
    
    코드 6: Doxygen 형식을 따른 버튼 문서화 예

    UI 프레임워크는 서로 다른 UI 컨트롤일지라도 유사한 동작의 경우 동일한 인터페이스를 갖추는 것이 앱 개발자로 하여금 보다 빠른 이해에 도움이 된다. 앱 개발자는 하나를 배움으로써 다른 UI 컨트롤의 동작도 유추할 수 있을 것이다.

    UIRadio myRadio; //라디오 생성 //앱 개발자는 라디오를 잘 모를지라도, EventClicked 동작이 무얼 의미하는지는 유추할 수 있다. myRadio.addEventCb(UIRadio.EventClicked, //EventClicked 이벤트 발생 시 아래 코드가 수행된다. lambda(UIObject obj) { ... } );

    코드 7: 동일한 이벤트 인터페이스의 예

    하나의 UI 컨트롤에 이벤트 처리를 추가함에 있어서 콜백 함수의 복수 등록은 충분히 가능하다. 버튼이 클릭되었을 때, 한편으로는 메세지를 출력하고 한편으로는 이미지를 출력할 수 있을 것이다. 두 동작을 하나의 함수 내에서 처리할 수도 있지만 서로 다른 개별 동작은 코드 관점에서 분리하여 작성하는 것이 코드 복잡도 측면에서 더 낫다. 결과적으로, 이벤트 처리도 복수 등록이 가능하도록 인터페이스를 설계해야 앱 개발이 더욱 편리하다.

    myBtn.addEventCb(UIButton.EventClicked,
                     //EventClicked 이벤트 발생 시 아래 코드가 수행된다.
                     lambda(UIObject obj) {    //메세지를 출력한다. }
                    );
    
    myBtn.addEventCb(UIButton.EventClicked,
                     //EventClicked 이벤트 발생 시 아래 코드 역시 수행된다.
                     lambda(UIObject obj) {    //이미지를 출력한다. }
                    );
    
    코드 8: 이벤트 중복 등록 예

    이벤트가 중복 등록된 경우, 어느 이벤트 함수가 선호출되어야 하는지 UI 프레임워크는 명확한 정책을 제시해야 한다. 앱 로직의 순서가 완전히 달라질 수도 있기 때문이다. 나중에 등록된 함수가 먼저 호출되는 경우가 일반적이지만, 상황에 따라 이러한 순서를 임의로 결정할 수 있는 이벤트 함수가 필요할 수도 있다.

    myBtn.addEventCb(UIButton.EventClicked, 
                     lambda(UIObject obj) { ... },
                     2,    //우선순위: 2
                    );
    
    //위 이벤트보다 우선순위가 더 높기 때문에 여기 등록된 이벤트 함수가 먼저 불린다.
    myBtn.addEventCb(UIButton.EventClicked,
                     lambda(UIObject obj) { ... },
                     1,    //우선 순위: 1
                    );
    
    코드 9: 이벤트 우선 순위 지정

    사실, 이벤트 함수의 순서에 의존하는 앱의 이벤트 처리 로직이 존재한다면 개선 여지가 있는지 다시 검토해 보아야 한다. 예로, 클릭 이벤트에 복수 이벤트 처리가 등록된 경우 각 이벤트 처리는 서로 독립적이어야 앱 로직의 복잡도를 줄일 수 있다. 각 이벤트 처리간 의존성이 존재한다면 이는 하나의 이벤트 처리로 합치는 것이 더 바람직하다.

    반면, 어떤 상황에서는 동작을 취소하기 위해 등록한 이벤트를 제거해야 할 수도 있다.

    ...
    //이벤트 콜백을 등록한다. 이후 이벤트 콜백을 해지하기 위해 콜백 핸들을 따로 보유한다.
    UIEventCb myEventCb = myBtn.addEventCb(UIButton.EventClicked, ...);
    ...
    //앞서 등록한 이벤트 콜백을 해지한다.
    myBtn.delEventCb(myEventCb);
    ...
    
    코드 10: 등록한 이벤트 삭제

    사용자 조건이 만족하기 전까지 앱의 특정 기능이 비활성화되어 있는 경우도 있다. 이 경우 해당 기능을 트리거하는 UI 컨트롤이 비활성화 상태로 존재해야 한다. 해당 컨트롤을 비활성화하는 기능을 제공하면 사용자로 하여금 해당 기능을 사용할 수 없다는 점을 인지할 수 있게 도와준다. 비활성화된 UI 컨트롤은 기본 동작을 수행하지 않음은 물론 관련 이벤트 역시 발생하지 않는다.


    그림 10: 버튼 비활성화

    myBtn.disable();    //버튼을 비활성화한다. 다시 활성화하려면 enable()을 호출하자.
    
    코드 11: 버튼 비활성화

    비활성화된 버튼은 UI 외양이 달라졌을 뿐만 아니라, 클릭과 같은 기본 기능 역시 동작하지 않는다.


    4. 앱 기본 구조 및 동작 분석

    앞 절에서 우리는 UI 컨트롤을 사용하여 앱과 사용자간의 상호작용하는 방법을 어떤 방식으로 구현할 수 있는지 간단한 예제를 통해 살펴보았다. 이번 절에서는 UI 컨트롤을 사용하는 앱이 UI 프레임워크와 어떻게 연동되어 동작할 수 있는지 간략히 알아보고자 한다. 이번 절 학습을 통해 UI 프레임워크와 앱 코드간의 기본 연동 방식 및 그 원리를 이해할 수 있을 것이다.

    C, C++, 자바 등 현대의 대표적인 프로그래밍 언어에서는 우리는 main() 함수에서 프로그램이 시작됨을 알고 있다. UI 컨트롤의 기능을 사용하기 위해 일반적으로 앱 프로세스는 UI 프레임워크의 엔진을 초기화하고 가동하는 작업을 수행해야 한다. 엔진이라고 하면, UI 컨트롤이 동작하는 핵심 기능을 수행하는 모듈이라고 볼 수 있다. 앱 개발에 있어서 엔진의 내부 동작 원리를 모를지라도 큰 문제는 안되지만, 앱 개발자가 엔진의 동작 원리를 이해한다면 문제 해결 및 앱 최적화 측면에서 큰 도움이 될 수는 있다. 반면, 사용하기 좋은 UI 프레임워크일수록 앱 개발자는 엔진 내부의 동작 방식에 영향을 받지 않고 조금 더 자유로운 방식으로 쉽고 빠르게 앱을 개발할 수 있어야 한다. 일반적으로 UI 엔진은 앱의 로직과는 별개로 앱의 그래픽 출력을 위해 복잡한 연산 및 로직을 무대 뒤에서 열심히 수행한다.

    /* * UIEngine은 UI 컨트롤의 기능을 구동하는 모듈이다. * UIEngine이라는 명칭은 임의로 정함. * 실제로는 UI 프레임워크 및 모듈의 실제 이름 등의 더 적절한 이름을 요구한다. */ main() { UIEngine.init(); //엔진 초기화

    UIEngine.run(); //엔진 가동. 내부적으로 메인루프(MainLoop)가 가동한다.

    UIEngine.term(); //엔진 종료

    }

    코드 12: UI 엔진 초기화 및 가동

    코드 12는 UIEngine을 초기화, 가동, 종료하는 코드이다. init()에서는 UI 엔진이 사용하는 리소스를 불러오고 엔진이 적절히 구동되기 위한 준비 작업을 수행한다. run()에서는 준비된 리소스를 가지고 실제 엔진을 가동한다. run()이 호출된 이후에는 엔진이 종료 요청을 받기 전까지 run()은 메인루프를 통해 계속 가동되야만 한다. 만약 run() 메서드가 종료된다면 앱 프로세스 역시 main() 함수와 함께 종료될 것이다. 이 경우, 앱의 UI는 화면에서 지속될 수가 없다. run() 내부적으로 메인루프가 가동되면서 매 루프마다 지정된 어떤 작업을 수행한다. term()는 run()이 끝난 앱의 종료 시점에 호출되며 엔진에서 사용한 리소스를 모두 정리하는 작업을 수행한다. 엔진은 사용자가 사용한 UI 컨트롤 등의 리소스가 해제되어 있지 않으면 term() 호출 시점에 내부적으로 알아서 정리해 줄 수 있다.

    일부 플랫폼에서 코드 12와 같은 UI 엔진을 초기화, 가동, 종료하는 호출이 없다고 놀랄 필요가 없다. 일반적으로 앱이 구동되는 여러 플랫폼에서는 UI 엔진을 더욱 추상화한다. 플랫폼은 UI 엔진의 존재를 감추고 앱 개발자로 하여금 필요한 UI 컨트롤을 바로 호출할 수 있도록 코드 템플릿을 제공하거나 애플리케이션 프레임워크를 좀 더 보완하여 제공한다. 결과적으로, main() 함수에서 직접적으로 UI 엔진을 초기화, 가동, 종료하는 코드는 사용되지 않을 가능성이 크다. 일반적으로 앱은 UI 엔진 뿐만 아니라 사운드, 네트워크 등의 기타 라이브러리 및 엔진 그리고 서비스를 동시에 사용하기 때문에 엔진의 초기화 작업 및 가동은 앱 프레임워크 내부에서 일괄적으로 처리해 줄 수 있다. 코드 13은 이러한 부분을 추상화한 UIApp 클래스를 이용하는 예이다.

    main() { UIApp myApp; myApp.init(); myApp.run(); myApp.term(); } /* * 애플리케이션 프레임워크에서 제공하는 기능이며 클래스 및 메서드 이름은 임의로 정했다. */ UIApp { //UI 엔진 뿐만 아니라 여러 라이브러리 및 서비스를 초기화 한다. init() { ... UIEngine.init();

    ... } /* UIEngine.run() 전후로 여러 기능이 수행될 수 있지만 UI 엔진을 가동하는 것이 핵심이다. */ run() { ... UIEngine.run();

    ... } //마찬가지로 UI 엔진 뿐만 아니라 여러 라이브러리 및 서비스를 종료한다. term() { ... UIEngine.term();

    ... } }

    코드 13: UIApp 클래스를 활용한 main() 작성


    UIApp.run()까지 잘 호출되었다고 가정하자. 앱이 처음 가동되면 앱은 첫 화면으로 무언가를 보여주어야 한다. 2.3절에서 살펴본 버튼 예제와 같은 방식으로 여러 UI 컨트롤을 배치한다면 화면 구성이 가능하다. 다만, 그전에 우리는 UI 컨트롤을 배치할 앱의 윈도우(Window)를 하나 생성해야 한다. 사실, 윈도우는 플랫폼마다 그 특성이 다르긴 한데 안드로이드의 경우에는 하나의 앱이 여러 뷰(정확한 명칭은 Activity이다.)를 보유할 수 있으며 각 뷰다 윈도우가 할당되는 반면, MS 윈도우나 리눅스의 전통적인 X Window 시스템에서는 하나의 앱이 하나의 윈도우를 보유하며 윈도우 내에서 앱이 마음대로 뷰를 구성한다. 하지만, 지금은 윈도우가 디바이스 화면에서 앱이 출력될 위치 및 크기를 결정하는 출력 영역 정도로 이해해도 좋다. 일반적인 데스크탑 환경을 이용해 보았다면 윈도우의 개념 이해는 크게 어렵지 않을 것이다. 



    그림 11: 데스크탑 환경의 윈도우 (Enlightenment)

    앱의 첫 화면을 위해 윈도우를 생성하고 UI 컨트롤을 배치하는 작업은 run()의 무한루프가 본격적으로 수행되기 이전에 수행되어야 할 것이다. 그렇다면, UIApp.init(), UIApp.run() 사이에서 수행하면 될까? UIEngine을 직접 사용한다면 init()과 run() 사이가 맞지만 UIApp 기반에서는 init()과 run() 내에서 여러 추가적인 작업들이 수행될 수 있기 때문에 UIApp 클래스는 사용자에게 UI를 생성할 시점을 알려주는 것이 더 명확하다. 사실 이 부분은 앱의 라이프사이클(Life-Cycle)과도 관련이 있는데, 모바일 환경의 앱의 경우 리소스 제약이나 사용 환경의 특성으로 인해 앱이 시스템의 지배를 더 많이 받기 때문이다. 그렇기 때문에, 애플리케이션 프레임워크는 앱 개발자로 하여금 앱의 라이프사이클에 맞는 동작을 수행할 수 있는 인터페이스를 기본적으로 제공한다. 앱이 UI를 구성해야 하는 시점 역시 이러한 라이프사이클의 일부분으로서 존재할 수 있다.


    그림 12: 앱 라이프사이클 모델

    /* * UIAppLifeCycle은 AppLifeCycle을 상속해서 구현한다. * AppLifeCycle은 앱의 라이프사이클 인터페이스를 제공한다. * UIAppLifeCycle은 AppLifeCycle에 따라 UIApp의 동작을 제어한다. * 이 예제에서는 대표적인 4개의 상태만 언급한다. */ UIAppLifeCycle extends AppLifeCycle { UIApp app; //UIApp 인스턴스를 통해 멤버변수 /* * 생성자 */ constructor(UIApp app) { self.app = app; } /* * 앱이 최초 생성될 경우 호출된다. */ create() override { //실제로 MyApp의 create()가 호출된다. self.app.create(); } /* * 앱 종료를 요청받을 경우 호출된다. * 앱 관리자에 의해 강제 종료되는 경우가 해당된다. */ destroy() override { //실제로 MyApp의 destroy()가 호출된다. self.app.destroy(); } /* * 앱이 백그라운드(Background)로 전환되거나 일시정지될 경우 호출된다. * 갑자기 걸려온 전화로 전화 앱으로 전환되는 경우가 하나의 예에 해당된다. */ pause() override { //실제로 MyApp의 pause()가 호출된다. self.app.pause(); } /* * 앱이 포어그라운드(Foreground)로 다시 전환되는 경우 호출된다. * 앱이 최초 생성된 후 가동될 경우에도 해당된다. */ resume() override { //실제로 MyApp의 resume()가 호출된다. self.app.resume(); } } /* * 라이프 사이클을 위한 UIApp도 재구현한다. */ UIApp { //friends 지정으로 AppLifeCycle은 UIApp의 내부 기능에 접근 가능하다. friends AppLifeCycle; /* * 여기서 첫 화면을 구성한다. * 앱 라이프사이클의 일부 */ create() { ... } /* * 여기서 사용한 리소스를 정리한다. */ destroy() { ... } /* * 동작 중인 무언가가 있다면 일시 정지한다. * 애니메이션 등 불필요한 과도한 출력 처리는 여기서 정지시키는 것이 좋다. */ pause() { ... } /* * 일시 정지한 무언가가 있다면 다시 재개한다. */ resume() { ... }

    //아래는 public 메서드이다.

    public:

    //UI 엔진 뿐만 아니라 여러 라이브러리 및 서비스를 초기화 한다.

    init()

    {

    //라이프사이클 정보를 UIApp를 통해 앱 프레임워크 코어쪽으로 전달한다.

    UIAppLifeCycle lifeCycle(self);


    /* App 코어 기능을 담당하는 모듈에게 lifeCycle 정보를 전달한다. 추후 AppCore는

    시스템 이벤트 발생 시 전달한 lifeCycle에 등록된 인터페이스를 호출한다. */

    AppCore.registerLifeCycle(lifeCycle);


    UIEngine.init();

    ...

    }


    /* UIEngine.run() 전후로 여러 기능이 수행될 수 있지만 UI 엔진을 가동하는 것이

    핵심이다. */

    run()

    {

    UIEngine.run();

    ...

    }


    //마찬가지로 UI 엔진 뿐만 아니라 여러 라이브러리 및 서비스를 종료한다.

    term()

    {

    UIEngine.term();

    ...

    }

    }

    코드 13:앱 라이프사이클과 UI App의 구현

    /* * UIApp을 상속하여 앱은 MyApp 클래스를 구현한다. UIApp의 인터페이스를 override함으로써 * 앱 라이프사이클에 맞는 동작을 수행한다. */ MyApp extends UIApp { UIWindow myWnd; //윈도우 객체를 보관할 인스턴스 /* * 앱 개발자는 여기서 첫 화면을 구성한다. */ create() override { self.myWnd; //윈도우 생성 self.myWnd.title(“My Window”); //윈도우의 타이틀 self.myWnd.resize(400, 400); //윈도우 크기 //윈도우 생성 후 필요한 UI 컨트롤을 추가적으로 생성한다... UIButton myBtn(self.myWnd); //myWnd의 버튼을 생성한다. myBtn.text(“Exit”); //버튼의 출력될 텍스트 myBtn.move(50, 50); //버튼 위치 myBtn.resize(100, 100); //버튼 크기 myBtn.show(); //화면에 나타내기 myBtn.addEventCb(UIButton.EventClicked, lambda(UIObject obj) { self.exit(); //버튼 클릭시 앱을 바로 종료한다. }, ); } destroy() override { /* 앱이 종료될 시 생성한 윈도우를 제거해준다. UIEngine의 term()에 의해 자동으로 수행될 수 있으므로 사실 필수는 아니다. */ self.myWnd = null; } pause() override { self.myWnd.hide(); //윈도우를 숨긴다... 꼭 필요할까? } resume() override { self.myWnd.show(); //윈도우를 나타낸다… 꼭 필요할까? } } main() { MyApp myApp; myApp.init(); myApp.run(); myApp.term(); }

    코드 14:UI App을 상속한 앱의 구현 코드

    주석을 통해 코드에 대한 전반적인 이해는 가능할 것으로 기대한다. 몇 부분만 추가 설명하자면, 우선 코드 14의 20줄을 보면 버튼 생성 시 윈도우 객체를 전달하는 것을 확인할 수 있을 것이다. 생성하는 버튼이 어느 윈도우에서 출력되어야 하는 버튼인지를 지정하기 위해 추가한 사항이다. 하나의 앱이 여러 개의 윈도우를 동시에 보유할 수 있다는 가정하에 필요하다고 판단하여 추가하였다. 두 번째로 27줄을 보면 self.exit()를 통해 앱 종료를 요청하는 작업을 볼 수 있는데 실제로 UIApp.exit()는 UIEngine으로 하여금 메인루프를 중단하는 작업을 요청할 것이다.

    UIApp.exit()
    {
        ...
        UIEngine.stop();     //UIEngine의 메인루프를 중단한다.
        ...
    }
    
    코드 15: UIApp.exit()의 코드

    마지막으로, pause()와 resume()에서 윈도우를 숨기고 나타내는 작업을 앱 개발자가 직접 수행하고 있는데 과연 이러한 호출이 필요할까 의문일 수도 있다. 만약 앱 개발자가 직접 호출을 수행해야 한다면, Pause 시 악의적으로 윈도우를 숨기지 않을 수도 있기 때문에 사실 이 부분은 앱의 역할이라기 보다는 윈도우를 관리하는 윈도우 관리자(Window Manager)가 수행해야 할 작업이 더 맞을 듯 보인다. 아직까지는 윈도우 관리자에 대해서 설명하지 않았으므로 윈도우 관리자가 다소 생소한 독자들을 위해 앱 개발자가 직접 호출하도록 코드를 남겨두었다.

    실제로 플랫폼에서 제공하는 앱 사이클의 정의 및 시나리오는 본 예제보다 다소 복잡할 수 있다. 여기서는 라이프사이클 모델을 최대한 단순화하여 이 정도로만 언급하고 넘어가도록 한다. 다음 그림은 실제 윈도우10 UWP 앱의 라이프사이클을 보여준다.


    그림 13: 윈도우10 UWP(Universal Windows Platform) 앱의 라이프사이클

    마지막으로, 이쯤해서 UIEngine의 메인루프를 도식화해보자. 실제 메인루프 내에서 수행해야 하는 작업들은 훨씬 더 복잡할테지만 지금까지는 대략 다음과 같은 작업을 수행할 것이다.


    그림 14: UIEngine 메인루프

    UIEngine의 메인루프는 매 루프마다 사용자의 입력을 비롯한 새로운 이벤트가 발생했는지 확인한 후, 이벤트가 존재한다면 이벤트를 처리한다. 그리고 이벤트에 의해 UI 컨트롤과 같은 UI 객체에 변화가 발생했는지 확인한 후, 변화가 있다면 이를 반영하여 화면 갱신(Rendering) 작업을 수행하고 최종적으로 앱의 화면을 재갱신한다. 이러한 절차의 작업은 UIEngine.stop()이 호출될 때까지 끊임없이 반복된다.


    5. 정리하기

    이상으로, 우리는 앱 개발에 필요한 UI 프레임워크의 기본 기능과 그 개념을 간략하게 살펴보았다. UI를 구성하는데 있어서 가장 원시적인 방법으로 이미지와 텍스트를 이용하는 방법이 있으며 UI 컨트롤을 이용하면 더욱 쉽고 빠른 UI 개발이 가능함을 알 수 있었다. UI 컨트롤은 단순히 그래픽 출력 뿐만 아니라 사용자와 앱간의 상호작용이 가능한 기능을 제공하며 이러한 기능을 위해 앱 개발자는 UI 컨트롤이 제공하는 이벤트를 처리하는 방법이 필요함을 알 수 있었다. 앱은 기본적으로 윈도우를 하나 생성하여 출력 영역을 확보하고 윈도우 안에 UI를 구성함을 알 수 있었고 앱 라이프사이클에 맞춰 UI 생성 및 조작이 필요함을 알 수 있었다. 마지막으로 이러한 UI가 가동될 수 있는 UI 엔진과 메인루프에 대한 기본 개념도 함께 살펴보았다.