Introduction
With my project CBR on
CodePlex, I had to work on the new Task
Parallel
Library to be compared with
old classical threads. This leads me on infinite progress bar control and a non-blocking or
intrusive progress UI.
To share my work, you will find below:
- How to write a WaitSpin control and create several designs in Expression
Designer...
- Create a custom ItemsControl to be a sliding panel with multi-progress
cancelable
items...
- Multi-Threading : Threads, Taks and Background worker...
Screenshots
Screenshot: "Demonstration app - Threads and sliding
panel"
Screenshot: "Demonstration app - WaitSpin designs"
WaitSpin control
The Code
It is derivated from the Control
class. It is very simple,
define a few properties and methods. The requirement for the template is to have a
PART_LoadingAnimation
that define a storyboard to animate the control.
public enum AnimationState
{
Playing,
Paused,
Stopped
}
public class WaitSpin : Control
{
#region --------------------CONSTRUCTORS--------------------
static WaitSpin()
{
FrameworkElement.DefaultStyleKeyProperty.OverrideMetadata(typeof(WaitSpin),
new FrameworkPropertyMetadata(typeof(WaitSpin)));
}
public WaitSpin()
{
this.DefaultStyleKey = typeof(WaitSpin);
}
#endregion
#region --------------------DEPENDENCY PROPERTIES--------------------
#region -------------------- fill--------------------
public static readonly DependencyProperty ShapeFillProperty =
DependencyProperty.Register("ShapeFill", typeof(Brush), typeof(WaitSpin), null);
[System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("The fill for the shapes.")]
public Brush ShapeFill
{
get { return (Brush)GetValue(ShapeFillProperty); }
set { SetValue(ShapeFillProperty, value); }
}
#endregion
#region -------------------- stroke--------------------
public static readonly DependencyProperty ShapeStrokeProperty =
DependencyProperty.Register("ShapeStroke", typeof(Brush), typeof(WaitSpin), null);
[System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("The stroke for the shapes.")]
public Brush ShapeStroke
{...
#endregion
#region --------------------Is playing--------------------
public static readonly DependencyProperty IsPlayingProperty = DependencyProperty.Register("IsPlaying", typeof(bool), typeof(WaitSpin),
new FrameworkPropertyMetadata(new PropertyChangedCallback(OnIsPlayingChanged)));
private static void OnIsPlayingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(d))
return;
WaitSpin element = d as WaitSpin;
element.ChangePlayMode((bool)e.NewValue);
}
[System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("Incates wheter is playing or not.")]
public bool IsPlaying
{...
#endregion
#region --------------------Associated element--------------------
public static readonly DependencyProperty AssociatedElementProperty = DependencyProperty.Register("AssociatedElement", typeof(UIElement), typeof(WaitSpin), null);
[System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("Associated element that will be disabled when playing.")]
public UIElement AssociatedElement
{...
#endregion
#region --------------------AutoPlay--------------------
public static readonly DependencyProperty AutoPlayProperty = DependencyProperty.Register("AutoPlay", typeof(bool), typeof(WaitSpin),
new FrameworkPropertyMetadata(new PropertyChangedCallback(OnAutoPlayChanged)));
private static void OnAutoPlayChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(d))
return;
WaitSpin element = d as WaitSpin;
element.ChangePlayMode((bool)e.NewValue);
}
[System.ComponentModel.Category("Loading Animation Properties"), System.ComponentModel.Description("The animation should play on load.")]
public bool AutoPlay
{...
#endregion
#endregion
#region --------------------PROPERTIES--------------------
...
public AnimationState AnimationState
{
get { return this._animationState; }
}
#endregion
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
this._loadingAnimation = (Storyboard)this.GetTemplateChild("PART_LoadingAnimation");
if (this.AutoPlay)
Begin();
}
internal void ChangePlayMode( bool playing )
{
if (this._loadingAnimation == null) return;
if (playing)
{
if (this._animationState != AnimationState.Playing)
Begin();
}
else
{
if (this._animationState != AnimationState.Stopped)
Stop();
}
}
public void Begin()
{
if (this._loadingAnimation != null)
{
this._animationState = AnimationState.Playing;
this._loadingAnimation.Begin();
this.Visibility = System.Windows.Visibility.Visible;
if (AssociatedElement != null)
AssociatedElement.IsEnabled = false;
}
}
public void Pause()
{
if (this._loadingAnimation != null)
{
this._animationState = AnimationState.Paused;
this._loadingAnimation.Pause();
}
}
public void Resume()
{
if (this._loadingAnimation != null)
{
this._animationState = AnimationState.Playing;
this._loadingAnimation.Resume();
}
}
public void Stop()
{
if (this._loadingAnimation != null)
{
this._animationState = AnimationState.Stopped;
this._loadingAnimation.Stop();
this.Visibility = System.Windows.Visibility.Hidden;
if (AssociatedElement != null)
AssociatedElement.IsEnabled = true;
}
}
}
The basic provided XAML style define the Visibility
, ShapeFill
and
ShapeStroke
properties. Joined is a simple ellipse template and a storyboard
to play with shape transparency.
Note that the template is surrounded by a Viewbox
binded to
the control dimensions - that's the easiest way i found to make it
adjustable because Canvas
control is a nightmare...Ellipse properties are
template binded to WaitSpin control properties
Stroke="{TemplateBinding ShapeStroke}" Fill="{TemplateBinding ShapeFill}"
<Style x:Key="{x:Type local:WaitSpin}" TargetType="{x:Type local:WaitSpin}">
<Setter Property="Visibility" Value="Visible" />
<Setter Property="ShapeFill" Value="White" />
<Setter Property="ShapeStroke" Value="#00000000" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:WaitSpin}">
<Viewbox Width="{TemplateBinding Width}" Height="{TemplateBinding Height}">
<Canvas x:Name="Document" Width="100" Height="100" Clip="F1 M 0,0L 100,0L 100,100L 0,100L 0,0">
<Canvas.Resources>
<Storyboard.....
</Canvas.Resources>
<Ellipse x:Name="ellipse8" Width="15" Height="15" Canvas.Left="12" Canvas.Top="12" Stretch="Fill" StrokeThickness="1" StrokeLineJoin="Round" Stroke="{TemplateBinding ShapeStroke}" Fill="{TemplateBinding ShapeFill}" Opacity="0.66" />
.....
</Canvas>
</Viewbox>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Create Designs
The easiest way to create design is to combine Expression Designer and Blend.
My model is based on a 100x100 document. In the Designer, create the shapes you
want and export them with the following options: "selected objects" + (3rd) "XAML SL4 / CWPF canvas" +
"Always name" + "Place grouped objects in container" + "Paths" + "Convert to
effets"
Then, copy the canvas content from the exported file into a WaitSpin style copy to
replace the ellipses.
Take also care about the order and name of elements if you use the pre-defined opacity storyboard that exist in the
basic style, then just rename the elements to "EllipseX" or change
the TargetName
of each KeyFrames
.
<Storyboard x:Key="PART_LoadingAnimation" x:Name="PART_LoadingAnimation" RepeatBehavior="Forever">
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="ellipse1" Storyboard.TargetProperty="(UIElement.Opacity)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="1"/>
....
Or create a new storyboard in Blend like the rotating one defined in the
"ie" style and printed below - Visual Studio don't like it in the
designer, but it's working.
<Storyboard x:Key="PART_LoadingAnimation" x:Name="PART_LoadingAnimation" RepeatBehavior="Forever">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[2].(RotateTransform.Angle)" Storyboard.TargetName="Document">
<EasingDoubleKeyFrame KeyTime="00:00:02" Value="360"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
Note : Expression suite is out of the scope...I am not mastering it at
all !
Multi-progress panel
For an online programm, often calls are made to a distant web service that is
not allways responding a quick manner. So you launch some asynchronous methods
and wait for a response. The idea is to make a "panel" like the download one in
Internet Explorer,
small, non intrusive, cancelable and multi-process in this case. I wan it to
be MVVM compliant too !
First, we need to choose the base control and define the ViewModel :
ItemsControl
seems to answer the requirement, then lets write the
ViewModel
for our process items :
public class ProcessItem : ViewModelBase
{
public bool UseTempo { get; set; }
public DateTime StartTime { get; set; }
public bool CanCancel { get; set; }
public bool ShowProgress { get; set; }
public bool ShowPercentage { get; set; }
private string _Title;
public string Title
{
get { return _Title; }
set
{
if (_Title != value)
{
_Title = value;
RaisePropertyChanged("Title");
}
}
}
private string _Message;
public string Message
{
get { return _Message; }
set
{
if (_Message != value)
{
_Message = value;
RaisePropertyChanged("Message");
}
}
}
public bool WaitForCancel { get; set; }
#region cancel command
private ICommand cancelCommand;
public ICommand CancelCommand
{
get
{
if (cancelCommand == null)
cancelCommand = new DelegateCommand(CancelCommandExecute, CancelCommandCanExecute);
return cancelCommand;
}
}
virtual public bool CancelCommandCanExecute()
{
return CanCancel;
}
virtual public void CancelCommandExecute()
{
WaitForCancel = true;
}
#endregion
}
To write the control create a class that inherit from ItemsControl
.
public class ProcessPanel : ItemsControl
Then we override the OnApplyTemplate
to retreive the opening and closing
animation that are defined in the template
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_CloseStoryboard = (Storyboard)this.GetTemplateChild("PART_CloseStoryboard");
_CloseAnim = _CloseStoryboard.Children[0] as DoubleAnimation;
_OpenStoryboard = (Storyboard)this.GetTemplateChild("PART_OpenStoryboard");
_OpenAnim = _OpenStoryboard.Children[0] as DoubleAnimation;
}
After that we only subscribe to the items collection event to expand or
reduce the panel that will be animated by our storyboard. Note that the
StoryBoard
object arround the animation are the only ways to modify the
From
and To
properties. Otherwise you will get
exceptions with frezzed objects because theu are playing.
protected override void OnItemsChanged(System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
base.OnItemsChanged(e);
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
Console.WriteLine("play open : from " + _OpenAnim.From + "to" + _OpenAnim.To);
_OpenStoryboard.Begin();
_OpenAnim.From = _OpenAnim.To;
_OpenAnim.To += 35;
_CloseAnim.From = _OpenAnim.To;
_CloseAnim.To = _OpenAnim.From;
}...
The style for our control is like below : I define a DockPanel
that contains the storyboards, then a simple grid that has a border
for shaping the control and an ItemPresenter
that contains my
ItemsControl
collection. ItemTemplate
is replaced
with simple DataTemplate
that is binded to the
ProcessItem
ViewModel :
<Style x:Key="{x:Type local:ProcessPanel}" TargetType="{x:Type local:ProcessPanel}">
<Setter Property="Visibility" Value="Visible" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:ProcessPanel}">
<DockPanel x:Name="PART_Container" VerticalAlignment="Bottom" Panel.ZIndex="1000">
<DockPanel.Resources>
<Storyboard x:Name="PART_CloseStoryboard" x:Key="PART_CloseStoryboard">
<DoubleAnimation x:Name="CloseAnim"
Storyboard.TargetName="PART_Container"
Storyboard.TargetProperty="(Height)"
From="41"
To="0"
Duration="00:00:00.4000000" />
</Storyboard>
<Storyboard x:Name="PART_OpenStoryboard" x:Key="PART_OpenStoryboard">
...
</DockPanel.Resources>
<Grid Margin="0">
<Border x:Name="top" Grid.ColumnSpan="4" CornerRadius="7,7,0,0">
<Border.Background>
<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
...
</LinearGradientBrush>
</Border.Background>
</Border>
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
</Grid>
</DockPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemsControl.ItemTemplate">
<Setter.Value>
<DataTemplate>
<Grid Margin="0" Height="35">
<Grid.ColumnDefinitions>
...
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="5" Background="Transparent"
Text="{Binding Title}" TextWrapping="WrapWithOverflow" TextTrimming="WordEllipsis" />
<TextBlock Grid.Column="1" VerticalAlignment="Center" Margin="5" Background="Transparent"
Text="{Binding Message}" TextWrapping="WrapWithOverflow" TextTrimming="WordEllipsis" />
<controls:WaitSpin Grid.Column="2" AutoPlay="True" Margin="6"></controls:WaitSpin>
<Button Grid.Column="3" Margin="6" Content="x" Command="{Binding CancelCommand}" />
</Grid>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
Thread, Task and BackgroundWorker
Explanations
On my C.B.R.
project, I gave a try to the TPL to compare with my thread
implementation...that was a catastroph...So I choose to go deeper and try to
compare these methods. I choose to implement a "recursive disk/folder
parsing" method to find images. This allow us to continue also with how
to use the WaitSpin
and ProgressPanel
controls.
The algorithm is allways the same and basically like below - depending on
the method : BtnClick => create a ProcessItem => add it to collection =>
start a "process thread" => when finished, remove ProcessItem
Thread way
Nothing special but note that I use the BeginInvoke
on the
application thread to add and remove the item. It is asynchron and can lead
into errors.
private void btnThread_Click(object sender, RoutedEventArgs e)
{
ProcessItem pi = new ProcessItem()
{
Title = "btnThread_Click",
Message = "btnThread_Click",
CanCancel = true,
ShowProgress = true,
ShowPercentage = false,
Data = this.tbFolder.Text,
StartTime = DateTime.Now,
UseTempo = chkUseTempo.IsChecked.Value
};
Thread t = new Thread(new ParameterizedThreadStart(LaunchThread));
t.IsBackground = true;
t.Priority = ThreadPriority.Normal;
t.Start(pi);
}
private void LaunchThread(object param)
{
try
{
ProcessItem pi = param as ProcessItem;
Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
{
_list.Add(pi);
listBox1.Items.Add(pi.StartTime);
});
pi.Message = "Processing folders";
ProcessMethod(pi, pi.Data as string);
Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
{
_list.Remove(pi);
listBox1.Items.Add(DateTime.Now - pi.StartTime);
});
}
...}
internal void ProcessMethod(object param, string folder)
{
ProcessItem pi = param as ProcessItem;
...
pi.Message = "Processing folder " + directory.Name;
foreach (FileInfo file in directory.GetFiles("*.*"))
{
if (pi.WaitForCancel)
{
pi.Message = "canceled by the user";
return;
}
pi.Message = "Processing file " + file.Name;
if (file.Extension == ".jpg")
{
Application.Current.Dispatcher.Invoke(DispatcherPriority.Normal, (ThreadStart)delegate
{
listBox1.Items.Add(file.Name);
});
}
...
}
TPL - First try...
The idea was : wow ! make some parallel tasks and see how quick it is....but
I forgot that creating a Task
is CPU expensive and that my hard
disk is the bottleneck in this case ! Imaging that even if the framework can
create 20000 task in a second my hard disk is not capable of reading file so quick !
Below is the first try :
public void ParallelProcessMethod(object param, string folder)
{
ProcessItem pi = param as ProcessItem;
try
{
DirectoryInfo directory = new DirectoryInfo(folder);
if (!directory.Exists)
...
Parallel.ForEach<FileInfo>(directory.GetFiles("*.*"), fi =>
{
...
Parallel.ForEach<DirectoryInfo>(directory.GetDirectories("*", SearchOption.TopDirectoryOnly), dir =>
{
...
ParallelProcessMethod(param, dir.FullName);
...
Advise : Never do that! Be sure that the task number will not grow too much and that the
processing is long enough to compensate the task creation cost.
Update : This is certainly due to Visual Studio and the debug mode ! See
cpu and time comparison...
TPL - Second try...
Remove the Parallel.ForEach
and everything gets better ! Here is
the handler. I put the code for adding and removing the ProcessItem
here to take
advantage of the ContinueWith
method. The process code is the same.
private void btnTask_Click(object sender, RoutedEventArgs e)
{
ProcessItem pi = new ProcessItem()
{
Title = "btnTask_Click",
Message = "btnTask_Click",
CanCancel = true,
ShowProgress = true,
ShowPercentage = false,
Data = this.tbFolder.Text,
StartTime = DateTime.Now,
UseTempo = chkUseTempo.IsChecked.Value
};
Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
{
_list.Add(pi);
listBox1.Items.Add(pi.StartTime);
});
Task tk = Task.Factory.StartNew(() =>
{
try
{
pi.Message = "Processing folders";
ParallelProcessMethod(pi, pi.Data as string);
}
catch (Exception err)
{
}
}).ContinueWith(ant =>
{
_list.Remove(pi);
listBox1.Items.Add(DateTime.Now - pi.StartTime);
}, TaskScheduler.FromCurrentSynchronizationContext());
}
...
Background Worder to finish
I implemented the BackgroundWorker
in a quick and classic
way
like below :
private void btnWorker_Click(object sender, RoutedEventArgs e)
{
BackgroundWorker _Worker = null;
_Worker = new BackgroundWorker();
_Worker.WorkerReportsProgress = true;
_Worker.WorkerSupportsCancellation = true;
_Worker.DoWork += new DoWorkEventHandler(bw_DoBuildWork);
_Worker.RunWorkerCompleted += new RunWorkerCompletedEventHandler(bw_RunWorkerCompleted);
_Worker.ProgressChanged += new ProgressChangedEventHandler(bw_ProgressChanged);
ProcessItem pi = new ProcessItem()
{
Title = "btnWorker_ClickbtnWorker_Click",
Message = "btnWorker_Click",
CanCancel = true,
ShowProgress = true,
ShowPercentage = false,
Data = this.tbFolder.Text,
StartTime = DateTime.Now,
UseTempo = chkUseTempo.IsChecked.Value
};
_Worker.RunWorkerAsync(pi);
}
...
void bw_DoBuildWork(object sender, DoWorkEventArgs e)
{
ProcessItem pi = e.Argument as ProcessItem;
Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
{
_list.Add(pi);
listBox1.Items.Add(pi.StartTime);
});
BackProcessMethod(pi, pi.Data as string);
e.Result = pi;
}
internal void BackProcessMethod(object param, string folder)
{
ProcessItem pi = param as ProcessItem;
...
}
void bw_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
...empty
}
void bw_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
ProcessItem pi = e.Result as ProcessItem;
if (e.Error != null)
{
}
else if (e.Cancelled)
{
}
Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart)delegate
{
_list.Remove(pi);
listBox1.Items.Add(DateTime.Now - pi.StartTime);
});
}
Timing and CPU
My system disk "C:" is containing 125 241 files, 23 087 folders for a
total of 163 Go.
BackgroundWorker = 00:00:08s 1070. Cpu is 50% max and
use the 4 core
Task with Paralell (first try) = 00:00:04s with
very high Cpu at 90% but we win 3s
Task = 00:00:07s 3214.
Cpu is 50% max, but use less on the 4 core
Thread = 00:00:07s 7414. Cpu is 60% max, activity is the
same as tasks
To summerize...
This little try, in my opinion, show :
-
BackgroundWorker
method has got a bit more code,
seems a bit obsolet with his progress handler and
more rigid
- Execution speed is nearly the same with a small advantage for Task
- Thread are more classical way of writing regarding linq style of the
Tasks
- Tasks certainly reveals avantages in a more complex scenario !
- Use release mode out of Visual Studio to compare !
By the way, it is more a fashion than anything else - this is to make TPL
fan overreact :-)
Conclusion
This article is a very quick draft of what you can use for further
developpement. I hope you will enjoy it as much as I do...next improvment is to
reduce the panel after user interaction...but I have troubles...
History
- v1.0
- First version: everything there. I would like to add a auto-hide function if
mouse leave the panel...
-
v2.0
- Second version: Update with cpu and
times.
New advise about the first try, Tasks seems to be better in release mode.