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

이처럼 캔버스는 UI 엔진에서 매우 중요한 역할을 수행하기 때문에 개발자로서 캔버스 동작 원리를 이해한다면 UI 렌더링의 이해에 큰 도움이 될 수 있다. 앱 개발 관점에서는 최적의 앱을 구현할 수 있는 고급 지식과 이해를 갖추는 데 도움이 된다. 이번 장에서는 UI 엔진의 핵심인 캔버스 모델을 자세히 살펴보고 캔버스가 제공하는 기본 기능은 물론 핵심 기능 구현 방법도 살펴보도록 하자. 이번 장을 학습하고 나면 UI 객체가 화면에 출력되는 전반적인 과정을 이해하는 데 많은 도움이 될 것이다.


1. 학습 목표

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

  • 렌더링의 개념과 동작 구조를 이해한다.
  • 리테인드, 이미디어트 모드의 차이를 이해한다.
  • 캔버스 모델의 특징과 캔버스 엔진의 핵심 기능을 구현한다.
  • UIObject 모델을 이해하고 핵심 기능을 구현한다.
  • 씬그래프 기반 렌더링 방법을 이해한다.
  • 레이어의 개념과 그 기능에 대해 알아본다.


  • 2. 렌더링 이해

    2.1 UI 렌더링 구성


    렌더링(Rendering)이란 컴퓨터 프로그램을 이용하여 입력 데이터로부터 영상 이미지를 만들어내는 과정을 뜻한다. UI 엔진은 UI 요소를 화면에 출력하기 위해 렌더링 엔진을 요구하는데 복잡한 UI 구성일수록 렌더링 과정 역시 복잡해진다. 벡터 그래픽스나 3차원 그래픽스처럼 기하학 정보로부터 최종 화소를 생성하는 경우 렌더링에 많은 연산이 필요하기 때문에 대부분 그래픽스 전용 하드웨어 장치를 활용하며 최근 UI 시스템 역시 그래픽스 하드웨어 장치를 활용하여 렌더링을 수행한다. 

    먼저 이해하기 쉽게 시나리오를 단순화하고 전체적 맥락을 짚어 보자. UI를 화면에 출력하기 위해 어떠한 렌더링 절차가 필요할까? 다음 그림은 렌더링 과정을 하나의 예로써 간략하게 도식화한다. 

    그림 1: UI 렌더링 과정


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


    이미지 렌더러는 JPEG, PNG 포맷의 이미지 파일로부터 데이터를 읽어와 비트맵 이미지를 생성하는 역할을 담당하며 그 과정에서 이미지 스케일링(Scaling), 변환(Transform), 색상 공간(Color Space) 변환 등을 수행한다. 물론 이미지 포맷에 따라 데이터 구성 및 해석 방식이 다르기 때문에 이미지 포맷마다 이미지를 불러오는 과정도 다를 수 있다. 이미지 로더에서 수행하는 디코딩 작업은 이미지 포맷에 의존하고 이미지 압축 방식에 따라서 디코딩 작업도 복잡해진다. 그렇기 때문에 이미지 로더를 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) 개념이 실용되면서 UI 시스템에서 벡터 그래픽스가 더욱 범용적으로 활용되는 추세이다. 현재는 벡터 애니메이션을 지원하기 위해 로띠(Lottie)로 불리는 JSON 포맷이 활용되고 있으며 이 포맷을 출력하기 위해 많은 플랫폼에서 로띠(Lottie) 렌더링 기능을 제공한다. 기본적으로 로띠 데이터는 어도비(Adobe) 애프터 이펙트(After Effect) 툴에서 작업한 벡터 데이터를 바디무빈(Bodymovin) 플러그인을 통해 추출한 결과물이다.


    2.2 비트맵과 화소


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

    그림 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[9, 7] = 0x000000ff;   //열 번째 라인의 여덟 번째 화소 색상은 검정
    bitmap[9, 8] = 0x00ffffff;   //열 번째 라인의 아홉 번째 화소 색상은 청록색
    bitmap[9, 9] = 0xffffffff;   //열 번째 라인의 열 번째 화소 색상은 흰색
    
    코드 1비트맵에 색상 값 채우는 예


    2.3 이미디어트와 리테인드 렌더링

    렌더링은 앱의 요청으로 수행될 수 있지만 보다 고급의 엔진에 가깝다면 UI 엔진 스스로 렌더링을 수행하기도 한다. 전자의 경우를 이미디어트(Immediate) 렌더링, 후자를 리테인드(Retained) 렌더링이라고 한다. 이미디어트 렌더링은 리테인드 방식 대비 원시적으로 동작하지만, 사용자가 렌더링을 직접 호출할 기회를 제공한다. 이미디어트 방식에서 사용자는 렌더링 영역과 시점을 직접 결정한다.  이미디어트는 앱이 드로잉 커맨드를 호출하는 즉시 렌더링을 수행하기 때문에 사용자는 드로잉 호출에 더욱 신중해야 한다. 앱은 불필요한 드로잉 요청을 피해야 하며 실질적인 무효(Invalid) 영역을 대상으로만 화면을 새로 갱신해야 더욱 효율적인 렌더링을 수행할 수 있다. 이를 위해 앱은 드로잉 영역 계산 및 문맥(Context) 관리 등의 작업을 직접 수행해야 한다.


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

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

    전통 그래픽스 시스템인 그놈(Gnome)의 카이로(Cairo),  마이크로소프트의 GDI, GDI+가 이미디어트 렌더링 방식을 구현하며 3D 그래픽스 인터페이스인 OpenGL과 Direct3D 역시 이미디어트 방식을 제공한다. 안드로이드의 스키아(Skia) 또한 이미디어트 렌더링 방식을 사용한다. 이미디어트 방식의 경우 사용자가 렌더링 영역과 시점에 직접 관여할 수 있기 때문에 매우 복잡한 화면 구성에 대해서도 최적화를 적극적으로 시도할 수 있다. 물론 그만큼 사용자 구현이 복잡해지는 단점도 존재한다. 게임처럼 렌더링 최적화가 중요한 경우 이미디어트 렌더링이 적합하다.

    반면, 리테인드 렌더링의 경우 엔진 내에서 렌더링 최적화를 수행해 준다. 차폐된 렌더링 객체를 걸러내고 클리핑(Clipping)을 통해 드로잉 영역을 최소화하며 UI 객체의 렌더링 컨텍스트를 엔진이 직접 보유하고 있기 때문에 무효 영역이 발생하면 엔진 스스로 해당 영역을 다시 그리는 작업을 수행할 수 있다. 그 때문에 앱 개발자는 렌더링 작업보다는 앱 로직에 집중하여 개발할 수 있는 장점을 갖는다. 대신 리테인드 방식은 이미디어트에 비해 상대적으로 엔진 동작이 복잡하고 구현이 까다로운 편이다. 그뿐만 아니라 렌더링과 관련된 정보가 엔진에 은닉되어 있어서 사용자는 렌더링 작업에 직접 관여하기 어렵다. 이러한 이유로 일부 그래픽스 시스템은 리테인드와 이미디어트 방식 장점을 적절히 조합하여 제공한다. 마이크로소프트의 WPF와 그보다 최신인 UWP 그리고 애플의 코어 그래픽스(Core Graphics)는 리테인드 방식으로 동작한다. 타이젠(Tizen)의 EFL 역시 마찬가지이다.

    그림 8: 리테인드 렌더링

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



    3.캔버스 엔진

    3.1 프레임 버퍼


    캔버스는 렌더링의 상위 개념이다. 우리는 캔버스 엔진 학습에 있어서 이미디어트보다 더 고급 개념인 리테인드 방식의 캔버스 엔진에 집중할 것이다. 리테인드 방식의 캔버스는 렌더링할 UI 객체를 관리하면서 제때 렌더링을 수행한다. 일반적으로 하나의 출력 영역을 갖는 앱은 출력 영역에 대응하는 캔버스를 하나 생성하고 이를 통해 화면에 출력할 객체를 그린다. 1장에서 살펴보았듯이, 전통적인 UI 앱은 윈도우를 통해 출력 영역을 확보하고 윈도우에 프레임 버퍼(Frame Buffer)를 매핑한다. 본 장에서는 프레임 버퍼를 UI 앱의 출력 버퍼로 해석해도 무방하다.

    그림 9: 프레임 버퍼 윈도우 매핑

    캔버스 엔진을 구현하기에 앞서 윈도우에 연결할 캔버스를 생성하고 초기화하는 구현부를 구축해보자. 캔버스는 블랙박스인 채로 이해해도 무방하다.

    /*
     * UIWindow는 앱의 출력 영역을 결정하는 객체이다.
     * UIWindow 내부적으로 캔버스를 생성하고 초기화한다.
     * 생성한 캔버스는 UI 엔진과 연동한다.
     * UIWindow의 동작은 윈도우 관리자의 영향을 받기 때문에 일반 UIObject와는 다른
     * 방식으로 동작을 수행해야 한다.
    */
    UIWindow:
        /*
         * 윈도우 타입을 정의한다. 윈도우 관리자는 윈도우 타입에 따라 관리 정책을
         * 다르게 적용한다. 일부 선택 옵션은 앱의 권한에 따라 허용이 불가할 수 있다.
         * Basic: 일반 UI 앱 윈도우
         * Gadget: 데스크톱 작업 공간에 배치되는 가젯 형태의 윈도우
         * Popup: 일시적으로 문맥 전환을 위한 윈도우. 다른 윈도우보다 우선순위가 높다. 
         * Notification: 사용자에게 어떤 정보를 알리기 위한 윈도우
         * 필요에 따라 그 외 다른 타입의 윈도우를 정의, 설계할 수 있다.
        */
        UIWindowType = [TypeBasic, TypeGadget, TypePopup, TypeNotification, ... ];
    
        UIWindowType type;       //윈도우 형식
        UICanvas canvas;         //윈도우에 매핑될 캔버스 객체
    
        /* 윈도우 시스템에서 제공하는 네이티브 윈도우 객체. 윈도우 관리자와 메시지를
           주고받기 위한 통신을 수행한다. 윈도우 상태 변환을 요청하거나 요청받는다. */
        NativeWindow window;
    
        /*
         * 윈도우 생성 시 윈도우 타입을 결정한다.
        */
        constructor(type = UIWindow.TypeBasic):
            //UIWindow로부터 네이티브 윈도우 타입을 결정한다.
            NativeWindowType windowType;
    
            switch(type)
                UIWindow.TypeBasic, UIWindow.TypeGadget:
                    windowType = NativeWindow.TypeTopLevel;
                UIWindow.TypeNotification, UIWindow.TypePopup:
                    windowType = NativeWindow.TypeMenu;
    
            //네이티브 윈도우를 생성한다.
            self.window = NativeWindow(windowType);
    
            /* 윈도우 관리자가 클라이언트에게 윈도우 상태 변경을 요청하면
               등록해 놓은 이벤트를 통해 필요한 UIWindow의 동작을 수행한다.
               동일 방식으로 Visibility, Position 등의 상태도 가능하다. */
            self.window.addEventCb(NativeWindow.EventSize,
                                   //EventResize 이벤트 발생 시 수행할 동작 구현...
                                   lambda(NativeWindow window):
                                       ...
                                  );
            ...       
    
            /* 캔버스 엔진을 초기화한다. 매핑할 네이티브 윈도우를 전달하여 캔버스 엔진이
               프레임 버퍼를 윈도우에 매핑할 수 있는 연결고리를 만든다. */
            self.canvas.setupEngine(self.window, ...);
    
            //캔버스를 UI 엔진과 공유한다.
            UIEngine.registerCanvas(self.canvas);
            ...
    
    코드 4윈도우 초기화 및 캔버스 매핑 과정

    코드 4를 보면 UIWindow 내부적으로 NativeWindow를  생성한다. NativeWindow는 플랫폼 윈도우 시스템에서 정의한 윈도우 서버/클라이언트 동작을 구현한다. 따라서 코드 2.4의 NativeWindow는 서버 역할을 수행하는 윈도우 관리자/컴퍼지터와 통신을 수행하는 메신저 역할을 수행한다고 가정하며 UI 앱과 윈도우 관리자/컴퍼지터 간 통신 규약을 준수하며 동작 신호를 주고받는다. 

    실제로 많은 리눅스 배포판이 윈도우 시스템으로서 X Window와 Wayland을 활용한다. 플랫폼은 NativeWindow처럼 윈도우 프로토콜을 통해 윈도우 시스템을 추상화함으로써 UI 시스템과 윈도우 시스템과의 의존성을 낮추고 확장성을 향상할 수 있다.

    그림 10NativeWindow와 윈도우 관리자 간 메시지 통신

    다음은 윈도우 크기 변경 시 필요한 최소 구현부를 보여준다. 네이티브 윈도우를 통해 윈도우 서버에게 변경된 정보를 알리는 점에 주목하자.

    /*
     * UIWindow의 크기를 변경한다.
     * 캔버스 출력 버퍼(프레임 버퍼) 크기를 조정하고 윈도우 서버에게도 그 정보를 알린다.
    */
    UIWindow.size(w, h):
        //캔버스 크기를 변경한다.
        self.canvas.size(w, h);
        //윈도우 시스템에 윈도우 버퍼 크기 변경 사실을 알려야 한다.
        self.window.requestSize(w, h);
        ...
    
    코드 5윈도우 크기 변경 동작


     X Window와 Wayland


    X Window는 1984 MIT대학에서 고안한 윈도우 시스템으로 현재 버전 11까지 개발되었으며 긴 역사만큼 많은 기능들을 소화한다. 그에 반해 Wayland는 2012년에 배포된 윈도우 시스템인 만큼 최신 기능에 초점을 맞춘 비교적 경량화한 윈도우 시스템이다. 특히 불편하고 복잡한 인터페이스를 개선하고 X Window 시스템에서 사용되지 않은 불필요한 요소를 배제하였기 때문에 소개 당시 많은 관심을 불러 모았다. Wayland는 클라이언트에서 프레임 버퍼를 직접 생성/관리하는 한편 이를 컴퍼지터와 공유, IPC 과정의 보안 취약 요소를 제거하여 보다 안정적이고 효율적이다. 최근 몇 년 사이 많은 시스템이 X Window에서 Wayland로 전환하였으며 GNOME, KDE, EFL 등 여러 리눅스 기반 UI 시스템이 Wayland 기반에서 동작한다.


    코드 4의 63번째 줄을 보면 UIWindow는 UICanvas 인스턴스를 생성하고 이를 UIEngine에 등록한다. 이로써 UIEngine은 전달받은 UICanvas를 대상으로 적절한 시점에 렌더링 요청을 한다.

    /*
     * UIEngine은 UI 엔진을 구동하는 클래스 
     * 주 역할은 메인루프를 가동하고 시스템과 사용자 이벤트를 처리한다.
     * 메인루프 사이클 동안 캔버스가 렌더링을 수행할 수 있도록 한다.
     * 하나의 프로세스(앱)는 하나의 UIEngine을 가동한다. 그렇기 때문에 UIEngine은
     * 싱글턴(singleton) 또는 전역 객체로 구현할 수 있다.
    */
    UIEngine:
        UICanvas canvas;        //캔버스 객체
        ...
    
        /*
         * 렌더링을 수행할 캔버스를 지정한다.
         * canvas: UICanvas 객체
        */
        registerCanvas(canvas):
            /* 다수의 윈도우를 활성화하는 앱을 위해 단일 캔버스가 아닌 캔버스 목록을
               고려해야 할까? 아니면 정말 엔진당 하나의 캔버스로 제한해야 할까? */
            self.canvas = canvas; 
    
        /*
         * 엔진 가동. 앱 메인루프가 시작되며 사이클 동안 일련의 작업을 반복한다. 
         * (그림 1.15 참고)
        */
        run():
            repeat(running)
                /* 이벤트 대기 */
                ...
                //이벤트 처리
                ...        
                //캔버스 업데이트 후 변경 사항이 존재하면 렌더링 수행
                if(self.canvas.update() == true)
                    self.canvas.render();
    
    코드 6: 엔진의 캔버스 렌더링 수행 코드

    여기서는 캔버스가 출력 버퍼를 생성하고 초기화 작업을 직접 수행한다. 사용 환경에 따라 출력 버퍼 속성이 다를 수 있기 때문에 만약 버퍼 초기화를 외부에서 수행해야 한다면 외부에서 생성한 버퍼 데이터를 캔버스가 접근할 수 있도록 인터페이스를 설계할 수도 있다. 데이터 복사를 최소화하기 위해 출력 버퍼를 컴퍼지터와 공유할 수 있다면 보다 효율적이다. 이를 위해 캔버스 출력 버퍼는 프로세스에 종속된 메모리가 아닌 공유 메모리 특성을 가져야 한다. 다음 코드는 캔버스 버퍼 초기화 작업 과정을 보여준다.

    /*
     * UICanvas는 UI 객체의 라이프사이클은 물론 그들의 동작을 통제하고 조절한다. 
     * 출력 버퍼를 설정하고 씬그래프(Scene-Graph)를 기반으로 활성(Active) 객체를
     * 출력한다. UICanvas와 UIWindow의 인스턴스는 1:1 매핑 관계이다.
    */
    UICanvas:
         //이하 캔버스 엔진 정보
         NativeDisplay displayInfo;    //디스플레이 정보
         NativeWindow window;          //네이티브 윈도우
         NativeSurface surface;        //윈도우 서피스 (네이티브 윈도우에 종속)
         NativeBuffer buffer;          //캔버스 출력 버퍼 (서피스에 종속)
         RenderContext ctx;            //렌더링 컨텍스트
         ...                           //이하 생략
    
        /*
         * 캔버스 엔진 설정. 전달받은 윈도우는 요구하는 버퍼 정보를 제공하며 이를 토대로
         * 캔버스 버퍼를 생성할 수 있다.
         * @p window: NativeWindow
        */
        setupEngine(window, ...):
            self.window = window;
            self.displayInfo = window.displayInfo();
            self.surface = window.createSurface(...);
    
            /* 새로운 크기로 버퍼 할당. 전달받은 displayInfo는 출력장치 정보를 제공하며
               이를 토대로 캔버스 버퍼를 생성한다고 가정하자. */
            self.buffer = NativeBuffer(self.surface, self.width, self.height, 
                                       RGBA32, IO_WRITE + IO_READ ...);
    
            //NativeBuffer는 공유 기능을 제공
            self.buffer.share(IPC_PRIVATE | IPC_CREAT | 0600, ...);
    
            //아래 코드는 가식일 뿐 여기선 중요하지 않다.
            self.ctx = self.surface.context(...);
            self.rotation = window.rotation();
            self.depth = self.surface.depth();
            self.alpha = self.surface.alpha();
            ...
    
        /* 
         * 장면 완성 후 화면 출력 요청
        */
        flush():
            /* 그림 완성 후 컴퍼지터로 신호를 보낸다. 신호를 받은 컴퍼지터는 해당
               윈도우의 버퍼를 이미지로서 합성할 수 있다. */
            self.window.commit(NativeWindow.CommitAsync);
    
    코드 7UICanvas 초기화 및 출력

    코드 7은 캔버스 엔진이 출력 버퍼를 생성하고 부수적인 초기화 작업을 수행하는 과정을 보여준다. 이를 setupEngine()이 구현하고 있으며 전달받은 인자 NativeWindow를 통해 필요 정보를 획득한다. NativeWindow는 클라이언트와 컴퍼지터 간 메시지를 주고받을 통신 프로토콜 역할을 수행하고 UICanvas의 출력 버퍼를 장식하는 NativeSurface를 제공한다. NativeSurface는 클라이언트와 컴퍼지터 사이에 공유되는 자원으로서 실제 버퍼 데이터는 NativeBuffer를 통해 접근할 수 있다. NativeSurface와 NativeBuffer는 윈도우와 그래픽스 시스템 간 호환, 확장성을 보장하기 위해 표준화한 가상의 인터페이스로 간주한다. 특히, 본 예제에서는 NativeSurface로 정의했지만, 이는 실제 그래픽스 시스템에서 제공하는 리소스 타입으로 대체해야 한다. 일반적으로 임베디드 시스템에서는 openGL ES를 이용하여 래스터 작업을 수행하기 때문에 eglSurface가 그 역할을 수행한다.

    코드 7의 flush()가 호출되면, UI 앱은 컴퍼지터로 그림을 완성했다는 메시지를 보낸다. 컴퍼지터는 공유받은 버퍼를 입력 데이터로 윈도우 효과를 추가로 적용한 후 다른 윈도우 화면과 합성하여 최종적으로 화면에 출력한다. 코드 2.7을 기반으로 캔버스와 컴퍼지터 간 출력 버퍼 공유를 도식화하면 그림 11과 같다.


    그림 11UI 앱과 컴퍼지터 간 출력 버퍼 공유


    앞서 확인한바, 캔버스 생성 및 초기화, 렌더링을 UIWindow와 UIEngine 그리고 UICanvas 내부로 감추었기 때문에 UI 앱 관점에서는 이러한 사항을 직접 확인하기 어렵다. 구현 편의도 있지만 무분별한 기능 노출은 사용자가 오류를 유발할 기회로 작용하기도 하므로 복잡한 동작은 가급적 감추는 편이 더 낫다. 이러한 동작 컨셉은 리테인드 렌더링 특성에 부합하기도 하다. 물론, 이러한 설계는 하나의 예시에 불과하므로 이들 동작은 물론 프레임워크 구조를 다르게 설계할 수 있지만 큰 맥락에서 기능은 크게 다르지 않다. 


    3.2 객체 관리 및 선별


    출력 버퍼 초기화를 완료했다면 캔버스는 그림을 그리기 위한 도구 준비를 마친 셈이다. 하지만 캔버스가 그려야 할 대상은 무엇일까?  캔버스가 렌더링을 수행하기 위해서는 렌더링할 정보가 필요하며 그 정보는 캔버스에 거주하는 UI 객체에 해당한다. 따라서, 캔버스는 활성 객체를 대상으로 렌더링을 수행한다. 

    기본적으로 UICanvas는 객체를 리스트로 관리하면서 이들 상태를 추적한다. 여기서 상태는 크게 두 가지 사항에 초점을 둔다. 
    1. 객체가 화면에 출력되어야 할 대상인가? (활성 객체인가?)
    2. 객체의 속성이 변경되어 새로 그려져야 하는가?
    캔버스는 객체 상태를 토대로 각 객체를 렌더링할지 말지 결정할 수 있다. 렌더링 부분화를 고려하지 않는 모델이라면 1번 사항만 고려하면 된다. 활성 객체인지 판별하기 위해 가장 먼저 확인해야 할 사항은 객체의 가시성(Visibility)이다. 객체가 화면에 보이지 않는다면 렌더링 대상에서 먼저 배제한다. 이 작업을 전문용어로 오브젝트 컬링(Object Culling)이라고 한다. 쉽게 사용자가 객체를 화면에 출력하도록 요청했는지, 객체가 캔버스 영역 내에 존재하는지, 다른 객체로 인해 완전히 가려졌는지 등을 판단하여 렌더링 제외 여부를 결정할 수 있다. 컨테이너처럼 객체가 비시각(Non-Visual) 요소로 구성되어 있어도 컬링 대상이다.

    렌더링 부분화를 고려한 모델이라면 2번의 객체 속성을 추가로 확인해야 한다. 렌더링 부분화는 객체가 가시성일지라도 변화가 없으면 렌더링을 생략할 수 있기 때문에 객체 속성에 변화가 발생했는지 확인해야 한다. 대표적으로 이러한 속성에는 객체의 지오메트리(Geometry), 색상, 객체가 출력하는 리소스 데이터 등이 있다. 렌더링 부분화에 관련된 자세한 사항은 2.5절 부분 렌더링을 참조한다.

    시스템 사양을 막론하고 일반적일 때 캔버스는 초당 60번의 화면 갱신을 해야 한다. 따라서 렌더링 성능을 위해 객체를 관리하는 방법 역시 효율적이면 좋다. 특히 UI 앱 특성상 화면을 구성하는 UI가 수시로 변경될 수 있기 때문에 UI 객체의 생성/삭제가 빈번히 발생한다는 점을 염두에 둘 필요 있다. 객체 인스턴스 풀(Object Memory Pool)을 두고 인스턴스를 재활용하거나 COW(Copy On Write) 기법으로 데이터 생성을 가급적 회피한다. 캔버스에 거주할 객체의 개수는 런타임 시 결정되므로 객체를 선형 리스트로 관리할 수 있으나 만약 객체 생성/삭제보다 탐색 위주의 시나리오가 많이 전개된다면 K진 트리를 이용한 공간 분할 기법을 이용하여 공간마다 객체를 분리하여 관리하는 것도 고려할만한 사항이다.

    그림 12객체 리스트를 탐색하며 렌더링을 수행하는 과정

    UICanvas:
        ...
        UIObject objs[];         //생성된 UI 객체 전체 목록
        UIObject activeObjs[];   //활성 객체 목록
        Bool dirty;              //캔버스 변화 발생 여부
    
        /*
         * 렌더링 수행 전 캔버스에 거주하는 UI 객체를 대상으로 어떤 사전 작업을 수행
        */
        update():
            //업데이트 과정에서 객체에 변화가 발생했는지 판단하며 이를 반환 값으로 알린다.
            foreach(self.objs, obj)
                //활성 상태의 경우 활성 목록에 추가하여 렌더링 대상으로 사용한다.
                if(obj.update() == true)
                    activeObjs.push(obj);
                    self.dirty = true;
            //반환 값을 통해 UIEngine에게 렌더링의 필요성을 알린다.
            return self.dirty;  
    
        /*
         * 렌더링 작업 수행
        */
        render():
            if(self.dirty == false) return;
    
            //활성 객체를 대상으로 출력 버퍼에 UI를 그리는 작업을 수행
            foreach(self.activeObjs, obj)
                obj.render();
    
            activeObjs.clear();       //렌더링 후 활성 대상을 정리한다.
            self.flush();             //컴퍼지터로 그림 완료 신호를 보낸다.
            self.dirty = false;
    
    코드 8UICanvas 렌더링 기본 로직

    코드 8의 update()와 render()는 UICanvas의 뼈대에 해당하는 로직을 보여준다. UICanvas는 update() 과정을 거쳐 활성 객체 목록을 구축한 후 이를 대상으로 렌더링을 수행한다. 그리고 update()에서는 obj.update()를 호출함으로써 각 객체의 상태를 갱신한다. 각 객체의 update() 메서드는 현재 블랙박스이기 때문에 알 수 없지만 객체 속성을 갱신하는 작업을 수행한다고 가정한다.


    때에 따라 캔버스에 거주하는 비활성 객체 수는 상당히 많아서 렌더링 대상을 간추리는 작업을 선행하면 좋다. 본 예제에서는 렌더링 과정을 간소화했지만, 실제 리테인드 렌더링 과정은 이보다 더 많은 작업 단계를 요구하기 때문에 렌더링 수행 단계가 복잡할수록 렌더링 객체를 간추리는 작업은 더욱 의미 있다.


    그림 13렌더링 객체 선별 과정



    4. 오브젝트 모델

    3절 캔버스 엔진을 학습하고 나면 UICanvas가 화면에 무언가를 그릴 준비를 완성했음을 알 수 있다. UIEngine은 메인루프 사이클마다 UICanvas의 update()와 render() 메서드를 호출하여 렌더링을 요청할  것이다. UICanvas는 렌더링 영역을 부분화하고 렌더링 객체를 선별하여 그 대상을 축소한다. 하지만 다양한 형태의 UI 객체를 보다 효율적으로 구조화하려면 데이터를 체계화할 필요가 있다.


    4.1 클래스 정의

    캔버스의 오브젝트 모델을 정의하는 가장 큰 목적은 캔버스가 모든 객체를 동일한 렌더링 객체로서 일괄처리할 수 있게 하기 위함이다. 오브젝트 모델은 캔버스 엔진과 밀접하게 동작하는 객체 모델을 정의하고 UI 객체의 데이터를 구조화한다. 예로 HTML의 도큐먼트 오브젝트 모델 트리(Document Object Model Tree)처럼 객체 간 부모-자식 관계를 정의하고 객체 관계 및 특성을 타입별로 구분할 수 있다면 객체 탐색, 갱신 그리고 렌더링을 보다 효율적으로 처리할 수 있다.

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


    만약 오브젝트의 기반 구조를 설계한다면 다양한 형태의 UI 객체를 보다 쉽게 확장할 수 있다. 캔버스 엔진 수정없이 UI 객체를 확장할 수 있기 때문에 설계 관점에서 고려할만 사항이다. 그뿐만 아니라, 다양한 UI 객체의 공통된 기능을 재사용할 수 있어서 UI 객체 확장이 한결 수월해지고 인터페이스 통일화가 가능하다. 실제로 객체지향 UI 시스템에서는 UI 객체를 상속구조로 확장/구현한다. IOS의 경우 NSObject라는 기저 클래스를 구현하고 이를 상속하여 다양한 UI 컨트롤을 확장할 수 있다.


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

    UI 객체를 확장하는 메커니즘으로서 클래스 상속 방법은 기능 확장 측면에서 용이할 수 있으나 기저 클래스와 파생 클래스 간 의존성이 증가하면 호환성이 결여되는 문제점을 야기할 수 있다. 따라서 일부 UI 시스템에서는 기능 확장 메커니즘으로서 구조적 언어 및 객체 조합 방식을 지향하기도 한다. 

    한편, 본 예시에서 UIObject는 UI 컨트롤의 최상위 클래스로서 UI 객체가 수행해야 할 기본 동작 인터페이스를 정의한다. UI 엔진은 UIObject를 제시함으로써 다양한 형태의 UI 객체로 확장할 수 있다. 따라서 1장의 예제로서 등장한 UISearchbar 및 UIButton은 UIObject의 자식 클래스로서 정의할 수 있다.


    그림 16: UIObject 클래스 상속 예


    그림 16는 UIObject와 UI 컨트롤 관계를 단순화한 예이지만, UIObject가 어떤 위치에 있는지 보여준다. 우리는 UIObject를 통해 객체지향 UI 시스템에서 객체 모델을 정의하는 방법을 배울 수 있다. 다음 그림은 UICanvas에서 동작하는 UIObject의 클래스 다이어그램이다. 기본 속성과 동작만 약식으로 정의해 보자.


    그림 17: UIObject 클래스 정의

    • tag: 객체가 유효한지 여부를 가리킴. 일종의 식별자

    • type: 객체 고유 타입을 문자열로 기록한다. 가령 UIButton의 경우 “UIButton”이 된다.

    • referenceCnt: 객체 참조 횟수

    • canvas: 오브젝트가 종속된 UICanvas의 인스턴스

    • parent: 부모 객체

    • children: 자식 객체 목록

    • geometry: 위치와 크기 정보 

    • layer: 레이어 위치. 레이어의 값이 클수록 상단에 표시된다.

    • visible: 가시 상태 여부 (화면에 보이는지?)

    • changed: 객체 상태에 변화가 발생했는지 여부


     태그(tag) 식별자


    UI 엔진이 객체 인스턴스 메모리를 주도적으로 통제하고 싶다면 태그는 유효하다. 태그는 객체 인스턴스 메모리의 특정 필드에 특유 값을 기록하고 이후 인스턴스 메모리에 접근할 때마다 이 필드 값을 비교하여 메모리가 유효한지 검증하는 방식이다. 만약 인스턴스 접근 시 태그값이 일치하지 않다면 메모리가 유효하지 않다고 판단하고 인스턴스의 접근을 방지할 수 있다. 일부 OS에서는 해제된(free) 사용자 영역 메모리일지라도 해당 주소의 커널 메모리를 일시적으로 보존한다. 이 경우 해당 메모리 접근 시 세그멘테이션 폴트(Segmentation Fault)는 발생하지 않는다. 따라서 태그 식별을 통해 객체 접근 방지를 수행할 수 있다. 반면, 커널 메모리가 존재하지 않는 객체에 접근 시 세그먼테이션 폴트는 즉시 발생한다. 이 경우 OS는 프로세스를 중단하므로 앱 크래시(crash)로부터 안전하지 않다. 이러한 동작 수행은 OS의 메모리 관리 유닛(MMU) 정책에 달려있으며 일관적이지 않은 결과는 오히려 문제를 감추거나 사용자를 혼란에 빠뜨릴 수도 있다. 따라서 태그를 적용하더라도 엔진 독자적인 메모리를 사용하지 않는다면 여전히 메모리 접근 위반이 발생할 수 있다. 한편 UI 엔진이 독자적인 메모리 공간(Memory Pool)을 이용하는 경우 예약된 메모리 공간이 보장되기 때문에 일관적으로 크래시 방지가 가능하다.



    여기서는 클래스를 단순하게 구성했다는 점을 알아두자. 실질적인 UI 엔진을 개발한다면 UIObject를 구성하는 기능을 특성별 엔티티(Entity)로 구성하고 인터페이스를 여러 집합으로 분리하는 것이 고려될 수 있다. 그뿐만 아니라 UIObject 자체는 실체가 존재하지 않기 때문에 이를 추상 클래스로 정의하는 것이 바람직하다. 

    다음으로 UIObject 구현부를 살펴보자.

    /*
     * UIObject는 UIControl의 기저(base) 클래스에 해당하며 UI 객체의 공통 특성을 구현한다.
     * UIObject의 생명주기는 UICanvas에 종속되며 참조 횟수로 이를 관리한다.
     * UIObject는 불균형 트리(Unbalanced Tree)를 통해 부모-자식 관계를 유지할 수 있다.
    */
    UIObject:
        Var tag;
        String type;
        Var referenceCnt = 0;
        Geometry geometry = {0, 0, 0, 0}
        Var layer = 0;
        UICanvas canvas;
        UIObject parent;
        UIObject children[];
        Bool visible = false;
        Bool changed = true;
        ...
    
        /*
         * UIObject 확장 클래스는 자신 고유의 tag와 type을 결정해서 전달해야 한다.
         * @p canvas: UICanvas
         * @p tag: Var 
         * @p type: String
         * @p parent: UIObject 연결할 부모 객체. 지정하지 않으면 최상위 객체이다.
        */
        constructor(canvas, tag, type, parent, ...):
            ...
            canvas.registerObj(self);   //캔버스에 새 오브젝트를 등록한다.
            self.parent(parent);        //부모를 지정한다.
     
        /*
         * 레퍼런스 카운팅 메커니즘을 흉내 낸다.
         * 매니지드(Managed) 언어처럼 가비지 컬렉터(GC) 상 레퍼런스 카운팅과 유사하다.
         * ref()는 컴파일러의 해석 단계에서 객체를 생성하거나, obj = obj2; 와 같이 객체
         * 참조 복사가 발생할 때 호출된다고 가정한다.
        */
        ref():
            ++self.referenceCnt;
    
        /*
         * unref()는 컴파일러의 해석 단계에서 obj = null; 처럼 객체를 제거하거나 
         * 함수 스택이 종료될 때 호출된다고 가정한다.
        */
        unref():
            --self.referenceCnt;
            //참조가 존재하지 않으면 dispose()로 제거하자.
            if(self.referenceCnt == 0) dispose();
    
        /*
         * 부모를 지정한다. null인 경우 현재 객체는 독립한다.
         * @p parent: UIObject 
        */
        parent(parent):
            //부모와 이 객체의 캔버스가 다르다? 허용하지 않는다!
            if(parent && parent.canvas != self.canvas) ...
            //기존 부모로부터 현재 객체를 제거한다.
            if(self.parent) self.parent.removeChild(self);
            //새로운 부모가 null일 수도 있다!
            if(parent) parent.addChild(self);
    
            self.parent = parent;
            self.changed = true;
    
        /*
         * 자식 객체 추가. null은 허용하지 않는다.
         * @p child: UIObject
        */
        addChild(child):
            self.children.add(child);
            child.parent(self);
            self.changed = true;
    
        /*
         * 자식 객체 제거
         * @p child: UIObject
        */
        removeChild(child):
            self.children.remove(child);
            child.parent(null);           //이 경우 child의 부모는 누가 되어야 할까?
            self.changed = true;
    
        /*
         * UI 객체가 필요 없는 경우 캔버스로부터 제거한다.
        */
        dispose():
            //부모가 있는 경우, 부모로부터 연결을 끊는다.
            if(self.parent) self.parent.removeChild(self);
              
            self.children.clear();         //자식이 있는 경우 자식도 제거한다.
            self.canvas.removeObj(self);   //캔버스에서 오브젝트를 제거한다.
            self.tag = 0x00000000;         //유효하지 않은 객체이다. 태깅을 제거한다.
    
            /* 사용하지 않는 인스턴스이므로 Memory Management Unit에게 메모리 
               반환을 요청한다. */
            if(self.referenceCnt <= 0) System.MMU.retrieve(self);
    
    코드 9: UIObject 클래스


    4.2 오브젝트 트리

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

    그림 18 UISearchBar 객체 구성

    UIObject의 트리(tree)를 형성하기 위해 코드 9는 parent와 children을 멤버 변수로 추가하고 parent(), addChild(), removeChild() 메서드를 구현한다. 

    부모는 자식을 보유함으로써 자식 객체 고유 특성과 동작을 제어할 수 있다. 만일 자식 객체를 사용자가 직접 접근할 수 있다면 부모 객체와 사용자 모두 자식 객체에 접근이 가능하므로 호출 충돌에도 염두에 둬야 한다. 예를 들면, 사용자가 UISearchbar의 아이콘을 임의로 변경하거나 삭제한다면 UISearchbar는 본연의 기능을 제대로 수행하지 못한다. 프레임워크는 이러한 예외 가능성을 염두에 두어 UI 객체의 인터페이스를 디자인해야 한다. OCP(Open Closed Principle) 규칙을 준수하고 기능 구현 시 다양한 예외 가능성을 염두에 두어 불안정한 동작을 야기하지 않도록 주의해야 한다.


    4.3 참조 안전성

    UIObject는 참조 횟수(referenceCnt)가 0이 될 때야 비로소 dispose()를 통해 메모리에서 해방된다. 이러한 동작은 안전한 객체 참조 메커니즘을 모방하고 메모리 오류를 방지하기 위한 자체적인 방편이다. 만약 어떤 이유로 강제 dispose()가 요청되었음에도 불구하고 참조 횟수가 0보다 크다면 이 값이 0이 될 때까지 삭제를 보류할 수 있다. 이 경우 tag와 별개로 객체 상태를 invalid로 기록하고 이후 해당 인스턴스에 접근할 시 객체가 어떠한 동작도 수행하지 않도록 방어한다. 이를 위해 코드 10처럼 방어 코드를 UIObject 모든 메서드 서두에 추가할 수 있다.

    UIObject.dispose():
        /* 객체 삭제를 요청받았으나 이 객체는 어디선가 참조되고 있음으로 바로 삭제하지
           않는다. */
        if(self.referenceCnt > 0)
            self.invalid = true;
            return;
        ...
    
    /*
     * dispose() 호출 후 어디선가 같은 객체를 대상으로 update()를 호출하였다! 
    */
    UIObject.update():
        /* UIObject 태그값을 11223344라고 가정했을 때 이 객체는 실제 유효한 메모리를
          가리키지 않는다. 객체가 가리키는 메모리는 이미 삭제되었거나 다른 용도로 사용되고
          있을 수 있다. 프로그램 오류로부터 안전을 보장한다. */
        if(self.tag != 0x11223344) return;
    
        /* 메모리는 아직 유효하나 폐기처분 대상 객체이므로 기능을 수행하지 않는다.
           객체 참조로부터 안전을 보장한다. */
        if(self.invalid == true) return;
        ...
    
    코드 10안전한 객체 참조를 위한 방어 코드

    invalid와 tag가 비슷한 목적을 갖는 것처럼 보일 수 있지만 invalid는 정상적인 객체 참조를 위한 안전 장치인 반면 tag는 안전한 메모리 접근을 위한 장치로서 활용함을 주목하자.


    4.4 상태 로깅

    삭제 요청 즉, dispose()를 수행한 인스턴스에 메서드 호출이 추가로 발생한다면 이것은 명백한 로직 오류이다. 프로그램은 안전할지언정 사용자가 의도한 기능이 수행되지 않음으로 사용자에게 이러한 사실을 알려준다면 디버깅에 도움이 될 수 있다. 이 경우 오류 메시지를 출력하거나 보다 강건한 프로그램을 위해 abort()를 수행한다.

    if(self.tag != 0x11223344)
    #if DEVEL_MODE      //개발 단계에서만 동작한다.
        System.printError(“This object is freed!...”);
        abort();
    #endif
        return;
    
    /* 마찬가지로 invalid 경우에도 정보를 출력한다. */
    ...
    
    코드 11로깅을 통한 디버깅 정보 제공

    코드 11을 개선하여 시스템 로깅 또는 디버깅 시스템을 구축한 후 이를 일괄 적용하는 것이 더 바람직하다.

    if(self.tag != 0x11223344)
        /* SystemLog는 요청한 메시지를 파일이나 콘솔에 출력하거나 네트워크를 통한
           메시지 전달 기능을 구현한다. 로그 수준으로(1, 2, 3 ...) abort()
           여부를 결정할 수 있으며 객체 정보는 물론 해당 프로세스와 관련된 부가
           정보(콜스택 등)도 같이 출력하여 디버깅 작업에 도움을 줄 수 있다. 
           소프트웨어 배포 시에는 내부 동작을 비활성화할 수 있는 옵션도 제공할 수 있다. */
        SystemLog.printError(SystemLog.LOG_LEVEL1,
                             “This object is freed!...”);
        return;
    
    코드 12로깅 시스템 활용 예


    5. 씬그래프

    씬그래프(Scene-Graph) 또는 장면 그래프라고도 하며 트리 구조의 그래프를 통해 여러 장면을 순서대로 출력할 때 응용할 수 있는 자료 구조이다. 씬그래프를 이용하면 각 트리 노드를 개별 장면으로 취급하고 이들을 조합하여 최종 장면을 생성할 수 있다. UI 객체의 경우 부모 객체는 전체 장면에 해당하고 자식 객체는 부모 장면에 종속된 지역 장면을 구성한다. 캔버스 엔진의 경우 트리를 순회하며 각 노드에 해당하는 객체를 화면에 출력할 수 있다.

    씬그래프의 트리는 부모-자식 노드로 연결되어 있기 때문에 탐색에도 효율적이다. 예를 들면 이벤트가 발생했을 때 깊이 우선(Depth-First) 탐색을 수행하면 된다. 이벤트 대상을 찾기 위해 현재 노드는 이벤트 조건에 부합하는 자식 노드를 찾아 이벤트를 바로 전달할 수 있으며 형제(sibling) 노드는 고려하지 않아도 된다. 


    5.1 객체 렌더링

    본론으로 돌아와서 하나의 UI 객체가 뷰(View) 내지 어떤 비주얼(Visual) 요소에 해당한다면 캔버스 엔진은 씬그래프를 이용하여 여러 객체를 출력한 후 최종 장면을 구축할 수 있다. 이해를 돕기 위해 UI 애플리케이션 그림 1의 구글 검색 화면을 다시 살펴보자. 이를 씬그래프로 구성하면 그림 19와 같다.


    그림 19구글 검색 화면 씬그래프 구성도

    그림 19의 씬그래프는 구글 검색 화면 씬그래프 구성 개념을 보여준다. 캔버스 엔진은 최상위 노드인 UIWebView로부터 트리 탐색을 수행하면서 자식 객체의 장면 요소를 하나씩 출력하여 최종적으로 UI 애플리케이션 그림 1과 같은 전체 화면을 출력할 수 있다. 이러한 개념을 구현하기 위해 UIObject는 부모-자식 관계를 구현하고 update(), render() 인터페이스를 제시함으로써 씬그래프(Scene-Graph)를 구현한다. 

    /*
     * 객체를 캔버스에 그리는 작업 수행
    */
    UIObject.render():
        ...
    
        //자식들도 렌더링을 수행할 수 있도록 render()를 호출한다.
        foreach(self.children, child)
            child.render();
    
        self.changed = false;
    
    /*
     * 객체 상태 갱신. 반환값을 통해 해당 객체가 렌더링 대상인지 알린다.
    */
    UIObject.update():
    
        //자식 상태 갱신
        foreach(self.children, child)
            self.changed |= child.update();
    
        //필요하다면 자신 상태도 갱신한다.
        ...
    
        /* 클리핑, 컬링, 비주얼 속성 등을 통해 객체가 렌더링 대상인지 여러 조건을
           판단한다. 반환 값으로 활성 객체 여부를 알린다. */
        ...
    
    코드 13씬그래프 기반 렌더링 수행

    UIObject 스스로 비주얼 정보를 보유하고 있지 않음으로 update() 및 render()에서는 특별한 구현 사항이 존재하지 않는다. 실질적으로 장면 정보를 가진 UIObject의 파생 클래스가 update() 및 render()를 오버라이딩하여 구현할 수 있다. 이러한 메서드는 폴리모피즘(Polymorphism) 특성을 통해 캔버스 엔진에 의해 자연스럽게 수행된다. 그 외로 update() 및 render()에서는 씬그래프를 구현하기 위해 트리를 순회하며 각 노드의 장면을 출력하는 점에 주목한다.

    씬그래프는 그림 12 캔버스 객체 리스트에도 변화를 가져온다. 이제는 캔버스에 상주하는 객체가 선형 연결 구조로 구성되지 않고 최상단 부모 객체만 캔버스의 객체 리스트에 추가되며 그 자식들은 부모에 연결되는 구조로 바뀐다. 따라서 부모가 렌더링 대상이 아니라면 그 자식은 검토할 필요도 없는 문제이다. 이는 캔버스가 렌더링을 위해 모든 객체를 탐색하는 부담을 줄일 수 있음으로 성능 차원에서도 도움이 된다.

    그림 20씬그래프 기반 캔버스 자료 구조

    이제 씬그래프 기반의 UI 객체가 어떤 장면을 출력한다면, 캔버스는 그림 21과 같이 객체 트리를 순회하며 렌더링을 수행할 것이다.

    그림 21씬그래프 기반 렌더링 과정


    그림 21의 각 화살표 번호는 렌더링 순서를 가리킨다. 부모로부터 시작한 트리는 전위순회(Pre-order Traversal) 탐색으로 각 자식 노드를 방문하며 렌더링을 수행한다. 각 자식은 개별 렌더링 과정 거쳐 장면을 생성하고 생성된 장면 결과물은 부모 장면의 일부로서 화면에 합성된다.


    5.2 레이어

    UI 세계에서 UI 객체의 z축 위치를 레이어(Layer)라고도 호칭하는데 만약 객체의 z 순서(z order)를 변경하고 싶다면 레이어 순서를 조정하면 된다. 이는 앞서 UIObject에 layer 속성을 추가한 이유이기도 하다. 핵심은 레이어가 객체의 렌더링 순서에 의존한다는 점인데 이때 우리는 씬그래프의 탐색 순서를 이해하면 어떤 객체가 먼저 그려지는지 알 수 있다. 최상위 부모의 렌더링 순서는 캔버스 엔진의 objs(객체 목록)에 추가된 순서에 영향을 받고 자식의 렌더링 순서는 부모의 children 목록에 추가된 순서에 영향을 받는다. 따라서 객체의 레이어 순서, 즉 누가 상단에 위치하고 하단에 위치하는지 결정하고 싶다면 객체 목록 순서를 변경하면 된다.

    그림 22객체 목록 순서에 따른 레이어 결과


    기본적으로 코드 14와 같이 레이어 관련 인터페이스를 제공하면 사용자는 레이어 순서를 쉽게 지정할 수 있다. 고려해야 할 사항으로는 레이어 순서를 조작할 때 레이어 변경 범위를 자신이 종속된 부모의  공간으로 제한할지 여부이다. 이러한 결정은 부모 자식 관계가 변경될 수 있는 점에 기인한다. 부모 자식 관계가 고정이라면 캔버스에 추가된 최상위 부모는 다른 최상위 부모들과 레이어 순서를 경합하고 자식 객체는 그 부모의 자식들과 경합한다. 달리 말하면 레이어 순서를 변경할 경우에는 해당 객체가 추가된 목록을 대상으로 객체 순서를 바꿔야 한다. 레이어의 순서가 특별히 지정되지 않는 경우(기본값인 경우)에는 목록의 맨 끝에 추가함으로써 해당 공간에서의 최상단에 위치하게 할 수 있다.
    obj.layer(3);         //obj의 레이어 순서를 세 번째로 지정한다.
    obj.above(obj2);      //obj를 obj2 바로 위로 이동한다.
    obj.below(obj2);      //obj를 obj2 바로 밑으로 이동한다.
    obj.top();            //obj의 레이어를 한칸 상승시킨다.
    obj.bottom();         //obj의 레이어를 한칸 하강시킨다.
    obj.topMost();        //obj를 가장 최상단으로 이동시킨다.
    obj.bottomMost();     //obj를 가장 최하단으로 이동시킨다.
    
    코드 14레이어 순서 변경 예



    6. 정리하기

    이번 장에서 우리는 렌더링 개념과 함께 캔버스 엔진을 살펴보는 시간을 가졌다. 기본적으로 캔버스 엔진은 화면에 UI를 출력할 수 있는 기능을 제공하는데 윈도우에 매핑될 프레임 버퍼를 생성하고 이를 출력 시스템과 연동하는 기반 설정 작업을 수행함을 알 수 있었다. 그뿐만 아니라 캔버스 엔진의 렌더링 방식 중 리테인드 렌더링은 드로잉 명령 기능을 감추고 객체 지향 렌더링을 수행하는 컨셉을 제공함을 알 수 있었다. 또한 고성능 렌더링을 위해 렌더링 객체 선별 과정을 거쳐 부분 렌더링을 수행하는 최적화 기법과 함께 백엔드 렌더링을 통해 시스템에 유연한 하드웨어 가속 방안에 대해서 살펴보았다. 리테인드 방식의 캔버스 엔진은 앱 개발자가 렌더링에 깊은 이해가 없어도 쉽고 빠르게 앱 UI를 개발할 수 있는 장점을 제공함을 알 수 있었고 이를 위해 리테인드 캔버스 엔진에 적합한 UI 오브젝트 모델을 결정하고 여기에 기반을 둔 주요한 인터페이스에 대해서도 함께 살펴보았다. 마지막으로 캔버스에 추가된 UI 객체를 효율적으로 다루기 위해 씬그래프 구조를 통해 오브젝트 트리를 구성하였는데 캔버스 엔진은 씬그래프 트리를 탐색하면서 UI 객체를 관리하고 렌더링을 보다 효과적으로 수행할 수 있음을 알 수 있었다.