Introduction
We may meet many asynchronous-data-binding and data-virtualization scenarios in WPF. This article introduces a method to improve the WPF async data binding logic.
Background
Make sure you are familiar with WPF, data binding and data virtualization before the following discussion:
- The demo VS solution contains two projects, one for WPF4.0, the other for WPF4.5
- Before you run the demo project, make sure the directory D:\test exist and contains 100+ jpg files
Using the Code
This article will develop an ImageViewer
by using three different DataTemplate
(s) as:
- Create a simple WPF solution
- Add a class named FileInformation.cs as data-model
public class FileInformation
{
public FileInformation(FileInfo fileInfo)
{
FileInfo = fileInfo;
}
public FileInfo FileInfo { get; private set; }
}
- Add a
ListBox
control to the MainWindow.xaml, and enable UI-Virtualization by setting two Attached DependencyProperty ScrollViewer.CanContentScroll
& VirtualizingStackPanel.IsVirtualizing
: <Window.Resources>
<DataTemplate x:Key="DataTemplateKey1" DataType="{x:Type local:FileInformation}">
<StackPanel Orientation="Horizontal">
<Image Height="100" Source="{Binding FileInfo.FullName, IsAsync=True}"></Image>
<TextBlock Text="{Binding FileInfo.Name}"></TextBlock>
</StackPanel>
</DataTemplate>
</Window.Resources>
<Grid>
<ListBox Grid.Row="0" Name="lstBox1" ScrollViewer.CanContentScroll="True" VirtualizingStackPanel.IsVirtualizing="True" ItemTemplate="{StaticResource DataTemplateKey1}"></ListBox>
</Grid>
- Add code to MainWindow.xaml.cs to fill
lstBox1
with all jpg files in a directory
*You should replace d:\test with the directory path in your hard drive which contains 100+ jpg files.lstBox1.ItemsSource = new DirectoryInfo(@"D:\test").EnumerateFiles
("*.jpg", SearchOption.AllDirectories).Select((fi) => new FileInformation(fi)).ToList();
- Now run the first version, apparently, UI responsiveness is not smooth, why?
- Thumbnail image is a heavy resource, so we may reclaim it as much as possible, and we should defer to instantiate the thumbnail image in non-UI thread.
Binding.IsAsync
actually uses the OS thread pool, so the first DataTemplate
causes too many threads to run synchronously, this will consume too much CPU & IO.
- The first issue can be fixed easily by utilizing
WeakReference
, so I add two public
properties to FileInformation.cs: public object SlowBitmap
{
get
{
return _weakBitmap.Target ?? (_weakBitmap.Target = GetBitmap(FileInfo.FullName));
}
}
public object FastBitmap
{
get
{
return _weakBitmap.Target ?? DependencyProperty.UnsetValue;
}
}
private static BitmapSource GetBitmap(string path)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.UriSource = new Uri(path);
bmp.DecodePixelHeight = 100;
bmp.EndInit();
bmp.Freeze();
return bmp;
}
catch (Exception)
{
return null;
}
}
private WeakReference _weakBitmap = new WeakReference(null);
- Change
DataTemplate
in MainWindow.xaml as below: <DataTemplate x:Key="DataTemplateKey2" DataType="{x:Type local:FileInformation}">
<StackPanel Orientation="Horizontal">
<Image Height="100">
<Image.Source>
<PriorityBinding>
<Binding Path="FastBitmap"></Binding>
<Binding Path="SlowBitmap" IsAsync="True"></Binding>
</PriorityBinding>
</Image.Source>
</Image>
<TextBlock Text="{Binding FileInfo.Name}"></TextBlock>
</StackPanel>
</DataTemplate>
- Now run the second version, check whether UI responsiveness is satisfied.
- To fix the second issue, we will replace the
MS.Internal.Data.DefaultAsyncDataDispatcher
(via. Reflector), but WPF framework doesn't have a public
method to replace the DefaultAsyncDataDispatcher
, this class is not public
, we have to use the public
interface to achieve the goal, Binding's Converter is our selection. But how can Binding's Converter notify data model to instantiate a certain property. WPF framework has already provided many interfaces to implement many features, for example, INotifyPropertyChanged, INotifyPropertyChanging, ISupportInitialize, but misses an interface to delay instantiating property, so I add an interface to support property delay instantiation. (Reflection is an alternative way, but consider the well-known reason, I ignore it.)
public interface IInstantiateProperty
{
void InstantiateProperty(string propertyName,
System.Globalization.CultureInfo culture, SynchronizationContext callbackExecutionContext);
}
*Why do I need the third argument SynchronizationContext
, I will explain it later.
And implement the interface in FileInformation
as below:
public class FileInformation: INotifyPropertyChanged, IInstantiateProperty
{
#region IInstantiateProperty Members
public void InstantiateProperty(string propertyName,
System.Globalization.CultureInfo culture, SynchronizationContext callbackExecutionContext)
{
switch (propertyName)
{
case "FastBitmap":
callbackExecutionContext.Post((o) => OnPropertyChanged(propertyName),
_weakBitmap.Target = GetBitmap(FileInfo.FullName));
break;
default:
break;
}
}
}
- Now we can compose Binding's Converter:
public class InstantiatePropertyAsyncConverter : IValueConverter
{
private TaskScheduler _taskScheduler;
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
Task.Factory.StartNew((context) =>
{
var init = value as IInstantiateProperty;
if (init != null)
{
init.InstantiateProperty((parameter as string) ?? PropertyName, culture, (SynchronizationContext)context);
}
}, SynchronizationContext.Current, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
return null;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
public string PropertyName { get; set; }
}
- and update
DataTemplate
in MainWindow.xaml: <local:InstantiatePropertyAsyncConverter x:Key="InstantiatePropertyAsyncConverterKey"
PropertyName="FastBitmap" ></local:InstantiatePropertyAsyncConverter>
<DataTemplate x:Key="DataTemplateKey3"
DataType="{x:Type local:FileInformation}">
<StackPanel Orientation="Horizontal">
<Image Height="100">
<Image.Source>
<PriorityBinding>
<Binding Path="FastBitmap"></Binding>
<Binding Converter="
{StaticResource InstantiatePropertyAsyncConverterKey}"></Binding>
</PriorityBinding>
</Image.Source>
</Image>
<TextBlock Text="{Binding FileInfo.Name}"></TextBlock>
</StackPanel>
</DataTemplate>
*Now explain why I need the argument SynchronizationContext
, because we persist the thumbnail image reference in a WeakReference
. if we don't have the argument SynchronizationContext
, when the UI thread receives the PropertyChanged
event (WPF BeginInvoke
the PropertyChanged
event to UI thread asynchronously if the event happens in non-UI thread), the thumbnail image may have been GC.Collected
!
- Now run again, UI responsiveness improves more, but not fully satisfied. Why?
- Thread concurrency must limit
- The default
TaskScheduler
(TaskScheduler.Default
) always schedules task in FIFO order, but consider the ImageViewer
thumbnail scenario, FILO should be better.
- To fix these two issues, I implement a new
TaskScheduler
, I borrow the code from ParallelExtensionsExtras, I copy code from QueuedTaskScheduler.cs and change a few lines and rename this class to StackedTaskScheduler.cs. And change InstantiatePropertyAsyncConverter.cs as below:
public class InstantiatePropertyAsyncConverter : IValueConverter
{
private TaskScheduler _taskScheduler;
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
Task.Factory.StartNew((context) =>
{
var init = value as IInstantiateProperty;
if (init != null)
{
init.InstantiateProperty((parameter as string) ?? PropertyName, culture, (SynchronizationContext)context);
}
}, SynchronizationContext.Current, CancellationToken.None, TaskCreationOptions.None, TaskScheduler);
return null;
}
public object ConvertBack(object value, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
public string PropertyName { get; set; }
public int MaxConcurrentLevel { get; set; }
public bool UseQueue { get; set; }
public TaskScheduler TaskScheduler
{
get
{
return LazyInitializer.EnsureInitialized(ref _taskScheduler, () => UseQueue ?
(TaskScheduler)new QueuedTaskScheduler(TaskScheduler.Default, MaxConcurrentLevel) :
(TaskScheduler)new StackedTaskScheduler(TaskScheduler.Default, MaxConcurrentLevel));
}
}
}
- Now run the ultimate version, feel better.
Points of Interest
- WPF 4.5 adds a new feature, the Attached
DependencyProperty VirtualizingPanel.CacheLength
which guides WPF cache more UI elements, more than the visible UI elements
But in WPF4.0-, WPF only caches the visible UI elements. - How to limit the maximum Task in
TaskScheduler
queue, if the scheduled task queue count exceeds the threshold, we should cancel the earliest task. I have not figured out an elegant way to cancel a Task
in TaskScheduler
queue, from Reflector, there is a private
method InternalCancel()
in System.Threading.Tasks.Task
which can be used to cancel a scheduled Task, but why is it private
not public
?
History
- 28th March, 2014: Initial version