WPF UI Virtualization -1

프로그래밍 2011.03.01 14:55 Posted by 아일레프

Virtualization은 Data Virtualization과 UI Virtualization으로 나뉠 수 있다. Data Virtualization이란 필요한 Data만을 데이터 저장소에서 가져와 성능을 향상시키는 방법을 의미하고 UI Virtualization이란 화면에 보여지지 않는 컨트롤 객체를 생성 않는 것으로 속도를 향상시키는 기법을 의미한다. 이 포스트는 UI Virtualization만을 다룬다. 만약 Data Virtualization에 대해 자세히 알고 싶은 분은 놀랍도록 멋진 Data Virtualization in WPF and beyond을 참고하면 되겠다.

UI Virtualization을 좀 더 쉽게 살펴보기 위해 ListBox를 예로 들어보자. 1000개의 int 데이터를 가지는 List<int> 가 ListBox의 ItemsSource에 바인딩 되었다고 가정하면 ListBox의 VisualTree는 다음과 같이 구성될 것이다.

ListBox        
  ScrollViewer      
    ItemsPresenter    
      Panel  
        ListBoxItem
        ListBoxItem
        ListBoxItem
...
...
...

UI 가상화를 사용하지 않는다면 데이터가 1000개 이므로 ListBox가 로드되었을 때 생성될 ListBoxItem들의 숫자는 총 1000개이다. WPF의 UI Virtualization은 "화면에 보여지지 않는 객체를 생성하지 않는 것으로 속도를 향상시키는 기법" 이라고 말했다. 이를 ListBox의 예로 국한하면 화면에 보여지지 않는 불필요한 ListBoxItem을 생성하지 않는 것으로 구체화할 수 있다. ListBox의 예에서 UI Virtualization을 가능하게 하려면 이 ListBoxItem의 생성을 컨트롤 할 수 있어야 한다는 것이다. 그렇다면, ListBoxItem을 생성하게 하는 객체가 무엇인가? 라는 질문을 던져야 한다. 당신은 후보가 다음과 같을 때 어떤 객체가 ListBoxItem을 생성하게 한다고 생각하는가?

  1. ListBox
  2. ItemsPresenter
  3. Panel

 

...

...

...

 

자, 답을 보자. ListBoxItem객체는 ListBox의 GetContainerForItemOverride 메소드를 통해 생성된다. 하지만 위 질문의 답은 ListBox가 아니다. ListBox의 GetContainerForItemOverride메소드는 ListBoxItem을 생성하기 위해 이용당하는 것이다. 잘 살펴보면 GetContainerForItemOverride 메소드를 호출하는 것은 ItemsPresenter내의 ItemContainerGenerator라는 객체이며 이 객체를 이용해 최초에 ListBoxItem을 생성하라는 의지를 발하는 녀석은 "Panel"이다. 만약 StackPanel이 ItemsPanel로 사용되었다면 ItemsSource가 바인딩 된 시점에 다음 코드가 불리게 된다.

public abstract class Panel : FrameworkElement, IAddChild
    internal virtual void GenerateChildren()
    {
        IItemContainerGenerator generator = (IItemContainerGenerator)_itemContainerGenerator; 
        if (generator != null)
        {
            using (generator.StartAt(new GeneratorPosition(-1, 0), GeneratorDirection.Forward))
            { 
                UIElement child;
                while ((child = generator.GenerateNext() as UIElement) != null) 
                { 
                    _uiElementCollection.AddInternal(child);
                    generator.PrepareItemContainer(child); 
                }
            }
        }
} 

StackPanel은 ListBox의 Items가 변경되었을 때 자신의 Panel의 GenerateChildren()메소드를 그대로 사용해 모든 데이터에 대응되는 ItemContainer를 만들어 자신(StackPanel)에 삽입하는 것을 볼 수 있다. 하여간 중요한 것은 ItemsContainer를 생성하게 만드는 객체는 바로 Panel이라는 사실이다. 따라서 UI Virtualization을 구현하려면 바로 이 Panel을 만지작 거려야 한다.

그런데 Panel만을 수정하는 것으로 UI Virtualization을 구현할 수 있을까? 다른 정보가 필요하지 않을까? 가상화 지원 Panel은 일반적인 Panel(Grid, StackPanel, WrapPanel)과 다르게 특정 시점에 화면에 보여야만 하는 컨트롤만을 생성해 자신의 Children에 추가해야한다. 그런데 이것은 Panel 홀로 할 수 없는 일이다. 어떤 것이 추가로 필요할까? 그렇다. 바로 "화면"에 대한 정보, 곧 ScrollViewer정보를 알 수 있어야 하며 나아가 ScrollViewer를 컨트롤 할 수 있어야 한다. UI Virtualization기능은 가상화 지원 Panel과 ScrollViewer를 동시에 필요로 한다. 둘 중 하나라도 만족하지 못하면 UI Virtualization 기능을 구현할 수 없다. UI Virtualization 기능을 위해선 이 ScrollViewer의 역할이 굉장히 중요하다.

이 ScrollViewer를 살펴보자. WPF ScrollViewer Overview 페이지를 보면 Physical Scroll 과 Logical Scroll의 개념이 나온다. Physical Scroll을 사용하면 Pixel, DIU단위의 스크롤링이 가능하다. 반면 Logical Scroll을 사용하면 ScrollViewer의 스크롤 동작을 수행했을 때 pixel단위로 움직이는 것이 아니라 임의의 논리 단위 만큼의 Height 또는 Width만큼 Scroll의 Offset이 이동하게 된다.(ListBox의 동작을 생각해보라!)

일단 Physical Scroll이 어떻게 가능할 지 생각해보자. ScrollViewer는 자신의 가용 크기를 알아야 하며 자식이 요구하는 크기를 알아야 한다. 이는 모두 Measure동작에서 이루어진다. ScrollViewer는 자식에게 Measure(double.PositiveInfinity, double.PositiveInfinity)를 사용해 자식이 요구하는 최대 크기를 알아낼 수 있고 MesureOverride 메소드에서 들어오는 constraint인수로 자신의 가용 크기를 알아 낼 수 있다. 이 때 자식이 요구하는 크기가 ScrollViewer의 Extent Size가 되며, ScrollViewer의 가용 크기, 곧 화면에 노출되는 ScrollViewer의 크기가 Viewport Size가 된다. 따라서 다음이 성립된다.

  • Viewport Size : ExtentSize = Scroll Thumb Size  : ScrollBar Size

그리고 해당 정보를 사용해 VerticalScrollBar, HorizontalScrollBar의 Visual을 결정할 것이다. 그리고 이후 Scroll동작이 일어나면 ㅡ VerticalOffset또는 HorizontalOffset 값이 변경되면 ㅡ ScrollViewer의 Offset을 이용해 Child를 Arrange시키면 되는 것이다.

물론 Logical Scroll은 이와 다르게 동작한다. Logical Scroll을 위해서 ScrollViewer는 자신의 기능(Extent Size 결정, Viewport Size결정, Vertical Offset, Horizontal Offset이 변경되었을 때의 동작 등)을IScrollInfo라는 Interface를 구현하는 객체에게 위임해야 한다. 실제로 Logical Scroll로 동작하는 ScrollViewer는 ScrollBar를 표시하는 일만 하는 것 같다. 대부분의 일들을 ScrollViewer자신이 결정하지 않고 자신의 기능을 위임한 객체에게 맞긴다. UI Virtualization을 위한 Panel은 자식들의 실제 Physical Size를 알 수 없기에 반드시 이 Logical Scroll을 사용해야 한다.

이제 UI Virtualization을 위한 Panel의 동작을 살펴보자. 이 Panel의 동작은 ItemContainer의 생성, 화면에 보여지지 않는 ItemContainer의 파괴(Optional), ScrollViewer의 정보 설정, ItemContainer의 배치로 요약될 수 있다.

  1. ItemContainer 생성
  2. 화면에 보이지 않는 ItemContainer 파괴
  3. ScrollViewer의 정보 설정
  4. ItemContainer의 배치

1,2,3 동작은 Panel의 Measure동작시 이루어진다. Measure 동작시 Panel은 ScrollViewer 정보와 자신의 가용 크기를 바탕으로 화면에 보여져야하는 ItemContainer를 ItemContainerGenerator를 이용해 생성해야 한다. Vertical Orientation의 가상화 지원 StackPanel을 만든다고 한다면 아마 다음과 같은 코드를 사용할 수 있을 것이다.

protected override Size MeasureOverride(Size availableSize)
{
    _viewport = availableSize;
 
    ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
    if (itemsControl.Items.Count == 0)
    {
        return new Size();
    }
 
    double offset;
    GeneratorPosition position = ItemContainerGenerator.
                        GeneratorPositionFromIndex(FindItemIndexFromOffset(VerticalOffset, out offset));
 
    #region 영역의 크기만큼의 ItemContainer을 생성한다.
    using (this.ItemContainerGenerator.StartAt(position, GeneratorDirection.Forward, true))
    {
        double remainHeight = availableSize.Height
 
        while (remainHeight > 0)
        {
            bool isRealized;
            UIElement container = ItemContainerGenerator.GenerateNext(out isRealized) as UIElement;
            if (container != null && isRealized)
            {
                this.AddInternalChild(container);
                ItemContainerGenerator.PrepareItemContainer(container);
            }
            else if (container != null && !isRealized)
            {
                if (!Children.Contains(container))
                {
                    this.AddInternalChild(container);
                }
            }
            else
            {
                break;
            }
            container.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
            remainHeight -= container.DesiredSize.Height;
        }
    }

위 작업이 종료되면 2,3번 동작을 MeasureOverride 메소드내에 포함시켜야 한다. 2번은 Optional할 수 있으므로 지나치더라도 3번 동작을 수행해야하는데 그 중에서도 ScrollViewer의 Extent Size를 결정해야한다. 그런데 이 ItemContainer의 크기가 고정적이라면 이것이 어렵지 않은데 각각의 ItemContainer 크기가 가변적이라면 어쩔 수 없이 "추정"이 들어가야 하므로 굉장히 복잡하고도 어렵다. 따라서 이 "추정"을 가능한 실재와 정확하게 하는 것이 가상화 Panel을 만드는 프로그래머의 주 목표라 할 수 있겠다. "추정"이 들어가는 부분은 이 뿐만이 아니다. 위 코드를 보면 FindItemIndexFromOffset이라는 메소드가 있는데 이 역시 ScrollViewer의 Vertical Offset을 바탕으로 가장 위에 보여질 Item의 Index를 "추정" 해야 한다.

protected override Size MeasureOverride(Size availableSize)
{
       // ...
       // ...
       // ...  
    this.extentSize = GetExtentSize();
    this.ScrollOwner.InvalidateScrollInfo();
    CleanUpItems(firstElement, lastElement);
    return availableSize;
}

현재 회사에서 만들고 있는 가상화 지원 패널은 ItemContainer의 크기를 바인딩 된 Data의 값을 바탕으로 얻을 수있기에 이 부분이 크게 어렵지 않았는데 이런 부가 정보가 없이 정확히 ScrollViewer의 정보를 계산하는 가상화 지원 패널을 만드는 것은 사실상 불가능하다.

이 Measure동작을 완료했다면 4.생성한 ItemContainer들을 배치하는 일이 남았다. 이 일은 ArrangeOverride 메소드가 담당해야 한다.

protected override Size ArrangeOverride(Size finalSize)
{
    double startYlocation =0.0;
    double offset;
    if (Children.Count == 0) return finalSize;
    var index = FindItemIndexFromOffset(VerticalOffset, out offset);
            
    GeneratorPosition position = ItemContainerGenerator.GeneratorPositionFromIndex(index);
            
    using (this.ItemContainerGenerator.StartAt(position, GeneratorDirection.Forward, true))
    {
        while (startYlocation < finalSize.Height)
        {
            bool isRealized;
            UIElement container = ItemContainerGenerator.GenerateNext(out isRealized) as UIElement;
            if (container == null) return finalSize;
            if (!Children.Contains(container))
            {
                throw new InvalidOperationException();
            }
 
            container.Arrange(new Rect(new Point(HorizontalOffset * -1, startYlocation),
                    new Size(finalSize.Width + HorizontalOffset,container.DesiredSize.Height)));
            startYlocation += container.RenderSize.Height;
        }
    }
    return finalSize;
}

여기까지 왔다면 ItemContainer들이 정상적으로 화면에 보이는 것을 확인 할 수 있을 것이다. 하지만 위 Measure와 Arrange코드는 사실 "최소한"의 부분이다. 사실 가장 중요한 부분은 역시 "추정"에 있는데 내가 만든 Panel은 그 동작이 현업의 특수한 상황에 의존적이라 이 코드에 추가하지 않았다. 완전한 코드를 소개하면 좋을 텐데 그러지 못해 조금 아쉽다. 그래서 다음 포스트에는 이 "추정"동작을 완벽 하게 해낼 수 없기에 이를 다른 방식으로 비껴나가는 전략을 취하는 Panel을 소개하기로 한다.

 

 

결론

보기에도 간단해 보이지 않는 이 가상화는 만들어진 소수의 ItemContainer를 바탕으로 보여지지 않는 영역을 예측, 추정해야 하는 것이기에 어려움이 있다. 때로는 "확률"이 쓰일 수도 있고 "평균"에 의지해야 할 수도 있다. 그리고 안타깝게도 이것은 결국 추정이기 때문에 어떤 경우에는 옳았던 구현이 다양한 상황에 의해 어떤 경우에는 틀릴 수 있다. 또한 모든 것을 이해한다고 생각하고 코딩에 뛰어들었지만 이 프로그램 세상은 언제나 그랬듯이 내가 미처 알지 못한 미지의 영역을 비웃으며 소개한다. 물론 그 미지의 영역을 헤집고 나아가는 것이 이 세계를 여행하는 자의 가장 큰 즐거움이라 할 수 있겠지만.

References :

신고


 

티스토리 툴바