Виртуализация данных в WPF

Доброго времени суток.

Меня давно интересовал вопрос написания своего класса для оптимальной загрузки информации из базы данных, например когда количество записей более 10 млн. записей.
Отложенная загрузка информации, использование нескольких источников данных и пр.

Не нашел на хабре пост посвященный данной теме, поэтому представляю вам свой перевод статьи Пола МакКлина, которая стала отправной точкой в решении поставленных задач.

Оригинал статьи: здесь
Исходные файлы проекта: здесь

Дальше по тексту я буду писать от имени автора.

Введение


WPF предоставляет некоторые интересные возможности виртуализации пользовательского интерфейса для эффективной работы с большими коллекциями, по крайней мере с точки зрения пользовательского интерфейса, но не предусматривает общего метода для виртуализации данных. В то время, как во многих постах на форумах идет обсуждение виртуализации данных, никто (насколько мне известно) не опубликовал решение. Статья представляет одно из таких решений.

Предпосылки


Виртуализация пользовательского интерфейса

Когда элемент управления WPF ItemsControl связан с большой коллекцией исходных данных с включенной настройкой виртуализации UI, элемент управления создает визуальные контейнеры только для видимых элементов (плюс несколько сверху и снизу). Обычно это малая часть исходной коллекции. Когда пользователь прокручивает список, новые визуальные контейнеры создаются тогда, когда элементы становятся видимыми, а старые контейнеры уничтожаются в тот момент, когда элементы становятся невидимыми. При повторном использовании визуальных контейнеров, мы снижаем накладные расходы на создание и уничтожение объектов.

Виртуализация пользовательского интерфейса означает, что элемент управления может быть связан с большой коллекцией данных и занимать при этом меньший объем памяти в связи с малым количеством видимых контейнеров.

Виртуализация данных

Виртуализация данных — термин, который означает достижение виртуализации для объекта данных связанного с ItemsControl. Виртуализация данных не предусмотрена в WPF. Для относительно маленьких коллекций базовых объектов потребление памяти не имеет значения. Однако для больших коллекций потребление памяти может стать очень значительным. Кроме того, получение информации из базы данных или создание объектов может занять много времени, особенно при сетевых операциях. По этим причинам желательно использовать какой-то механизм виртуализации данных, чтобы ограничить количество объектов данных, которые должны быть извлечены из источника и размещены в памяти.

Решение


Обзор

Это решение основывается на том, что когда элемент управления ItemsControl связан с реализацией IList, а не IEnumerable, следовательно он не перечисляет весь список, а вместо этого предоставляет только выборку элементов, необходимых для показа. Он использует свойство Count для определения размера коллекции, для установки размера полосы прокрутки. В будущем он будет перебирать экранные элементы через индексатор списка. Таким образом, можно создать IList, который может сообщить, что он имеет большое количество элементов, а получать элементы только по мере необходимости.

IItemsProvider<T>

Для того чтобы использовать данное решение, базовый источник должен уметь предоставлять информацию о количестве элементов в коллекции, и предоставлять малую часть (или страницу) из всей коллекции. Эти требования выражены в интерфейсе IItemsProvider.
/// <summary>
/// Представляет поставщика деталей коллекции of collection details.
/// </summary>
/// <typeparam name="T">Тип элемента в коллекции</typeparam>
public interface IItemsProvider<T>
{
    /// <summary>
    /// Получить общее количество доступных элементов
    /// </summary>
    /// <returns></returns>
    int FetchCount();

    /// <summary>
    /// Получить диапазон элементов
    /// </summary>
    /// <param name="startIndex">Начальный индекс</param>
    /// <param name="count">Количество элементов для получения</param>
    /// <returns></returns>
    IList<T> FetchRange(int startIndex, int count);
}

Если базовый источник данных является запросом к базе данных, то можно относительно легко реализовать интерфейс IItemsProvider, используя агрегатную функцию COUNT(), или выражения OFFSET и LIMIT, предоставляемые большинством поставщиков баз данных.

VirtualizingCollection<T>

Это реализация интерфейса IList которая осуществляет виртуализацию данных. VirtualizingCollection<T> разделяет все пространство коллекции на ряд страниц. При необходимости страницы загружаются в память, и уничтожаются при ненадобности.

Интересные моменты будут обсуждаются ниже. За подробностями обращайтесь, пожалуйста, к исходным кодам приложенным к данной статье.

Первый аспект реализации IList – это реализация свойства Count. Оно используется элементом управления ItemsControl для оценки размера коллекции и отрисовки полосы прокрутки.
Private int _count = -1;

public virtual int Count
{
    get
    {
        if (_count == -1)
        {
            LoadCount();
        }
        return _count;
    }
    protected set
    {
        _count = value;
    }
}

protected virtual void LoadCount()
{
    Count = FetchCount();
}

protected int FetchCount()
{
    return ItemsProvider.FetchCount();
}

Свойство Count реализовано с использованием шаблона отложенной или ленивой загрузки (lazy load). Оно использует специальное значение -1, чтобы показать, что значение еще не загружено. При первом обращении свойство загрузит актуальное количество элементов из ItemsProvider.

Другим важным аспектом интерфейса IList является реализация индексатора.
public T this[int index]
{
    get
    {
        // определить какая страница и смещение внутри страницы
        int pageIndex = index / PageSize;
        int pageOffset = index % PageSize;

        // запросить главную страницу
        RequestPage(pageIndex);

        // если обратились более чем к 50% тогда запросить следующую страницу
        if ( pageOffset > PageSize/2 && pageIndex < Count / PageSize)
            RequestPage(pageIndex + 1);

        // если обратились менее чем к 50% тогда запросить предшествующую страницу
        if (pageOffset < PageSize/2 && pageIndex > 0)
            RequestPage(pageIndex - 1);

        // удалить устаревшие страницы
        CleanUpPages();

        // защитная проверка в случае асинхронной загрузки
        if (_pages[pageIndex] == null)
            return default(T);

        // вернуть запрошенный элемент
        return _pages[pageIndex][pageOffset];
    }
    set { throw new NotSupportedException(); }
}

Индексатор представляет собой самую уникальную часть решения. Во первых, он должен определить, какой странице принадлежит запрошенный элемент (pageIndex) и смещение внутри страницы (pageOffset). Затем вызывается метод RequestPage(), возвращающий страницу.

Затем происходит загрузка следующей или предшествующей страницы на основании переменной pageOffset. Это основано на том предположении, что если пользователи просматривают страницу 0, то есть большая вероятность, что они будут прокручивать вниз для просмотра страницы 1. Получение данных заранее не вызывает пропусков данных при отображении на экране.

CleanUpPages() вызывается для очистки (или выгрузки) не используемых страниц.

Наконец, защитная проверка на наличие страницы. Эта проверка необходима на случай если метод RequstPage() не работает в синхронном режиме, как при использовании производного класса AsyncVirtualizingCollection<T>.
private readonly Dictionary<int, IList<T>> _pages = 
        new Dictionary<int, IList<T>>();
private readonly Dictionary<int, DateTime> _pageTouchTimes = 
        new Dictionary<int, DateTime>();

protected virtual void RequestPage(int pageIndex)
{
    if (!_pages.ContainsKey(pageIndex))
    {
        _pages.Add(pageIndex, null);
        _pageTouchTimes.Add(pageIndex, DateTime.Now);
        LoadPage(pageIndex);
    }
    else
    {
        _pageTouchTimes[pageIndex] = DateTime.Now;
    }
}

protected virtual void PopulatePage(int pageIndex, IList<T> page)
{
    if (_pages.ContainsKey(pageIndex))
        _pages[pageIndex] = page;
}

public void CleanUpPages()
{
    List<int> keys = new List<int>(_pageTouchTimes.Keys);
    foreach (int key in keys)
    {
        // page 0 is a special case, since the WPF ItemsControl
        // accesses the first item frequently
        if ( key != 0 && (DateTime.Now - 
             _pageTouchTimes[key]).TotalMilliseconds > PageTimeout )
        {
            _pages.Remove(key);
            _pageTouchTimes.Remove(key);
        }
    }
}

Страницы хранятся в словаре (Dictionary), в котором индекс используется в качестве ключа. Также словарь используется для хранения информации о времени последнего использования. Это время обновляется при каждом обращении к странице. Оно используется методом CleanUpPages() для удаления страниц, к которым не было обращения за значительное количество времени.
protected virtual void LoadPage(int pageIndex)
{
    PopulatePage(pageIndex, FetchPage(pageIndex));
}

protected IList<T> FetchPage(int pageIndex)
{
    return ItemsProvider.FetchRange(pageIndex*PageSize, PageSize);
}

В завершении, FetchPage() выполняет получение страницы из ItemsProvider, и метод LoadPage() производит работу по вызову метода PopulatePage(), размещающего страницу в словаре c заданным индексом.

Может показаться, что в коде много несущественных методов, но они были разработаны таким образом по определенным причинам. Каждый метод выполняет ровно одну задачу. Это помогает сохранить код читаемым, а также делает простым расширение и модификацию функциональности в производных классах, как будет наблюдаться далее.

Класс VirtualizingCollection< T> достигает главной цели по осуществению виртуализации данных. К сожалению, в процессе использования этот класс имеет один существенный недостаток — все методы получения данных выполняются синхронно. Это означает, что они запускаются потоками пользовательского интерфейса, что в результате потенциально затормаживают работу приложения.

AsyncVirtualizingCollection< T>

Класс AsyncVirtualizingCollection< T> унаследован от VirtualizingCollection< T>, и переопределяет метод Load() для реализации асинхронной загрузки данных. Ключевой особенностью асинхронного источника данных является то, что в момент получения данных он должен оповестить через свою связку (data binding) пользовательский интерфейс. В обычных объектах это решается использованием интерфейса INotifyPropertyChanged. Для реализации коллекций необходимо использовать его близкого родственника INotifyCollectionChanged. Этот интерфейс используется классом ObservableCollection< T>
public event NotifyCollectionChangedEventHandler CollectionChanged;

protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
    NotifyCollectionChangedEventHandler h = CollectionChanged;
    if (h != null)
        h(this, e);
}

private void FireCollectionReset()
{
    NotifyCollectionChangedEventArgs e = 
      new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset);
    OnCollectionChanged(e);
}

public event PropertyChangedEventHandler PropertyChanged;

protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
{
    PropertyChangedEventHandler h = PropertyChanged;
    if (h != null)
        h(this, e);
}

private void FirePropertyChanged(string propertyName)
{
    PropertyChangedEventArgs e = new PropertyChangedEventArgs(propertyName);
    OnPropertyChanged(e);
}

Класс AsyncVirtualizingCollection< T> реализует оба интерфейса INotifyPropertyChanged и INotifyCollectionChanged для предоставления максимальной гибкости связки. В этой реализации нечего отметить.
protected override void LoadCount()
{
    Count = 0;
    IsLoading = true;
    ThreadPool.QueueUserWorkItem(LoadCountWork);
}

private void LoadCountWork(object args)
{
    int count = FetchCount();
    SynchronizationContext.Send(LoadCountCompleted, count);
}

private void LoadCountCompleted(object args)
{
    Count = (int)args;
    IsLoading = false;
    FireCollectionReset();
}

В переопределенном методе LoadCount(), получение вызывается асинхронно через ThreadPool. По завершении, будет установлено новое количество и вызван метод FireCollectionReset() обновляющий пользовательский интерфейс через InotifyCollectionChanged. Заметьте, что метод LoadCountCompleted вызывается из потока пользовательского интерфейса благодаря использованию SynchronizationContext. Свойство SynchronizationContext устанавливается в конструкторе класса, с предположением, что экземпляр коллекции будет создан в потоке пользовательского интерфейса.
protected override void LoadPage(int index)
{
    IsLoading = true;
    ThreadPool.QueueUserWorkItem(LoadPageWork, index);
}

private void LoadPageWork(object args)
{
    int pageIndex = (int)args;
    IList<T> page = FetchPage(pageIndex);
    SynchronizationContext.Send(LoadPageCompleted, new object[]{ pageIndex, page });
}

private void LoadPageCompleted(object args)
{
    int pageIndex = (int)((object[]) args)[0];
    IList<T> page = (IList<T>)((object[])args)[1];

    PopulatePage(pageIndex, page);
    IsLoading = false;
    FireCollectionReset();
}

Асинхронная загрузка данных страницы действуют по тем же правилам, и снова метод FireCollectionReset() используется для обновления пользовательского интерфейса.

Отметим также свойство IsLoading. Это простой флаг, который может быть использован пользовательским интерфейсом для индикации загрузки коллекции. Когда свойство IsLoading изменяется, метод FirePropertyChanged() вызывает обновление пользовательского интерфейса через механизм INotifyProperyChanged.
public bool IsLoading
{
    get
    {
        return _isLoading;
    }
    set
    {
        if ( value != _isLoading )
        {
            _isLoading = value;
            FirePropertyChanged("IsLoading");
        }
    }
}

Демонстрационный проект


Для того, чтобы продемонстрировать данное решения, я создал простой демонстрационный проект (входит в исходные коды проекта).

Во-первых, была создана реализация класса IItemsProvider, которая предоставляет фиктивные данные с остановкой потока для симуляции задержки получения данных с диска или по сети.
public class DemoCustomerProvider : IItemsProvider<Customer>
{
    private readonly int _count;
    private readonly int _fetchDelay;

    public DemoCustomerProvider(int count, int fetchDelay)
    {
        _count = count;
        _fetchDelay = fetchDelay;
    }

    public int FetchCount()
    {
        Thread.Sleep(_fetchDelay);
        return _count; 
    }

    public IList<Customer> FetchRange(int startIndex, int count)
    {
        Thread.Sleep(_fetchDelay);

        List<Customer> list = new List<Customer>();
        for( int i=startIndex; i<startIndex+count; i++ )
        {
            Customer customer = new Customer {Id = i+1, Name = "Customer " + (i+1)};
            list.Add(customer);
        }
        return list;
    }
}

Вездесущий объект Customer использован в качестве элемента коллекции.

Простое окно WPF с элементом управления ListView было создано, чтобы позволить пользователю поэкспериментировать с различными реализациями списка.
<Window x:Class="DataVirtualization.DemoWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="Data Virtualization Demo - By Paul McClean" Height="600" Width="600">
    
    <Window.Resources>
        <Style x:Key="lvStyle" TargetType="{x:Type ListView}">
            <Setter Property="VirtualizingStackPanel.IsVirtualizing" Value="True"/>
            <Setter Property="VirtualizingStackPanel.VirtualizationMode" Value="Recycling"/>
            <Setter Property="ScrollViewer.IsDeferredScrollingEnabled" Value="True"/>
            <Setter Property="ListView.ItemsSource" Value="{Binding}"/>
            <Setter Property="ListView.View">
                <Setter.Value>
                    <GridView>
                        <GridViewColumn Header="Id" Width="100">
                            <GridViewColumn.CellTemplate>
                                <DataTemplate>
                                    <TextBlock Text="{Binding Id}"/>
                                </DataTemplate>
                            </GridViewColumn.CellTemplate>
                        </GridViewColumn>
                        <GridViewColumn Header="Name" Width="150">
                            <GridViewColumn.CellTemplate>
                                <DataTemplate>
                                    <TextBlock Text="{Binding Name}"/>
                                </DataTemplate>
                            </GridViewColumn.CellTemplate>
                        </GridViewColumn>
                    </GridView>
                </Setter.Value>
            </Setter>
            <Style.Triggers>
                <DataTrigger Binding="{Binding IsLoading}" Value="True">
                    <Setter Property="ListView.Cursor" Value="Wait"/>
                    <Setter Property="ListView.Background" Value="LightGray"/>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>
    
    <Grid Margin="5">
        
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

       
        <GroupBox Grid.Row="0" Header="ItemsProvider">
            <StackPanel Orientation="Horizontal" Margin="0,2,0,0">
                <TextBlock Text="Number of items:" Margin="5" 
                  TextAlignment="Right" VerticalAlignment="Center"/>
                <TextBox x:Name="tbNumItems" Margin="5" 
                  Text="1000000" Width="60" VerticalAlignment="Center"/>
                <TextBlock Text="Fetch Delay (ms):" Margin="5" 
                  TextAlignment="Right" VerticalAlignment="Center"/>
                <TextBox x:Name="tbFetchDelay" Margin="5" 
                  Text="1000" Width="60" VerticalAlignment="Center"/>
            </StackPanel>
        </GroupBox>

        <GroupBox Grid.Row="1" Header="Collection">
            <StackPanel>
                <StackPanel Orientation="Horizontal" Margin="0,2,0,0">
                    <TextBlock Text="Type:" Margin="5" 
                      TextAlignment="Right" VerticalAlignment="Center"/>
                    <RadioButton x:Name="rbNormal" GroupName="rbGroup" 
                      Margin="5" Content="List(T)" VerticalAlignment="Center"/>
                    <RadioButton x:Name="rbVirtualizing" GroupName="rbGroup" 
                      Margin="5" Content="VirtualizingList(T)" 
                      VerticalAlignment="Center"/>
                    <RadioButton x:Name="rbAsync" GroupName="rbGroup" 
                      Margin="5" Content="AsyncVirtualizingList(T)" 
                      IsChecked="True" VerticalAlignment="Center"/>
                </StackPanel>
                <StackPanel Orientation="Horizontal" Margin="0,2,0,0">
                    <TextBlock Text="Page size:" Margin="5" 
                      TextAlignment="Right" VerticalAlignment="Center"/>
                    <TextBox x:Name="tbPageSize" Margin="5" 
                      Text="100" Width="60" VerticalAlignment="Center"/>
                    <TextBlock Text="Page timeout (s):" Margin="5" 
                      TextAlignment="Right" VerticalAlignment="Center"/>
                    <TextBox x:Name="tbPageTimeout" Margin="5" 
                      Text="30" Width="60" VerticalAlignment="Center"/>
                </StackPanel>
             </StackPanel>
        </GroupBox>

        <StackPanel Orientation="Horizontal" Grid.Row="2">
            <TextBlock Text="Memory Usage:" Margin="5" 
              VerticalAlignment="Center"/>
            <TextBlock x:Name="tbMemory" Margin="5" 
              Width="80" VerticalAlignment="Center"/>

            <Button Content="Refresh" Click="Button_Click" 
              Margin="5" Width="100" VerticalAlignment="Center"/>

            <Rectangle Name="rectangle" Width="20" Height="20" 
                     Fill="Blue" Margin="5" VerticalAlignment="Center">
                <Rectangle.RenderTransform>
                    <RotateTransform Angle="0" CenterX="10" CenterY="10"/>
                </Rectangle.RenderTransform>
                <Rectangle.Triggers>
                    <EventTrigger RoutedEvent="Rectangle.Loaded">
                        <BeginStoryboard>
                            <Storyboard>
                                <DoubleAnimation Storyboard.TargetName="rectangle" 
                                   Storyboard.TargetProperty=
                                     "(TextBlock.RenderTransform).(RotateTransform.Angle)" 
                                   From="0" To="360" Duration="0:0:5" 
                                   RepeatBehavior="Forever" />
                            </Storyboard>
                        </BeginStoryboard>
                    </EventTrigger>
                </Rectangle.Triggers>
            </Rectangle>
            
            <TextBlock Margin="5" VerticalAlignment="Center" 
              FontStyle="Italic" Text="Pause in animation indicates UI thread stalled."/>
            
        </StackPanel>

        <ListView Grid.Row="3" Margin="5" Style="{DynamicResource lvStyle}"/>
        
    </Grid>
</Window>

Не стоит вдаваться в подробности XAML. Единственное что стоит отметить — это использование заданных стилей ListView для изменения заднего фона и курсора мыши в ответ на изменение свойства IsLoading.
public partial class DemoWindow
{
    /// <summary>
    /// Initializes a new instance of the <see cref="DemoWindow"/> class.
    /// </summary>
    public DemoWindow()
    {
        InitializeComponent();
        
        // use a timer to periodically update the memory usage
        DispatcherTimer timer = new DispatcherTimer();
        timer.Interval = new TimeSpan(0, 0, 1);
        timer.Tick += timer_Tick;
        timer.Start();
    }

    private void timer_Tick(object sender, EventArgs e)
    {
        tbMemory.Text = string.Format("{0:0.00} MB", 
                             GC.GetTotalMemory(true)/1024.0/1024.0);
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        // create the demo items provider according to specified parameters
        int numItems = int.Parse(tbNumItems.Text);
        int fetchDelay = int.Parse(tbFetchDelay.Text);
        DemoCustomerProvider customerProvider = 
                       new DemoCustomerProvider(numItems, fetchDelay);

        // create the collection according to specified parameters
        int pageSize = int.Parse(tbPageSize.Text);
        int pageTimeout = int.Parse(tbPageTimeout.Text);

        if ( rbNormal.IsChecked.Value )
        {
            DataContext = new List<Customer>(customerProvider.FetchRange(0, 
                                                   customerProvider.FetchCount()));
        }
        else if ( rbVirtualizing.IsChecked.Value )
        {
            DataContext = new VirtualizingCollection<Customer>(customerProvider, pageSize);
        }
        else if ( rbAsync.IsChecked.Value )
        {
            DataContext = new AsyncVirtualizingCollection<Customer>(customerProvider, 
                              pageSize, pageTimeout*1000);
        }
    }
}

Макет окна доволно простой, но достаточный для демонстрации решения.

Пользователь может настроить количество элементов в экземпляре DemoCustomerProvider и время симулятора задержки.

Демонстрация позволяет пользователям сравнить стандартную реализацию List(T), реализацию с синхронной загрузкой данных VirtualizingCollection(T) и реализацию с асинхронной загрузкой данных AsyncVirtualizingCollection(T). При использовании VirtualizingCollection(T) и AsyncVirtualizingCollection(T) пользователь может задать размер страницы и таймаут (задает время через которое страница должна быть выгружена из памяти). Они должны быть выбраны в соответствии с характеристиками элемента и ожидаемым шаблоном использования.



Для сравнения разных типов коллекций, в окне также отображается общий объем использованной памяти. Анимация вращающегося квадрата используется для визуализации остановки потока пользовательского интерфейса. В полностью асинхронном решении, анимация не должна затормаживаться или останавливаться.

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 14

    0
    Отличная статья и отличный перевод. Спасибо!
      0
      Рад, что статья оказалась полезной для вас.
        +1
        Спасибо за статью. На будущее — если делаете перевод, то оформляйте его как перевод
          0
          Спасибо. Учту в будущем.
        0
        А если Count неизвестен?
          0
          Приведите ситуацию для примера. Что выступает в качестве источника данных.
            0
            представим что мы берем данные не с локального хранилища, а от веб-сервиса, который поддерживает пейджирование, но не отдает кол-во данных. т.е. при прокрутке вниз контрола, надо подгружать уже новые данные если они есть и изменять Count динамически.
              0
              Думаю можно модернизировать метод LoadPage, который в случае наличия новой страницы данных от службы будет подгружать страницу и увеличивать Count. Полоса прокрутки будет увеличиваться автоматически при изменении этого свойства.
                0
                Ну LoadPage не вызовется пока Count будет меньшим или равным нулю. Т.е. надо форсировать его вызов.

                Я не хочу устроить демагогию, просто я уже неоднократно сталкивался с такой задачей (в большей степени в Windows Phone) и пытаюсь все найти какое-то универсальное решение.
                  0
                  При инициализации списка у вас загружается нулевая страница.
                    0
                    Это достаточно баян ситуация, возвращаете на 1 элемент больше чем у вас есть, как только просят элемент которого у вас нет — докачиваете его. Более того, можете еще и ГУИ сделать, последний элемент отображать спинером и тогда всем будет понятно, что новые элементы подгружаются. Такая идеология повсеместно используется в iOS, где данные в списки впринципе заполняются исключительно через виртуализацию (по крайней мере раньше было так)
                    0
                    К примеру при загрузке страницы вы параллельно запрашиваете следующую страницу. Если она есть, ставите Count++. При пролистывании на следующую страницу запрашиваете на наличие следующую, если она есть увеличиваете количество. В моем представлении как-то так.
            • UFO just landed and posted this here
              • UFO just landed and posted this here

                Only users with full accounts can post comments. Log in, please.