Contents
Introduction
At some point in the development of most applications, the capability to allow a user to undo and action becomes a requirement.
Neither WPF nor Silverlight 4, provide any real infrastructure for accomplishing this,
but instead require developers to build this functionality into each component.
There are some real advantages to providing an application wide mechanism for managing, what the author calls tasks; which are units of work performed within the application.
Advantages that include allowing tasks to be monitored, and grouped according to a context (such as a UI control),
executed sequentially or in parallel, and even to be rolled back on failure.
The main features of the task management system provided in this article are:
- Tasks can be undone, redone, and repeated.
- Task execution may be cancelled.
- Composite tasks allow sequential and parallel execution of tasks with automatic rollback on failure of an individual task.
- Tasks can be associated with a context, such as a UserControl, so that the undo, redo, and repeat actions can be, for example, enabled according to UI focus.
- Tasks can be global, having no context association.
- The task system can be wired to ICommands.
- Tasks can be chained, in that one task can use the Task Service to perform another.
- Return to a point in history by specifying an undo point.
- Coherency in the system is preserved by disallowing the execution of tasks outside of the Task Service.
- Task Model compatible with both the Silverlight and Desktop CLRs
While the examples presented in this article are predominately based around Calcium,
the Task Service and tasks are completely separate from Calcium, and can in fact be consumed
by any Desktop CLR or Silverlight CLR application. There will be two articles in this series.
Part one, this article, details the Task Model and how to use it.
Part two will show how the Task Model has been integrated into Calcium, and will demonstrate a simple diagram designer module.
Background
Back in 2007 I wrote about a Command Management system
that I created for a game. The code I present in this article follows some of the same principles I explored in that article, but this time around I have expanded the scope and depth to a far greater extent.
The WPF (and now Silverlight 4) commanding infrastructure does not provide support for an undo redo mechanism directly via the CommandManager. Controls such as the TextBox
provide internal support by means of RoutedCommands
. So, a unified system can be difficult to obtains with the existing infrastructure. The system that I have
devised leverages existing Undo/Redo capabilities of such controls; adding handlers for unhandled Routed Commands; while providing new features such as
execution cancellation, and allowing for a task to be repeated.
The Task Model
As stated in the introduction, tasks are what the author defines as a unit of work performed within an application. Tasks themselves are instances of an ITask
, which encapsulate the data and logic required to carry out an objective, such as moving an item in a UI.
Two Approaches
There are two approaches to task management. The first approach is to dictate
that every action causing a change in state is encapsulated in a task. This approach can be laborious, as a new task
instance must be created for each action. There is also a risk with this approach that external changes, performed outside of a task may change the state of a component, thereby rendering the undo task capability ineffective.
The second approach is to provide for state awareness. This means to take a snapshot of a component's state when a change
in state is occurring. The implementation of an undo action is to simply to restore the previous state of a UI component. This approach may be mandatory for interfaces where many actions are simply not undoable, for example a paint program where
use of filters, such as blur, are not able to be undone. This approach is compelling because it has a onetime cost of creating the capability to serialize and deserialize the component state. The downside is that storage efficiency needs to be considered, because each new task will save the entire component state, and not merely the change. One can imagine employing, for example, a diffgram
like approach to contend with the extra storage requirements, but that is outside the scope of this article.
Both approaches are attainable with the Task Model infrastructure presented here.
We will, however, be focussing on the first.
Capture Logic within a Task
By encapsulating the logic and state for a particular action within a task, we are able to
better manage tasks. Most notably we can queue tasks, undo the work performed by a task
(or set of tasks), and repeat that work if supported by the task. We can also provide a notification and cancellation system for tasks, so that subscribers to a task service are able to intervene when a particular task is being performed. We also have support for a context system. That is, we can provide the Task Service with an identifier,
which is used to partition a set of tasks, so that all actions around those tasks can be managed separately from other tasks.
For example, the context could be a view within the UI, so that when the view
loses focus, undoing of tasks for that view is disabled. We also allow for global tasks, which have no association with any particular UI context.
Task Service
All task execution activities are performed by the Task Service. In fact, only the Task Service is able execute a task. If it were otherwise, it could allow the state of the UI, for example, to be placed out of sync with the Task Service Undo or Repeat stacks, so that a subsequent undo or repeat of a task would produce unexpected results. This is important when the order of tasks is important, which is usually the case.
The following flow chart diagram shows how the Task Service goes about performing a task.
Figure: Flowchart of Task Execution
The Task Service itself contains several stacks
, which are associated with execution contexts. An execution context may be a control, or a
control's view model for example. In the demonstration Diagram Designer module, we use a guid identifier for the
view model. By using an execution context we are able to associate a set of Tasks with a particular UI element, thereby allowing a different set of undo/repeat tasks to be displayed in the
Edit menu, depending on what control has focus.
The default implementation of the ITaskService
is the TaskService
. As an aside, I further extend this TaskService
in the Calcium implementation to provide for application wide notifications using event aggregation.
Figure: ITaskService
and default implementation TaskService
.
Tasks and Undoable Tasks
A task represents a unit of work performed by the ITaskService
implementation. Some tasks are undoable, while others are not.
The ITask
interface represents the base functionality for all tasks. The base Task class (TaskBase
)
inherits from IInternalTask
. IInternalTask
provides capabilities directly associated with the TaskService
implementation, and allows the TaskService
to repeat the execution of a task. As stated previously, user code is prevented
from executing tasks without the TaskService
. This has been accomplished with explicit implementation of internal interfaces.
Other reasons for prohibiting the execution of a task directly; bypassing the ITaskService
, include preventing subscribers
of the TaskService
events from missing out on notifications and the opportunity to cancel tasks.
Also, the ITaskService
offers a single point to provide auditing and logging etc.
Figure: Task
class and interface hierarchy.
TaskBase
and UndoableTaskBase
are the starting point for creating new tasks which encapsulate the behavior of a task.
That is, by inheriting from either of these two classes we can encapsulate the task logic and state for an action within the subclass itself.
Alternatively, if this is too heavy, and you don't wish to go to the trouble of creating a new Task class, use the Task<T>
and UndoableTask<T>
classes. Both of these two classes accept Actions
,
which are performed when the task itself is performed etc.
See Using the Light Weight Task and UndoableTask Classes
Inheriting from TaskBase and UndoableTaskBase
Both TaskBase
and UndoableTaskBase
provide events that can be subscribed to in order to perform some custom activity.
TaskBase
provides an Execute
event, while UndoableTaskBase
provides both an Execute
event
(inherited from TaskBase
) and an Undo
event. I find it a good practice to favor events over virtual methods
because when a virtual method approach is used, it may cause confusion over whether the base implementation should be called,
likewise it can create a dependency on the base class implementation,
and in this case we also are able to prevent the circumvention of the TaskService
, because a custom task can't
call the Execute
method on the task.
An example of a custom UndoableTaskBase
implementation is the MoveDesignerHostTask
from the Diagram Designer module. It is presented here in its entirety.
C#:
class MoveDesignerHostTask : UndoableTaskBase<MoveDesignerHostArgs>
{
Point? point;
public MoveDesignerHostTask()
{
Execute += OnExecute;
Undo += OnUndo;
}
void OnUndo(object sender, TaskEventArgs<MoveDesignerHostArgs> e)
{
if (!point.HasValue)
{
throw new InvalidOperationException("Previous Point is not set.");
}
var viewModel = e.Argument.DesignerItemViewModel;
viewModel.Left = point.Value.X;
viewModel.Top = point.Value.Y;
}
void OnExecute(object sender, TaskEventArgs<MoveDesignerHostArgs> e)
{
var newPoint = e.Argument.NewPoint;
point = e.Argument.OldPoint;
var viewModel = e.Argument.DesignerItemViewModel;
viewModel.Left = newPoint.X;
viewModel.Top = newPoint.Y;
}
public override string DescriptionForUser
{
get
{
return "Move Item";
}
}
}
VB.NET:
Friend Class MoveDesignerHostTask
Inherits UndoableTaskBase(Of MoveDesignerHostArgs)
Public Sub New()
AddHandler MyBase.Execute, New EventHandler(Of TaskEventArgs(Of MoveDesignerHostArgs))(AddressOf Me.OnExecute)
AddHandler MyBase.Undo, New EventHandler(Of TaskEventArgs(Of MoveDesignerHostArgs))(AddressOf Me.OnUndo)
End Sub
Private Sub OnExecute(ByVal sender As Object, ByVal e As TaskEventArgs(Of MoveDesignerHostArgs))
Dim newPoint As Point = e.Argument.NewPoint
Me.point = New Point?(e.Argument.OldPoint)
Dim viewModel As DesignerItemViewModel = e.Argument.DesignerItemViewModel
viewModel.Left = newPoint.X
viewModel.Top = newPoint.Y
End Sub
Private Sub OnUndo(ByVal sender As Object, ByVal e As TaskEventArgs(Of MoveDesignerHostArgs))
If Not Me.point.HasValue Then
Throw New InvalidOperationException("Previous Point is not set.")
End If
Dim viewModel As DesignerItemViewModel = e.Argument.DesignerItemViewModel
viewModel.Left = Me.point.Value.X
viewModel.Top = Me.point.Value.Y
End Sub
Public Overrides ReadOnly Property DescriptionForUser As String
Get
Return "Move Item"
End Get
End Property
Private point As Point?
End Class
We can see that this task's purpose is to merely relocate a DesignerItemViewModel
by setting its Left
and Top
properties.
Using the Light Weight Task and UndoableTask Classes
While it is often prudent to place Task logic in a class, because this improves reusability, and helps
to decouple application logic (ala the Strategy Pattern), sometimes we may want to do things inline using a delegate
.
For this purpose we can use the Task
and UndoableTask
, which both accept an Action
parameter.
The following excerpt from the DiagramDesignerViewModel
demonstrates the UndoableTask
,
and how it is used to add new designer items.
C#:
DesignerItemViewModel designerItemViewModel;
var removedItems = new Stack<DesignerItemViewModel>();
UndoableTask<object> task = new UndoableTask<object>(
delegate
{
if (removedItems.Count > 0)
{
designerItemViewModel = removedItems.Pop();
}
else
{
if (lastAddedAt.Y > 200)
{
offset += offsetIncrement;
lastAddedAt = new Point(offset, 0);
}
lastAddedAt = new Point(lastAddedAt.X + offsetIncrement,
lastAddedAt.Y + offsetIncrement);
designerItemViewModel = new DesignerItemViewModel
{
Left = lastAddedAt.X, Top = lastAddedAt.Y
};
}
designerItems.Add(designerItemViewModel);
},
delegate
{
if (lastAddedAt.X > offset && lastAddedAt.Y > offset)
{
lastAddedAt = new Point(lastAddedAt.X - offset, lastAddedAt.Y - offset);
}
int removalIndex = designerItems.Count - 1;
var viewModel = designerItems[removalIndex];
designerItems.RemoveAt(removalIndex);
removedItems.Push(viewModel);
alignLeftCommand.RaiseCanExecuteChanged();
}, "Add Designer Item");
task.Repeatable = true;
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
taskService.PerformTask(task, null, Id);
VB.NET:
Dim designerItemViewModel As DesignerItemViewModel
Dim removedItems As New Stack(Of DesignerItemViewModel)
Dim task As New UndoableTask(Of Object)(Function
If (removedItems.Count > 0) Then
designerItemViewModel = removedItems.Pop
Else
If (Me.lastAddedAt.Y > 200) Then
Me.offset = (Me.offset + Me.offsetIncrement)
Me.lastAddedAt = New Point(Me.offset, 0)
End If
Me.lastAddedAt = New Point((Me.lastAddedAt.X + Me.offsetIncrement), _
(Me.lastAddedAt.Y + Me.offsetIncrement))
designerItemViewModel = New DesignerItemViewModel { _
.Left = Me.lastAddedAt.X, _
.Top = Me.lastAddedAt.Y _
}
End If
Me.designerItems.Add(designerItemViewModel)
End Function, Function
If ((Me.lastAddedAt.X > Me.offset) AndAlso (Me.lastAddedAt.Y > Me.offset)) Then
Me.lastAddedAt = New Point((Me.lastAddedAt.X - Me.offset), (Me.lastAddedAt.Y - Me.offset))
End If
Dim removalIndex As Integer = (Me.designerItems.Count - 1)
Dim viewModel As DesignerItemViewModel = Me.designerItems.Item(removalIndex)
Me.designerItems.RemoveAt(removalIndex)
removedItems.Push(viewModel)
Me.alignLeftCommand.RaiseCanExecuteChanged
End Function, "Add Designer Item")
task.Repeatable = True
ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService).PerformTask(Of Object)(_
DirectCast(task, UndoableTaskBase(Of Object)), Nothing, MyBase.Id)
Notice that in order to allow this task to be repeated, we must set its Repeatable
property to true. Doing
so indicates to the TaskService
that it should be placed in a repeatable Stack
associated
with the DiagramDesignerViewModel
(via its Id
property).
Composite Tasks
Sometimes we may want to allow a set of tasks to be associated with a single user action. In these circumstances it may
be tempting to circumvent the task system by placing various logically distinct activities into a single task,
which risks violating the Single Responsibility Principle.
In order to prevent this I have created the notion of a Composite Task. Composite tasks allow you to place any number of tasks
within them, and then when the Composite Task is performed, undone, or repeated; all tasks will be performed etc.
Using a Composite Task also allows us to choose to perform the sub tasks sequentially or in parallel, and to also automatically undo tasks when an individual task raises an exception.
Just as we have Task
and UndoableTask
classes to represent a single activity,
we have CompositeTask
and CompositeUndoableTask
classes.
Executing a Composite Undoable Task
To perform the execution of a set of tasks, we instantiate a CompositeUndoableTask
; passing it an IDictionary
of tasks and associated task arguments, as
shown in the following excerpt, demonstrating the alignment capability of the
diagram designer:
C#:
var tasksAndArgs = designerItems.ToDictionary(
x => (UndoableTaskBase<MoveDesignerHostArgs>)new MoveDesignerHostTask(),
x => new MoveDesignerHostArgs(x, new Point(20, x.Top)));
var undoableTask = new CompositeUndoableTask<MoveDesignerHostArgs>(tasksAndArgs, "Align Left");
var taskService = ServiceLocatorSingleton.Instance.GetInstance<ITaskService>();
taskService.PerformTask(undoableTask, null, Id);
VB.NET:
Dim undoableTask As New CompositeUndoableTask(Of MoveDesignerHostArgs)( _
Me.designerItems.ToDictionary(Of DesignerItemViewModel, _
UndoableTaskBase(Of MoveDesignerHostArgs), _
MoveDesignerHostArgs)(Function (ByVal x As DesignerItemViewModel)
Return New MoveDesignerHostTask
End Function, Function (ByVal x As DesignerItemViewModel)
Return New MoveDesignerHostArgs(x, New Point(20, x.Top))
End Function), "Align Left")
ServiceLocatorSingleton.Instance.GetInstance(Of ITaskService) _
.PerformTask(Of MoveDesignerHostArgs)( _
DirectCast(undoableTask, UndoableTaskBase(Of MoveDesignerHostArgs)), Nothing, MyBase.Id)
To the user this will appear as a single action, and if an undo is performed, it will likewise undo all tasks in sequence.
Parallel Execution of Composite Tasks
By default Composite Tasks execute sequentially, that is, child tasks are performed on the same thread, one after another, as this excerpt from the CompositeUndoableTask shows:
C#:
static void ExecuteSequentially(Dictionary<UndoableTaskBase<T>, T> taskDictionary)
{
var performedTasks = new List<UndoableTaskBase<T>>();
foreach (KeyValuePair<UndoableTaskBase<T>, T> pair in taskDictionary)
{
var task = (IInternalTask)pair.Key;
try
{
task.PerformTask(pair.Value);
performedTasks.Add(pair.Key);
}
catch (Exception)
{
SafelyUndoTasks(performedTasks.Cast<IUndoableTask>());
throw;
}
}
}
VB.NET:
Private Shared Sub ExecuteSequentially( _
ByVal taskDictionary As Dictionary(Of UndoableTaskBase(Of T), T))
Dim performedTasks As New List(Of UndoableTaskBase(Of T))
Dim pair As KeyValuePair(Of UndoableTaskBase(Of T), T)
For Each pair In taskDictionary
Dim task As IInternalTask = pair.Key
Try
task.PerformTask(pair.Value)
performedTasks.Add(pair.Key)
Catch exception1 As Exception
CompositeUndoableTask(Of T).SafelyUndoTasks(performedTasks.Cast(Of IUndoableTask)())
Throw
End Try
Next
End Sub
Sometimes however, we may have a set of tasks that are independent, and which may benefit from execution on different threads.
static void ExecuteInParallel(Dictionary<UndoableTaskBase<T>, T> taskDictionary)
{
var performedTasks = new List<UndoableTaskBase<T>>();
object performedTasksLock = new object();
var exceptions = new List<Exception>();
object exceptionsLock = new object();
var events = taskDictionary.ToDictionary(x => x, x => new AutoResetEvent(false));
foreach (KeyValuePair<UndoableTaskBase<T>, T> pair in taskDictionary)
{
var autoResetEvent = events[pair];
var task = (IInternalTask)pair.Key;
var undoableTask = pair.Key;
var arg = pair.Value;
ThreadPool.QueueUserWorkItem(
delegate
{
try
{
task.PerformTask(arg);
lock (performedTasksLock)
{
performedTasks.Add(undoableTask);
}
}
catch (Exception ex)
{
lock (exceptionsLock)
{
exceptions.Add(ex);
}
}
autoResetEvent.Set();
});
}
foreach (var autoResetEvent in events.Values)
{
autoResetEvent.WaitOne();
}
if (exceptions.Count > 0)
{
SafelyUndoTasks(performedTasks.Cast<IUndoableTask>());
throw new CompositeException("Unable to undo tasks", exceptions);
}
}
In which case we merely need to change the Parallel
property of the CompositeTask
before execution.
The following unit test excerpt demonstrates this:
C#:
void CompositeTasksShouldBePerformedInParallel(object contextKey)
{
var tasks = new Dictionary<TaskBase<string>, string>();
for (int i = 0; i < 100; i++)
{
tasks.Add(new MockTask(), i.ToString());
}
var compositeTask = new CompositeTask<string>(tasks, "1") { Parallel = true };
var target = new TaskService();
target.PerformTask(compositeTask, null, contextKey);
foreach (KeyValuePair<TaskBase<string>, string> keyValuePair in tasks)
{
var mockTask = (MockTask)keyValuePair.Key;
Assert.AreEqual(1, mockTask.ExecutionCount);
}
}
VB.NET:
Private Sub CompositeTasksShouldBePerformedInParallel(ByVal contextKey As Object)
Dim tasks As New Dictionary(Of TaskBase(Of String), String)
Dim i As Integer
For i = 0 To 100 - 1
tasks.Add(New MockTask, i.ToString)
Next i
Dim compositeTask As New CompositeTask(Of String)(tasks, "1")
compositeTask.Parallel = True
New TaskService().PerformTask(Of String)(compositeTask, Nothing, contextKey)
Dim keyValuePair As KeyValuePair(Of TaskBase(Of String), String)
For Each keyValuePair In tasks
Dim mockTask As MockTask = DirectCast(keyValuePair.Key, MockTask)
Assert.AreEqual(Of Integer)(1, mockTask.ExecutionCount)
Next
End Sub
Chaining Tasks
Tasks themselves can leverage the ITaskService
in order to execute sub-tasks.
This is useful when we have conditional execution of tasks depending on the result of some internal task activity.
The chaining capability explains a curious aspect in the implementation of the various PerformTask
overloads
in the TaskService
class.
C#:
public TaskResult PerformTask<T>(TaskBase<T> task, T argument, object contextKey)
{
ArgumentValidator.AssertNotNull(task, "task");
if (contextKey == null)
{
return PerformTask(task, argument);
}
var eventArgs = new CancellableTaskServiceEventArgs(task);
OnExecuting(eventArgs);
if (eventArgs.Cancel)
{
return TaskResult.Cancelled;
}
int dictionaryKey = contextKey.GetHashCode();
undoableDictionary.Remove(dictionaryKey);
redoableDictionary.Remove(dictionaryKey);
ReadWriteSafeStack<IInternalTask> tasks;
if (!repeatableDictionary.TryGetValue(dictionaryKey, out tasks))
{
tasks = new ReadWriteSafeStack<IInternalTask>();
repeatableDictionary[dictionaryKey] = tasks;
}
tasks.Push(task);
var result = task.PerformTask(argument);
OnExecuted(new TaskServiceEventArgs(task));
return result;
}
VB.NET:
Public Function PerformTask(Of T)(ByVal task As TaskBase(Of T), _
ByVal argument As T, ByVal contextKey As Object) As TaskResult
Dim tasks As ReadWriteSafeStack(Of IInternalTask)
ArgumentValidator.AssertNotNull(Of TaskBase(Of T))(task, "task")
If (contextKey Is Nothing) Then
Return Me.PerformTask(Of T)(task, argument)
End If
Dim eventArgs As New CancellableTaskServiceEventArgs(task)
Me.OnExecuting(eventArgs)
If eventArgs.Cancel Then
Return TaskResult.Cancelled
End If
Dim dictionaryKey As Integer = contextKey.GetHashCode
Me.undoableDictionary.Remove(dictionaryKey)
Me.redoableDictionary.Remove(dictionaryKey)
If Not Me.repeatableDictionary.TryGetValue(dictionaryKey, tasks) Then
tasks = New ReadWriteSafeStack(Of IInternalTask)
Me.repeatableDictionary.Item(dictionaryKey) = tasks
End If
tasks.Push(task)
Dim result As TaskResult = task.PerformTask(argument)
Me.OnExecuted(New TaskServiceEventArgs(task))
Return result
End Function
We see that the task itself is performed after the task has been pushed onto the tasks Stack
.
Thereby allowing any chained tasks to be undone or repeated in the correct order.
Unit Tests
The TaskServiceTest
class contains the unit tests for the entire Task Model.
A behavior driven approach is invaluable when developing a component like this, as there are quite a few scenarios to test.
This class is worth looking at for understanding some of the behavior that hasn't been covered in the demo application.
Figure: Task model unit test results
Conclusion
In this article we have seen how tasks, which are application work units, are able to be performed, undone, and repeated by a task management system. We saw how composite tasks, comprising any number of sub-tasks, can be performed sequentially or in parallel,
and how tasks can be chained without compromising the undo/repeat capability. We then
touched on the practical application of the Task Model to a simple diagramming tool.
In the next part of the series, we will look at how the Task Model has been
integrated into a WPF application, and explore the example diagram designer in
greater detail. I hope you will join me then.
I hope you find this project useful. If so, then I'd appreciate it if you would rate it and/or leave feedback below. This will help me to make my next article better.
History
February 2010