Async Data Binding & Data Virtualization
Improve WPF async data binding
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 AttachedDependencyProperty 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 firstDataTemplate
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 twopublic
properties to FileInformation.cs:/// <summary> /// item thumbnail, should NOT be invoked in UI thread /// </summary> public object SlowBitmap { get { return _weakBitmap.Target ?? (_weakBitmap.Target = GetBitmap(FileInfo.FullName)); } } /// <summary> /// item thumbnail, may be invoked in UI thread /// return DependencyProperty.UnsetValue if WeakReference.Target = null /// </summary> 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 apublic
method to replace theDefaultAsyncDataDispatcher
, this class is notpublic
, we have to use thepublic
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 aWeakReference
. if we don't have the argumentSynchronizationContext
, when the UI thread receives thePropertyChanged
event (WPFBeginInvoke
thePropertyChanged
event to UI thread asynchronously if the event happens in non-UI thread), the thumbnail image may have beenGC.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 theImageViewer
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 aTask
inTaskScheduler
queue, from Reflector, there is aprivate
methodInternalCancel()
inSystem.Threading.Tasks.Task
which can be used to cancel a scheduled Task, but why is itprivate
notpublic
?
History
- 28th March, 2014: Initial version