JokerMartini
JokerMartini

Reputation: 6157

Improve draw speeds for WPF Listbox

I have created a Listbox in WPF, where I plot 2D points randomly when the user clicks Generate. In my case I'm going to be plotting several thousand points when the user clicks Generate. I noticed when I generate around 10,000 or even 5,000 points, it takes forever. Does anyone have advice on ways to speed this up?

Is it possible to only trigger the update to take place once all points have been generated, assuming that due to the ObservableCollection it's attempting to update the listbox visuals every time a new point is added to the collection.

enter image description here

MainWindow.xaml.cs

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
using System.Windows.Threading;

namespace plotting
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        public MainWindow()
        {
            InitializeComponent();
            this.DataContext = this;

            CityList = new ObservableCollection<City>
            {
                new City("Duluth", 92.18, 46.83, 70),
                new City("Redmond", 121.15, 44.27, 50),
                new City("Tucson", 110.93, 32.12, 94),
                new City("Denver", 104.87, 39.75, 37),
                new City("Boston", 71.03, 42.37, 123),
                new City("Tampa", 82.53, 27.97, 150)
            };
        }

        private ObservableCollection<City> cityList;
        public ObservableCollection<City> CityList
        {
            get { return cityList; }
            set
            {
                cityList = value;
                RaisePropertyChanged("CityList");
            }
        }

        // INotifyPropertyChanged
        public event PropertyChangedEventHandler PropertyChanged = delegate { };

        private void RaisePropertyChanged(string propName)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(propName));
        }

        public async Task populate_data()
        {
            CityList.Clear();
            const int count = 5000;
            const int batch = 100;
            int iterations = count / batch, remainder = count % batch;
            Random rnd = new Random();

            for (int i = 0; i < iterations; i++)
            {
                int thisBatch = _GetBatchSize(batch, ref remainder);

                for (int j = 0; j < batch; j++)
                {
                    int x = rnd.Next(65, 125);
                    int y = rnd.Next(25, 50);
                    int popoulation = rnd.Next(50, 200);
                    string name = x.ToString() + "," + y.ToString();
                    CityList.Add(new City(name, x, y, popoulation));
                }

                await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle);
            }
        }

        public void populate_all_data()
        {
            CityList.Clear();
            Random rnd = new Random();

            for (int i = 0; i < 5000; i++)
            {
                int x = rnd.Next(65, 125);
                int y = rnd.Next(25, 50);
                int count = rnd.Next(50, 200);
                string name = x.ToString() + "," + y.ToString();
                CityList.Add(new City(name, x, y, count));
            }
        }

        private static int _GetBatchSize(int batch, ref int remainder)
        {
            int thisBatch;

            if (remainder > 0)
            {
                thisBatch = batch + 1;
                remainder--;
            }
            else
            {
                thisBatch = batch;
            }

            return thisBatch;
        }

        private async void Button_Click(object sender, RoutedEventArgs e)
        {
            Stopwatch sw = Stopwatch.StartNew();

            await populate_data();
            Console.WriteLine(sw.Elapsed);
        }

        private void Button_Click_All(object sender, RoutedEventArgs e)
        {
            Stopwatch sw = Stopwatch.StartNew();
            populate_all_data();
            Console.WriteLine(sw.Elapsed);
        }
    }

    public class City
    {
        public string Name { get; set; }

        // east to west point
        public double Longitude { get; set; }

        // north to south point
        public double Latitude { get; set; }

        // Size
        public int Population { get; set; }

        public City(string Name, double Longitude, double Latitude, int Population)
        {
            this.Name = Name;
            this.Longitude = Longitude;
            this.Latitude = Latitude;
            this.Population = Population;
        }
    }

    public static class Constants
    {
        public const double LongMin = 65.0;
        public const double LongMax = 125.0;

        public const double LatMin = 25.0;
        public const double LatMax = 50.0;
    }

    public static class ExtensionMethods
    {
        public static double Remap(this double value, double from1, double to1, double from2, double to2)
        {
            return (value - from1) / (to1 - from1) * (to2 - from2) + from2;
        }
    }

    public class LatValueConverter : IValueConverter
    {
        // Y Position
        public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            double latitude = (double)value;
            double height = (double)parameter;

            int val = (int)(latitude.Remap(Constants.LatMin, Constants.LatMax, height, 0));
            return val;
        }

        public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    public class LongValueConverter : IValueConverter
    {
        // X position
        public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            double longitude = (double)value;
            double width = (double)parameter;

            int val = (int)(longitude.Remap(Constants.LongMin, Constants.LongMax, width, 0));
            return val;
        }

        public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}

MainWindow.xaml

<Window x:Class="plotting.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        xmlns:local="clr-namespace:plotting"
        Title="MainWindow" 
        Height="500" 
        Width="800">

    <Window.Resources>
        <ResourceDictionary>
            <local:LatValueConverter x:Key="latValueConverter" />
            <local:LongValueConverter x:Key="longValueConverter" />
            <sys:Double x:Key="mapWidth">750</sys:Double>
            <sys:Double x:Key="mapHeight">500</sys:Double>
        </ResourceDictionary>
    </Window.Resources>

        <StackPanel Orientation="Vertical" Margin="5" >
        <Button Content="Generate Batches" Click="Button_Click"></Button>
        <Button Content="Generate All" Click="Button_Click_All"></Button>

        <ItemsControl ItemsSource="{Binding CityList}">
            <!-- ItemsControlPanel -->
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <Canvas />
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>

            <!-- ItemContainerStyle -->
            <ItemsControl.ItemContainerStyle>
                <Style TargetType="ContentPresenter">
                    <Setter Property="Canvas.Left" Value="{Binding Longitude, Converter={StaticResource longValueConverter}, ConverterParameter={StaticResource mapWidth}}"/>
                    <Setter Property="Canvas.Top" Value="{Binding Latitude, Converter={StaticResource latValueConverter}, ConverterParameter={StaticResource mapHeight}}"/>
                </Style>
            </ItemsControl.ItemContainerStyle>

            <!-- ItemTemplate -->
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <!--<Button Content="{Binding Name}" />-->
                    <Ellipse Fill="#FFFFFF00" Height="15" Width="15" StrokeThickness="2" Stroke="#FF0000FF"/>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>

    </StackPanel>

</Window>

Update 1: Assign the ObservableCollection once all the points have been made.

public void populate_data()
{
    CityList.Clear();
    Random rnd = new Random();

    List<City> tmpList = new List<City>();
    for (int i = 0; i < 5000; i++)
    {
        int x = rnd.Next(65, 125);
        int y = rnd.Next(25, 50);
        int count = rnd.Next(50, 200);
        string name = x.ToString() + "," + y.ToString();
        tmpList.Add(new City(name, x, y, count));
    }
    CityList = new ObservableCollection<City>(tmpList);
}

This change does not affect the UI experience much, if at all. Is there a way to allow the UI to update as the objects are added?

End goal is plotting just points representing each coordinate in 2D space.

enter image description here

Upvotes: 3

Views: 394

Answers (1)

Peter Duniho
Peter Duniho

Reputation: 70701

Is it possible to only trigger the update to take place once all points have been generated, assuming that due to the ObservableCollection it's attempting to update the listbox visuals every time a new point is added to the collection.

Actually, that's not a correct assumption. In fact, ListBox already will defer updates until you're done adding items. You can observe this by modifying your Click handler (having added the appropriate ElapsedToIdle property to your window class and bound it to a TextBlock for display, of course):

private void Button_Click(object sender, RoutedEventArgs e)
{
    Stopwatch sw = Stopwatch.StartNew();

    populate_data();
    ElapsedToIdle = sw.Elapsed;
}

The problem is that even though it's deferring updates, when it finally gets around to processing all the new data, it still does that in the UI thread. With the above, I see the elapsed time at around 800 ms on my computer. So, the populate_data() method is only taking that long. If, however, I change the method so it measures the time until the UI thread returns to an idle state:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    Stopwatch sw = Stopwatch.StartNew();

    var task = Dispatcher.InvokeAsync(() => sw.Stop(), DispatcherPriority.ApplicationIdle);
    populate_data();
    await task;
    ElapsedToIdle = sw.Elapsed;
}

…the actual time is in the 10-12 second range (it varies).

From the user point of view, it may be less important that the operation takes so much time, than that the entire program appears to lock up while the initialization is taking place. This can be addressed by changing the code so that the UI gets chances to update while the initialization occurs.

We can modify the initialization code like this to accomplish that:

public async Task populate_data()
{
    CityList.Clear();
    const int count = 5000;
    const int batch = 50;
    int iterations = count / batch, remainder = count % batch;
    Random rnd = new Random();

    for (int i = 0; i < iterations; i++)
    {
        int thisBatch = _GetBatchSize(batch, ref remainder);

        for (int j = 0; j < batch; j++)
        {
            int x = rnd.Next(65, 125);
            int y = rnd.Next(25, 50);
            int popoulation = rnd.Next(50, 200);
            string name = x.ToString() + "," + y.ToString();
            CityList.Add(new City(name, x, y, popoulation));
        }

        await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle);
    }
}

private static int _GetBatchSize(int batch, ref int remainder)
{
    int thisBatch;

    if (remainder > 0)
    {
        thisBatch = batch + 1;
        remainder--;
    }
    else
    {
        thisBatch = batch;
    }

    return thisBatch;
}

private async void Button_Click(object sender, RoutedEventArgs e)
{
    Stopwatch sw = Stopwatch.StartNew();

    await populate_data();
    ElapsedToIdle = sw.Elapsed;
    ButtonEnabled = true;
}

This adds 4-5 seconds to the initialization time. For obvious reasons, it's slower. But, what the user sees is a gradually populated UI, giving them better feedback as to what's going on, and making the wait less onerous.

For what it's worth, I also experimented with running the initialization in a background task while the UI was allowed to update. This produces something in between the above two options. That is, it's still slower than initializing without updates, but it's a bit faster than the initialize-and-update-in-UI-thread option, because there's just a bit of concurrency involved (I implemented it so that it would start a task to compute the next batch of objects, and then while that task was running, add the previous batch of objects and wait for that update to complete). But, I probably wouldn't use that approach in a real program, because while it's a bit better than just doing everything in the UI thread, it's not that much better, and it significantly increases the complexity of the code.

Note that adjusting the batch size has important effects on the trade-off between responsiveness and speed. Larger batch sizes will run faster overall, but the UI is more likely to stall and/or be completely unresponsive.

Now, all that said, one important question is, do you really need to use ListBox here? I ran the code using a plain ItemsControl instead, and it was 2x to 3x faster, depending on the exact scenario. I assume you are using the ListBox control to give selection feedback, and that's fine. But if speed is really important, you might find it makes more sense to use ItemsControl and handle item selection yourself.

Upvotes: 2

Related Questions