Tasks for ActionScript 3 – Improving on Event-Driven Programming

posted by Craig Gidney on October 2, 2012

I was somewhat recently involved in an ActionScript 3 project. During that project I implemented tasks for AS3 which, thanks to how we handle code ownership, I can post on github for everyone. The API is based on .Net’s task parallel library, and I will be using it in my examples.

Before we begin, a disclaimer: I am an amateur in ActionScript. Maybe there’s already some nice alternative to events (where appropriate)… but I haven’t found it. There’s also the chance that tearing the library out has broken some of the code. Hopefully both veterans and amateurs will at least find the concept useful.

Many of the things you want to do in ActionScript require events. For example, to load content from a file, you start by creating a loader. Then you register event handlers for Event.Complete, IOErrorEvent.IO_Error and SecurityErrorEvent.SECURITY_ERROR (and maybe more? Don’t forget any!). Then, when one of the handlers is run, you react appropriately and unregister the callbacks. If this sounds like a lot of boilerplate code and documentation-reading to discover what events might be thrown… it is. Events are a very broadly applicable construct, but it is terrifying how easy and enticing it is to do use them the wrong way (only register for Event.Complete, never bother unregistering). Luckily, by using the more specialized concept of a “task”, we can make it far easier to do the right thing.

A task (also often called a future or a promise) is an “eventual result”. In order to access the result of a task, you register a callback that will be run once when the task completes or faults. It sounds a lot like an event, but callbacks are guaranteed to be called only once and you don’t need additional out-of-band functionality to propagate exceptions. As a result, you don’t need to worry about unregistering callbacks (it happens automatically) and there’s no need to search documentation in order to discover what errors are possible (unless you want to specially handle them).

More concretely, my task interface is this:

/// A result that will be available in the future.
public interface Task {
	/// Determines if the task has completed successfully.
	function IsCompleted() : Boolean;
	/// Determines if the task has 'completed' due to an error.
	function IsFaulted() : Boolean;
	/// Determines if the task has faulted due to cancellation.
	function IsCancelled() : Boolean;
	/// Determines if the task has not yet completed or faulted.
	function IsRunning() : Boolean;

	/// Returns the task's result. Fails if the task has not completed successfully.
	function Result() : *;
	/// Returns the task's fault. Fails if the task has not faulted.
	function Fault() : *;

	/// Runs a callback after the given task completes or faults.
	/// Returns the callback's eventual result as a task.
	/// The callback must take 0 arguments.
	/// If the given task has already completed then the callback may be run synchronously.
	function Await(callback : Function) : Task;

	/// Runs a callback using the result of the given task.
	/// Returns the callback's eventual result as a task.
	/// The callback must take 0 arguments or 1 argument for the task's result.
	/// If the given task faults then the fault is propagated and the callback is not run.
	/// If the given task has already completed then the callback may be run synchronously.
	function ContinueWith(callback : Function) : Task;

	/// Returns a Task<T> equivalent to the eventual Task<T> resulting from this Task<Task<T>>.
	/// Intuitively, transforms this Task<Task<T>> into a Task<T> in the reasonable way.
	/// If either this Task<Task<T>> or its resulting Task<T> fault, the returned Task<T> faults.
	function Unwrap() : Task;

	/// Runs a callback using the unwrapped result of the given task.
	/// Returns the callback's eventual result as a task.
	/// Equivalent to ContinueWith(callback).Unwrap()
	function Bind(callback : Function) : Task;

	/// Runs a callback based on the failure of the given task.
	/// Returns the callback's eventual result as a task.
	/// The callback must take 0 arguments or 1 argument for the task's fault.
	/// If the given task does not fault, the resulting task will contain a null result.
	/// If the given task has already completed then the callback may be run synchronously.
	function CatchWith(callback : Function) : Task;
}

Consuming Tasks

Tasks are consumed in a very simple and consistent way: registering a callback to be run when the task is finished. (Side note: the methods to synchronously access a task’s state, such as IsCompleted, are there for conveniences like debugging. You can use them, but it’s not recommended.) You don’t need to search documentation for the right ‘name’ or ‘key’. You don’t need to worry about “missing” a task (if the task has already finished then the callback runs immediately). You don’t need to cleanup the callback registration. You just get the task, and register your callback.

In my implementation there are four important methods for consuming tasks: ContinueWith, CatchWith, Await and Unwrap:

  • ContinueWith: Runs a callback when/if a task succeeds. The task returned by ContinueWith represents the eventual result of running the callback. If the task faults, the error is propagated to the returned task without running the callback.
  • CatchWith: Runs a callback when/if a task faults. If the task succeeds, the returned task will contain null. If the task faults, the returned task will contain the result of running the callback.
  • Await: Runs a callback when a task completes in any way, success or failure. The returned task contains the result of running the callback.
  • Unwrap: Turns ‘doubly relative’ tasks (e.g. an outer task containing an inner task containing an int) into proper tasks (e.g. a task containing just an int). If either the outer or inner task faults, then the resulting task faults. If both succeed then the resulting task succeeds with the result of the inner task.

There are also other useful methods, but these four are the most important to understand. Even understanding only ContinueWith is enough to ‘pull out’ values. For example, here’s how you access the content of a text file, given a function that loads it and exposes the result as a task:

TaskInterop.LoadText(url).ContinueWith(function(text:String):void {
    trace("The loaded text is: " + text);
});

Note that I am using an anonymous function to put the callback inline, keeping related code close together. I’m not sure how commonly used they are in ActionScript because I don’t see them in online examples, but I highly recommend them. In any case, this will print the contents of the text file once it is loaded. If the text file failed to load or the debug trace somehow failed, the error would be in a task returned from ContinueWith.

Handling failure is done via CatchWith. For example, we could invoke a remote web service method and include some error handling:

TaskInterop.InvokeWebServiceMethod(webService, "GetUserEmailAddress", username)
.ContinueWith(function(emailAddress:String):void {
    trace(username + "'s email address is " + emailAddress);
})
.CatchWith(function(err:*):void {
   trace("There was an error retrieving " + username + "'s email address: " + err);
});

This code attempts to invoke the “GetUserEmailAddress” method on the given remote web service, using the username variable as an argument. It registers a callback to print the returned email address, and also a callback to print any error.

Notice that you don’t have to care about the implementation details of LoadText or InvokeWebServiceMethod in order to use their result. All you need to know is that they return a task, and you’re set. You can treat them the same way. You can’t forget to register for SecurityErrorEvent.SECURITY_ERROR, you can’t forget to unregister the callbacks, it all just works.

Chaining Tasks

The most useful aspect of tasks is the powerful, yet easy, ability to chain them off of each other. ContinueWith returns a task, which you can use ContinueWith on again, and again on the result of that, and on the result of that, and so forth indefinitely. The previous example was already using chaining, but here’s a more involved example:

TaskInterop.LoadText(configFileUrl)
.ContinueWith(function(configText:String):Task {
   var contentUrl:String = configText;
   return TaskInterop.Load(contentUrl);
})
.Unwrap() // we have a Task of Task of content, so unwrap into a single-level Task of content
          // or could have used Bind, which is equivalent to ContinueWith(...).Unwrap()
.ContinueWith(function(content:MovieClip):void {
    trace("Loaded clip");
})
.CatchWith(function(err:*):void {
    trace("Error! Oh no! - " + err);
});

In this example the code attempts to load a config file, read a URL from it, then attempts to load a movie clip from that URL. If it succeeds in loading a movie clip then it prints “Loaded clip”. If it fails at any point along this chain, then the failure is printed.

We can also combine tasks as part of chaining. For example, maybe you want to load many files concurrently and continue once the eventual array of content is available. Writing that with events is a sick joke, especially if you want to handle errors, but it’s a breeze with tasks thanks to useful utility methods like AwaitAll:

var contentTasks : Array = new Array();
foreach (var url : String in urls) {
    contentTasks.Push(TaskInterop.Load(url));
}
TaskEx.AwaitAll(contentTasks)
.ContinueWith(function(content:Array):void {
    trace("All content loaded successfully");
})
.CatchWith(function(aggregateErr:*):void {
    trace("There was an error loading one or more files: " + aggregateErr);
});

This code loads the content in all of the URLs in an array. If they all succeed then it prints “All content loaded successfully” (the loaded content was passed in via the content parameter, but not used). If any of the content fails to load then any and all errors are printed.

For other useful task-chaining methods, see the TaskEx class.

Producing Tasks

There is a lot of functionality in ActionScript that could be exposed as a task, but is not. You can create your own methods to do the conversion, using the ‘TaskSource’ class. A TaskSource is a task, but it has methods to manually set the result (or exception). To expose some functionality as a task you just write a method that creates a new task source, registers callbacks that set its result, and returns the source to the caller as a task. For example, here’s a method that returns a task that will contain a given result but only after a specified real-time delay:

public static function Delay(delayMilliseconds : Number, result : * = null):Task {
    var r:TaskSource = new TaskSource();
    var t:Timer = new Timer(delayMilliseconds, 1);
    t.addEventListener(TimerEvent.TIMER_COMPLETE, function(e:TimerEvent):void { 
        r.SetResult(result); 
    });
    t.start();
    return r;
}

and here’s a method that invokes a web service method, returning the eventual result as a task:

public static function InvokeWebServiceMethod(ws:WebService, name:String, ... args):Task {
    var r:TaskSource = new TaskSource();
    var op:AbstractOperation = ws.getOperation(name);
    op.arguments = args;
    op.addEventListener(
        mx.rpc.events.FaultEvent.FAULT, 
        function(e:Object):void { r.TrySetFault(e.fault); } );
    op.addEventListener(
        mx.rpc.events.ResultEvent.RESULT, 
        function(e:Object):void { r.TrySetResult(e.result); } );
    op.send();
    return r;
}

I’ve implemented a few other adapter methods in the TaskInterop class.

Summary

Tasks make common use cases easier and, with chaining, allow you to tackle functionality that would be almost impossible to do correctly otherwise. I highly recommend trying them out.

View comments on reddit


Twisted Oak Studios offers consulting and development on high-tech interactive projects. Check out our portfolio, or Give us a shout if you have anything you think some really rad engineers should help you with.

Archive

More interesting posts (17 of 33 articles)

Or check out our Portfolio.