Wednesday, June 29, 2016

A High Performance WPF DataGrid Part II

The Suppressible is straightforward:

public class Suppressible : Notifier
{
    private bool _isSuppressed = true;

    public bool IsSuppressed
    {
        get { return _isSuppressed; }
        set
        {
            if (_isSuppressed != value)
            {
                _isSuppressed = value;
                RaisePropertyChanged();
            }
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected void RaisePropertyChanged([CallerMemberName]string propertyName = "")
    {
        if(!_isSuppressed)
        {
            var handler = PropertyChanged;
            if (handler != null)
            {
                handler(this, new PropertyChangedEventArgs(propertyName));
            }
        }
    }
}

I don't need to change anything else in Stock but the base class. Now is the fun part:

public class HighThroughputBehavior : Behavior<ItemsControl>
{
    protected override void OnAttached() { AssociatedObject.Loaded += OnItemsControlLoaded; }

    protected override void OnDetaching() { AssociatedObject.Loaded -= OnItemsControlLoaded; }

    private void OnItemsControlLoaded(object sender, System.Windows.RoutedEventArgs args)
    {
        ScrollViewer scrollViewer = ((ItemsControl)AssociatedObject).FindVisualChild<ScrollViewer>();
        OnScrollChanged(scrollViewer);
        scrollViewer.ScrollChanged += OnScrollChanged;
    }

    private void OnScrollChanged(object sender, RoutedEvent e = null) { ... }
}

When OnScrollChanged is invoked, how do I find and iterate through the items going off the screen and on the screen? ScrollViewer has a pair of properties seems to be handy:

ScrollViewer.VerticalOffset, it contains the index of the first Stock item in the view port; 
ScrollViewer.ViewPortHeight, it contains the number of the Stock items in the view port, can I just say:

foreach (int i in Enumerable.Range(scrollViewer.VerticalOffset, scrollViewer.ViewPortHeight) stocks[i].IsSuppressed = false;

and call it done? I ran into an issue soon after that. The value ScrollViewer.ViewPortHeight contains only the items are entirely in the view port. If the bottom item only shows half of it, it may not be included so in this implementation the data won't get refreshed on the screen! (why did I say "may not be included"? because sometimes it is included depends on how you scrolled).

I can choose to be lazy and just update whatever included in ViewPortHeight + 1, but I hate workaround like this, soon or later it will become annoying when adding new features. So, I go back to the old school stuffs from the Win32 time: do the geometry and calculate it by your self. ScrollViewer doesn't have the numbers I need directly, I have to go with VirtualizingStackPanel:

private IList<Stock> _onScreenItems = new List<Stock>();

private void OnScrollChanged(object sender, RoutedEvent e = null) 
{
VirtualizingStackPanel panel = ((ScrollViewer)sender).FindVisualChild<VirtualizingStackPanel>();
    Rect layout = LayoutInformation.GetLayoutSlot(panel);
    IEnumerable<ListBoxItem> children = panel.Children.Cast<ListBoxItem>();
    var onScreenItems = children
        .Select(item => new { bound = LayoutInformation.GetLayoutSlot(item), item })
        .Where(x => (x.bound.Bottom < layout.Bottom && x.bound.Bottom > layout.Top) 
|| (x.bound.Top > 0 && x.bound.Top < layout.Bottom))
        .Select(x => x.item.DataContext).Cast<Suppressible>().ToList();
foreach (var suppressible in _onScreenItems.Except(onScreenItems))
        suppressible.IsSuppressed = true;
    foreach (var suppressible in onScreenItems)
        suppressible.IsSuppressed = false;
_onScreenItems = onScreenItems;
}

This is very accurate, I can detect a Stock scrolled in and scrolled out of the view port.

I calculate how many times the mock data generator refreshes the 3226 stocks in a second and bound it to the screen, so I can clearly see how each step of the improvements did, the DataGrid (I called a ItemsControl a DataGrid? yes it looks like a DataGrid and scrolls like a DataGrid so it is a DataGrid) holds up really well against the huge amount of data pumping in.

I won't stop here. Refresh the data of each stock hundreds of times in a second on the UI doesn' make sense, even da Vinci won't be able to track them (does a man can track thousands items in a ListBox? the use case is not in discussion at this moment), and it obviously hurts the performance, so I need to reduce the refresh rate of the data on the screen - throttling the data source is not an option because Stock objects are the best place to store the latest data, even if the data is not shown on the screen.

I added a QueuePropertyChanged(string propertyName) method alongside with the old RaisePropertyChanged in Suppressible, all of the high volume properties like Bid, Ask, Last, Change, Percent, Volume will redirect their notification calls this way:

private HashSet<string> _eventCache = new HashSet<string>(); 

protected void QueuePropertyChanged
([CallerMemberName]string propertyName = "")
{
    if (!_isSuppressed)
    {
        lock (_eventCache) 
        
        _eventCache.Add(propertyName); 
        }
    }
}

public void OnTimerTick() 
{
    if (!_isSuppressed)
    {
        lock (_eventCache) 
        
    foreach(string propertyName in _eventCache)
                RaisePropertyChanged(propertyName);
            _eventCache.Clear(); 
        }
    }
}

In the MainViewModel, I created a DispatchTimer, and refresh the Suppressibles every second, the performance of final DataGrid is way above my expectation, the mock data engine can pump in the data for 3226 stocks over 1000 times per second on Dell tablet running m5-6Y54@1.51 GHz processor with 8GB memory!


PERFORMANCE COMPARISON

m5-6Y54@1.51 GHz
High
Average
 Improvement
Refresh Reduction
1018
740 1805%
On Screen Detection
685
470 1146%
UI Virtualization
657
450 1098%
Out of Box
48*
41 100%
* The data refresh of the entire collection per second

Source code available on GitHub:
https://github.com/SX-GitHub/HighThroughputDataGrid


No comments:

Post a Comment