3

I was previously using the code behind to manually add items to my ListBox, but it was terribly slow. I heard that data binding via XAML was the way to go, in terms of performance.

So I managed to get the data binding working (new to binding), but to my dismay, the performance is no better than my previous non-data binding method.

The idea is that my ListBox contains an Image with a name below it. I did some benchmarking and 54 items take a full 8 seconds to display. Which naturally is too long for a user to wait.

The source images are at a maxiumum: 2100x1535px and range from 400kb>4mb per file.

The images required to reproduce this issue can be found here: Link removed as question has been answered and my server doesn't have very much bandwidth allowance. Other image source here: https://i.stack.imgur.com/iS0h0.jpg

I've made a reproducible example of the issue below. What am I doing wrong that is making this so slow?

Thank you.

The XAML:

<Window x:Class="WpfApplication1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApplication1"
        mc:Ignorable="d"
        Title="MainWindow" Height="600" Width="800" WindowState="Maximized">
    <Grid>
        <ListBox x:Name="listBoxItems" ItemsSource="{Binding ItemsCollection}"
                    ScrollViewer.HorizontalScrollBarVisibility="Disabled">

            <ListBox.ItemsPanel>
                <ItemsPanelTemplate>
                    <WrapPanel IsItemsHost="True" />
                </ItemsPanelTemplate>
            </ListBox.ItemsPanel>

            <ListBox.ItemTemplate>
                <DataTemplate>
                    <VirtualizingStackPanel>
                        <Image Width="278" Height="178">
                            <Image.Source>
                                <BitmapImage DecodePixelWidth="278" UriSource="{Binding ImagePath}" CreateOptions="IgnoreColorProfile" />
                            </Image.Source>
                        </Image>
                        <TextBlock Text="{Binding Name}" FontSize="16" VerticalAlignment="Bottom" HorizontalAlignment="Center" />
                    </VirtualizingStackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </Grid>
</Window>

The code behind:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Threading;

namespace WpfApplication1
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        internal class Item : INotifyPropertyChanged
        {
            public Item(string name = null)
            {
                this.Name = name;
            }

            public string Name { get; set; }
            public string ImagePath { get; set; }

            public event PropertyChangedEventHandler PropertyChanged;
            private void NotifyPropertyChanged(String propertyName)
            {
                if (PropertyChanged != null)
                {
                    PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
                }
            }
        }

        ObservableCollection<Item> ItemsCollection;
        List<Item> data;

        public MainWindow()
        {
            InitializeComponent();

            this.data = new List<Item>();
            this.ItemsCollection = new ObservableCollection<Item>();
            this.listBoxItems.ItemsSource = this.ItemsCollection;

            for (int i = 0; i < 49; i ++)
            {
                Item newItem = new Item
                {
                    ImagePath = String.Format(@"Images/{0}.jpg", i + 1),
                    Name = "Item: " + i
                };

                this.data.Add(newItem);
            }

            foreach (var item in this.data.Select((value, i) => new { i, value }))
            {
                Dispatcher.Invoke(new Action(() =>
                {
                    this.ItemsCollection.Add(item.value);
                }), DispatcherPriority.Background);
            }
        }
    }
}
PersuitOfPerfection
  • 1,009
  • 1
  • 15
  • 28
  • Just tested it with 50 images in range 300-900kb and its showing almost instantly... however, i had to copy some images and rename them, not enough testing material available. – grek40 Mar 07 '17 at 07:14
  • Small images do, yes. It's the large and detailed images that bring it to a crawl – PersuitOfPerfection Mar 07 '17 at 11:58
  • @PeterDuniho Aha, looks like imgur is compressing them then or something. Here is the link to download them all: http://s.imgur.com/a/jmbv6/zip - I'll also add that to the OP – PersuitOfPerfection Mar 07 '17 at 22:50
  • @PeterDuniho Yep, just verified that myself here too. Well that's a kick in the nuts. I comared two images (my source image and one from the zip) and there is a 3mb file size difference). Looks like the images were compressed to some degree when being uploaded to Imgur ;/ – PersuitOfPerfection Mar 07 '17 at 22:57
  • @PeterDuniho I'm going to have to upload the images to my site and offer a direct download to them, to preverve the images integrity. I'll do that now – PersuitOfPerfection Mar 07 '17 at 22:58

3 Answers3

2

Now that I'm able to see the images you are using, I can confirm that the main issue here is simply the fundamental cost of loading large images. There is simply no way to improve on that time, using those image files.

What you can do is to either load the images asynchronously, so that at least the rest of the program is responsive while the user waits for all the images to load, or to reduce the size of the images, so that they load faster. If possible, I strongly recommend the latter.

If for some reason it is a requirement that the images being deployed and loaded in their original, large-size format, then you should at least load them asynchronously. There are lots of different ways to accomplish this.

The simplest is to set Binding.IsAsync on the Image.Source binding:

<ListBox.ItemTemplate>
  <DataTemplate>
    <StackPanel>
      <Image Width="278" Height="178" Source="{Binding ImagePath, IsAsync=True}"/>
      <TextBlock Text="{Binding Name}" FontSize="16"
                 VerticalAlignment="Bottom" HorizontalAlignment="Center" />
    </StackPanel>
  </DataTemplate>
</ListBox.ItemTemplate>

The main downside to this approach is that you can't set DecoderPixelWidth when using this method. The Image control is handling the conversion from the path to the actual bitmap for you, and there's no mechanism to set various options.

Given the simplicity of the technique, I think this is the preferred method, at least for me. Users often won't care about the total time to fully initialize all the data, as long as the program is responsive and showing signs of progress. However, I do note that without setting DecoderPixelWidth in this scenario, it took nearly twice as long to load all the images (around 7.5 seconds vs. almost 14 seconds). So you may be interested in loading the images asynchronously yourself.

Doing so requires normal asynchronous programming techniques, which you may already be familiar with. The main "gotcha" is that the WPF bitmap-handling classes by default will defer the actual loading of a bitmap until it's actually needed. Creating the bitmap asynchronously doesn't help, unless you can force the data to be loaded immediately.

Fortunately, you can. It's simply a matter of setting the CacheOption property to BitmapCacheOption.OnLoad.

I have taken the liberty of cleaning up your original example, creating proper view model data structures, and implementing asynchronous loading of the images. In this way, I get the sub-8-second load time, but the UI remains responsive while the loading is going on. I included a couple of timers: one displays elapsed time since the program started, and is mainly there to illustrate the responsiveness of the UI, and the other shows the time spent actually loading the bitmap images.

XAML:

<Window x:Class="TestSO42639506PopulateListBoxImages.MainWindow"
        x:ClassModifier="internal"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:l="clr-namespace:TestSO42639506PopulateListBoxImages"
        mc:Ignorable="d"
        WindowState="Maximized"
        Title="MainWindow" Height="350" Width="525">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition/>
    </Grid.RowDefinitions>
    <StackPanel>
      <TextBlock Text="{Binding TotalSeconds, StringFormat=Total seconds: {0:0}}"/>
      <TextBlock Text="{Binding LoadSeconds, StringFormat=Load seconds: {0:0.000}}"/>
    </StackPanel>

    <ListBox x:Name="listBoxItems" ItemsSource="{Binding Data}"
             Grid.Row="1"
             ScrollViewer.HorizontalScrollBarVisibility="Disabled">

      <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
          <WrapPanel IsItemsHost="True" />
        </ItemsPanelTemplate>
      </ListBox.ItemsPanel>

      <ListBox.ItemTemplate>
        <DataTemplate>
          <StackPanel>
            <Image Width="278" Height="178" Source="{Binding Bitmap}"/>
            <TextBlock Text="{Binding Name}" FontSize="16"
                       VerticalAlignment="Bottom" HorizontalAlignment="Center" />
          </StackPanel>
        </DataTemplate>
      </ListBox.ItemTemplate>
    </ListBox>
  </Grid>
</Window>

C#:

class NotifyPropertyChangedBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected void _UpdatePropertyField<T>(
        ref T field, T value, [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
        {
            return;
        }

        field = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

class Item : NotifyPropertyChangedBase
{
    private string _name;
    private string _imagePath;
    private BitmapSource _bitmap;

    public string Name
    {
        get { return _name; }
        set { _UpdatePropertyField(ref _name, value); }
    }

    public string ImagePath
    {
        get { return _imagePath; }
        set { _UpdatePropertyField(ref _imagePath, value); }
    }

    public BitmapSource Bitmap
    {
        get { return _bitmap; }
        set { _UpdatePropertyField(ref _bitmap, value); }
    }
}

class MainWindowModel : NotifyPropertyChangedBase
{
    public MainWindowModel()
    {
        _RunTimer();
    }

    private async void _RunTimer()
    {
        Stopwatch sw = Stopwatch.StartNew();
        while (true)
        {
            await Task.Delay(1000);
            TotalSeconds = sw.Elapsed.TotalSeconds;
        }
    }

    private ObservableCollection<Item> _data = new ObservableCollection<Item>();
    public ObservableCollection<Item> Data
    {
        get { return _data; }
    }

    private double _totalSeconds;
    public double TotalSeconds
    {
        get { return _totalSeconds; }
        set { _UpdatePropertyField(ref _totalSeconds, value); }
    }

    private double _loadSeconds;
    public double LoadSeconds
    {
        get { return _loadSeconds; }
        set { _UpdatePropertyField(ref _loadSeconds, value); }
    }
}

/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
partial class MainWindow : Window
{
    private readonly MainWindowModel _model = new MainWindowModel();

    public MainWindow()
    {
        DataContext = _model;
        InitializeComponent();

        _LoadItems();
    }

    private async void _LoadItems()
    {
        foreach (Item item in _GetItems())
        {
            _model.Data.Add(item);
        }

        foreach (Item item in _model.Data)
        {
            BitmapSource itemBitmap = await Task.Run(() =>
            {
                Stopwatch sw = Stopwatch.StartNew();
                BitmapImage bitmap = new BitmapImage();

                bitmap.BeginInit();
                // forces immediate load on EndInit() call
                bitmap.CacheOption = BitmapCacheOption.OnLoad;
                bitmap.UriSource = new Uri(item.ImagePath, UriKind.Relative);
                bitmap.DecodePixelWidth = 278;
                bitmap.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
                bitmap.EndInit();
                bitmap.Freeze();

                sw.Stop();
                _model.LoadSeconds += sw.Elapsed.TotalSeconds;
                return bitmap;
            });
            item.Bitmap = itemBitmap;
        }
    }

    private static IEnumerable<Item> _GetItems()
    {
        for (int i = 1; i <= 60; i++)
        {
            Item newItem = new Item
            {
                ImagePath = String.Format(@"Images/{0}.jpg", i),
                Name = "Item: " + i
            };

            yield return newItem;
        }
    }
}

Since I just copied the files straight from your .zip into my project directory, I changed the image-path loop to correspond to the actual file names there, i.e. 1-60, instead of 1-49 as your original example had. I also didn't bother with the 0-based label, and instead just made it the same as the file name.

I did do a little looking around to see if there was another question that directly addresses yours here. I didn't find one that I thought was an exact duplicate, but there's a very broad one, asynchronously loading a BitmapImage in C# using WPF, that shows a number of techniques, including ones similar or identical to the above.

Community
  • 1
  • 1
Peter Duniho
  • 68,759
  • 7
  • 102
  • 136
  • 1
    Wonderful answer. Thanks so much for the example and explanation. I also apprecite the time you took out of your day to test my example and provide the answer here. Thank you for persevering with me, and helping me to form structurally better questions in the future. – PersuitOfPerfection Mar 08 '17 at 02:10
1
  • Moving the line this.listBoxItems.ItemsSource = this.ItemsCollection; to the end of the method should help a little bit.
  • Whats happening here is, each time this.data.Add(newItem) is executed, the list is trying to update its contents which involves a lot of I/O (reading the disk file and decoding the rather large image). Running a profiler should confirm this.
  • Better way would be to load from a smaller thumbnail cache (which would require less I/O) if that is feasible for your requirement
  • Enabling VirtualizingStackPanel.IsVirtualizing will help keep memory requirements low

Here is one discussion on this topic I think you might find interesting.

Community
  • 1
  • 1
Shreyas Murali
  • 426
  • 2
  • 8
  • 14
  • I thought VirtualizingStackPanel.IsVirtualizing="True" was implicitly set regardless? Moving the itemsource line made no noticeable difference. I'll look into the thumbnail cache. The part that is baffling me the most is that you see this kind of code in use often, but you hear people using more complex solutions when they get into thousands of items. We're only talking about 50 items here though, so surely there is something sinister in my example code, no? – PersuitOfPerfection Mar 07 '17 at 03:34
  • Of course, I guess I could also resize the images and save them as thumbnails. I've tried with resizing the images with a batch image converter then using those small images and it was near instant. Would doing the resizing and saving to disk programmatically take a long time? Again, using the 50 images as the example data pool. Thanks – PersuitOfPerfection Mar 07 '17 at 03:42
  • @PersuitOfPerfection: I think it's likely the delay is not caused by the number of images nor the population of the `ListBox`, but rather simply reading and decoding the image data. You can't make that faster without making the files and images themselves smaller. You might have some success loading the files asynchronously, so that at least the UI remains responsive. Here's a post you might find useful: http://stackoverflow.com/questions/9317460/bitmap-performance-optimization-patterns. No sure-fire answers there, but food for thought. – Peter Duniho Mar 07 '17 at 04:09
1
  • You don't need an ObservableCollection and a List, when both keep the same objects. Remove data field.

  • You are not using VirtualizingStackPanel correctly. ListBox visualizes its items by default. I cannot understand why you are using a WrapPanel as the ItemsPanel, since you set HorizontalScrollBar to be disabled. Start with the minimum changes. I mean, remove VirtualizingStackPanel and ItemsPanel first, see how performance changes. You can later change ItemsPanel and etc.

  • I cannot understand why you are using Dispatcher.Invoke to populate the ObservableCollection. You have created it in the current Thread. No need for that. Virtualization will take care of loading images.

Let me know if something is wrong.

rmojab63
  • 3,513
  • 1
  • 15
  • 28
  • Even with horizontal scroll bar disabled, the items don't appear side by side unless used in a wrap panel (from my testing). Without it, they appear in a vertical list. With one item per row. – PersuitOfPerfection Mar 07 '17 at 04:27
  • Currently, you are using ``VirtualizingStackPanel`` in a wrong place. What I am trying to say is to start with the usual ListBox, see if you have performance problems. (I think not). Then you can think about the correct way of using WrapPanel, or horizontal StackPanel and etc., by googling or asking new questions. – rmojab63 Mar 07 '17 at 04:31