Sunday, February 1, 2009

Responsive WPF User Interfaces Part 3

User feedback and the limitations of the Dispatcher

In the previous post in this series we found out how fairly innocent code can cause mayhem on our User Interface (UI), locking it up and leaving the user wondering what is going on. We discovered how the Dispatcher can help us out here by queuing small requests for it to do when it can. However, I claimed at the end of the session that the model wont scale well. Well here I will try to actually prove my claim and guide you through some fairly standard functionality that you may have to build in WPF.
Consider a simple Photo viewing application. The application is to load all images in a directory and it's subdirectories. The images are then to be displayed in some fashion. Pretty simple? Here is my first attempt at the presentation in XAML:

<Window x:Class="ArtemisWest.Demo.ResponsiveUI._3_SingleThreaded.SingleThreaded"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="Single Threaded">
  <DockPanel>
    <DockPanel DockPanel.Dock="Top">
      <Button x:Name="SetSourcePath" Click="SetSourcePath_Click" DockPanel.Dock="Right">Set Source</Button>
      <Border BorderThickness="1" BorderBrush="LightBlue" Margin="3">
        <Grid>
          <TextBlock Text="{Binding SourcePath}">
            <TextBlock.Style>
              <Style TargetType="TextBlock">
                <Setter Property="Visibility" Value="Visible"/>
                <Style.Triggers>
                  <DataTrigger Binding="{Binding SourcePath}" Value="">
                    <Setter Property="Visibility" Value="Collapsed"/>
                    </DataTrigger>
                  <DataTrigger Binding="{Binding SourcePath}" Value="{x:Null}">
                    <Setter Property="Visibility" Value="Collapsed"/>
                  </DataTrigger>
                </Style.Triggers>
              </Style>
            </TextBlock.Style>
          </TextBlock>
          <TextBlock Text="Source not set" Foreground="LightGray" FontStyle="Italic">
            <TextBlock.Style>
              <Style TargetType="TextBlock">
                <Setter Property="Visibility" Value="Collapsed"/>
                <Style.Triggers>
                  <DataTrigger Binding="{Binding SourcePath}" Value="">
                    <Setter Property="Visibility" Value="Visible"/>
                    </DataTrigger>
                  <DataTrigger Binding="{Binding SourcePath}" Value="{x:Null}">
                    <Setter Property="Visibility" Value="Visible"/>
                  </DataTrigger>
                </Style.Triggers>
              </Style>
            </TextBlock.Style>
          </TextBlock>
        </Grid>
      </Border>
    </DockPanel>
    <ListView ItemsSource="{Binding Images}" ScrollViewer.VerticalScrollBarVisibility="Visible" />
  </DockPanel>
</Window>

Fairly simple. I have

  • TextBlock that shows the Root path of where our images are being fetched from
  • Button to open the folder browser which sets the TextBlock value
  • ItemsControl to show the images that have been fetched (well the filenames of them at the moment)
  • Most of the code here is actually an attempt to display the text "Source not set" when the SourcePath is null or an empty string.

And here is the C# code to do the work:

public partial class SingleThreaded : Window, INotifyPropertyChanged
{
private string sourcePath;
private readonly ObservableCollection<string> images = new ObservableCollection<string>();

public SingleThreaded()
{
  InitializeComponent();
  this.PropertyChanged += SingleThreaded_PropertyChanged;
  this.DataContext = this;
}

public string SourcePath
{
  get { return sourcePath; }
  set
  {
    sourcePath = value;
    OnPropertyChanged("SourcePath");
  }
}

public ObservableCollection<string> Images
{
  get { return images; }
}

void SetSourcePath_Click(object sender, RoutedEventArgs e)
{
  System.Windows.Forms.FolderBrowserDialog openFolderDlg = new System.Windows.Forms.FolderBrowserDialog();
  openFolderDlg.RootFolder = Environment.SpecialFolder.Desktop;
  if (openFolderDlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
  {
    if (!string.IsNullOrEmpty(openFolderDlg.SelectedPath))
    {
      SourcePath = openFolderDlg.SelectedPath;
    }
  }
}

void SingleThreaded_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
  if (e.PropertyName == "SourcePath")
  {
    //LoadImages(SourcePath); //Learning from previous experiance we use BeginInvoke instead.
    Dispatcher.BeginInvoke(new LoadImagesDelegate(LoadImages), DispatcherPriority.Background, SourcePath);
  }
}

delegate void LoadImagesDelegate(string folderLocation);
void LoadImages(string folderLocation)
{
  try
  {
    string[] files = System.IO.Directory.GetFiles(folderLocation);
    foreach (string file in files)
    {
      if (IsImage(file))
        Images.Add(file);
    }
  }
  catch (UnauthorizedAccessException) { } //Swallow

  try
  {
    string[] folders = System.IO.Directory.GetDirectories(folderLocation);
    foreach (string folder in folders)
    {
      //LoadImages(folder);//Learning from previous experiance we use BeginInvoke instead.
      Dispatcher.BeginInvoke(new LoadImagesDelegate(LoadImages), DispatcherPriority.Background, folder);
    }
  }
  catch (UnauthorizedAccessException) { } //Swallow
}

bool IsImage(string file)
{
  string extension = file.ToLower().Substring(file.Length - 4);
  switch (extension)
  {
    case ".bmp":
    case ".gif":
    case ".jpg":
    case ".png":
      return true;
    default:
      return false;
  }
}

#region INotifyPropertyChanged Members
/// <summary>
/// Implicit implementation of the INotifyPropertyChanged.PropertyChanged event.
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// Throws the <c>PropertyChanged</c> event.
/// </summary>
/// <param name="propertyName">The name of the property that was modified.</param>
protected void OnPropertyChanged(string propertyName)
{
  PropertyChangedEventHandler handler = PropertyChanged;
  if (handler != null)
    handler(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}

This is a little bit more complicated than previous examples. Let's start with the properties:

  • SourcePath is a string property with Notification enabled. This is what the TextBlock is bound to
  • Images is an ObservableCollection of strings. As we modify this collection the UI should update automatically.
Next, the event handlers:
  • SetSourcePath_Click handles the button click and shows an Open Folder dialog box. This allows the user to pick a folder to start looking for images in.
  • SingleThreaded_PropertyChanged handles all PropertyChanged events on itself. A bit silly but it will do for kicking off the loading of images when the SourcePath property changes
Finally the helper methods
  • LoadImages (with it's LoadImagesDelegate) does the grunt work for the class. It looks in a given directory for Images, adds them to the Images collection and then recursively calls itself with the Dispatcher.BeginInvoke
  • IsImage method is a simple method for identifying if a file is an image.

If you run this example the result is actually quite neat. Files start appearing as they are found and the UI feels responsive.

However, if I pick a folder with lots of stuff in it eg: the root of C:\, the process can take quite a while. What would be nice is if I could have some kind of visual clue that tells me when it is complete, or how much is left to go, or anything! Well lets look at our code. I think the simple option would be to have a boolean property call IsLoading that is simply set to true when we are loading images. When we are done set it back to false. So we drop this new piece of code into our C# which gives us the property we are looking for.

private bool isLoading = false;
public bool IsLoading
{
  get { return isLoading; }
  set
  {
    isLoading = value;
    OnPropertyChanged("IsLoading");
  }
}

I now want to modify my UI to show that something is loading. The easiest thing for me to do is throw a ProgressBar control in. So I put this code in just above my button at the top of the second DockPanel:

<Window ...>
  <Window.Resources>
    <BooleanToVisibilityConverter x:Key="boolToVisConverter"/>
  </Window.Resources>
  <DockPanel>
    <DockPanel DockPanel.Dock="Top">
      <ProgressBar IsIndeterminate="True" Height="18" DockPanel.Dock="Top" Visibility="{Binding Path=IsLoading, Converter={StaticResource boolToVisConverter}}" />
      <Button x:Name="SetSourcePath" ...../>

Ok. Now back to our C# code. When the SourcePath changes I want to load the images. This seems like a good place to set our IsLoading property.

void Stackoverflow_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
  if (e.PropertyName == "SourcePath")
  {
    IsLoading = true;
    Dispatcher.BeginInvoke(new LoadImagesDelegate(LoadImages), DispatcherPriority.Background, SourcePath);
    IsLoading = false;
  }
}

Hang on...when we call LoadImages we do it with a BeginInvoke method which is non-blocking. This effectively sets IsLoading to true, then back to false almost immediately. That's not right. Well lets change the BeginInvoke to Invoke. Nope that is not quite right either. Changing the initial BeginInvoke to a Invoke method call will just set the IsLoading flag for the duration where we processed the root of the source path but not for any of its sub-directories. Ok next idea.... Let's change the BeginInvoke inside the LoadImages method to be an Invoke call as well. Right that will work. We run that and it actually works quite well. I get feedback that the system is processing data via the ProgressBar and when the processing is complete, the ProgressBar disappers.

void Stackoverflow_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
  if (e.PropertyName == "SourcePath")
  {
    IsLoading = true;
    Dispatcher.Invoke(new LoadImagesDelegate(LoadImages), DispatcherPriority.Background, SourcePath);
    IsLoading = false;
  }
}

delegate void LoadImagesDelegate(string folderLocation);
void LoadImages(string folderLocation)
{
  try
  {
    string[] files = System.IO.Directory.GetFiles(folderLocation);
    foreach (string file in files)
    {
      if (IsImage(file))
        Images.Add(file);
    }
  }
  catch (UnauthorizedAccessException) { } //Swallow

  try
  {
    string[] folders = System.IO.Directory.GetDirectories(folderLocation);
    foreach (string folder in folders)
    {
      Dispatcher.Invoke(new LoadImagesDelegate(LoadImages), DispatcherPriority.Background, folder);
    }
  }
  catch (UnauthorizedAccessException) { } //Swallow
}

But wait, this exactly follows the StackOverflow model we had in Part 2 of the series. On my system I don't actually have a deep enough directory structure that would cause an overflow (I don't think vista actually supports structures that deep). This concerns me that I am using a system that "could" just cause a StackOverflowException on me. Is my concern well founded? Maybe it is, maybe not. Conceding that a minor concern is not usually good grounds for declaring a proven practice let's consider the performance of our LoadImages method. What would happen if

  • the disk was under load?
  • the call was to a database or remote service (WCF, Web Service, etc...)
  • any other scenario where a method may take more than say 250ms

Well we can emulate the above scenarios by throwing in a Thread.Sleep in to our code. In my code I place a Thread.Sleep(300) into my LoadImage method at the start of the method. This represents latency of the disk/database/network. I think you may agree that 300ms is plausible value for latency on a slow network.

delegate void LoadImagesDelegate(string folderLocation);
void LoadImages(string folderLocation)
{
  Thread.Sleep(300);
  try
  {

If I run my updated code now the result is not good. The progress bar is jumpy, hovering over the button gives a delayed MouseOver effect and clicking on the items in the list is unresponsive.
What have we proved here?

  • The dispatcher can be used for very fast methods to interact with UI elements
  • Using Dispatcher.Invoke method is a option when we actually want the properties of a blocking call
  • The responsiveness of our application can be effected by external interfaces when we call them via the Dispatcher.

So where are we now? Hopefully you have a better understanding of the dispatcher. We like the dispatcher but we are aware of its features and its limitations. The following should be well understood now:

  1. We can achieve simple rendering effects with StoryBoards. These can be asynchronous and WPF does all the hard work to ensure a responsive UI.
  2. The UI is single threaded and performing work on the UI thread can kill UI responsiveness.
  3. The UI thread will have a Dispatcher to enable queuing of work for it to do.
  4. A Dispatcher can only belong to one thread.
  5. UI Controls are DispatcherObjects and can only be updated on the thread they were created on. Conveniently they also expose a property to the Dispatcher that owns them.
  6. You can queue tasks for the UI thread by using the Invoke and BeginInvoke methods on the thread's Dispatcher.
  7. When queuing tasks for the dispatcher you can set a priority for the task.
  8. Dispatcher.Invoke method queues a task and waits for its completion. Dispatcher.BeginInvoke queues a task and does not wait for its completion.
  9. Any task queued for the UI thread will be completed in its entirety before performing any other task. Therefore all tasks performed by the Dispatcher will cause some loss in responsiveness on the UI.

So basically we have arrived at a point where understand the WPF threading model enough to recognise that tasks with long or unknown delays cannot be perform on the UI thread, either via Control Constructors, event handlers, Dispatcher.Invoke or Dispatcher.BeginInvoke. This leads us to our next part in the series Multi-Threaded programming in WPF

Next we discover how to perform true multi-threaded programming in WPF to really get the UI responsive in Part 4.

Previous - The WPF Threading model and the dispatcher in Responsive WPF User Interfaces in Part 2.

Back to series Table Of Contents

Working version of the code can be found here

No comments: