AS3 tricks 2: singleton for settings
Trick number 2: creating a singleton to load, hold, and apply settings for our AIR application.
In order to create a global settings class, ie one that can modify properties of both the WindowedApplication's children and the SWFLoader's children, I needed a singleton. A singleton is a class that can only have a single instantiation. So to prevent multiple instances the instantiation would normally be private, disallowing other classes from creating any more copies of the class. However, actionscript does not allow the declaration "private" to be applied to the instantiator. So I found this neat trick to get around that. Summarizing that page, the singleton is not accessed by the normal method of creating an instance and then calling functions and reading properties. Instead, classes access the singleton through "[Singleton].instance", which returns a reference to the one and only instance of the singleton. If the singleton does not yet exist when the .instance method is called it will instantiate itself, otherwise it will use the single instance.
So how does this work in practice? Here's my applied version of the singleton:
package singletonExample
{
import flash.events.*;
import flash.net.*;
import flash.utils.*;
public class Settings extends EventDispatcher
{
// singleton, use accessor Settings.instance to call methods
private static var _instance:Settings;
public static function get instance():Settings {
if (_instance == null) _instance = new Settings(new SingletonEnforcer());
return _instance;
}
private var _settingsLoader:URLLoader; // loader for settings xml
private var _settingsXML:XML; // XML for content xml
private var _settingsAvailable:Boolean; // false while loading xml, can be checked via getter
public function Settings(enforcer:SingletonEnforcer):void {
super();
}
public function get settingsAvailable():Boolean {
if (_settingsAvailable) return true;
else return false;
}
public function retrieve(_item:String):String {
if (!_settingsAvailable) return null;
if (_item == "all") return _settingsXML.toXMLString();
if (_settingsXML[_item] != null) return _settingsXML[_item].toString();
else return null;
}
public function store(_item:String, _val:String, _parent:String = ""):Boolean {
if (!_settingsAvailable) return false;
try {
if (_parent != "") _settingsXML[_parent][_item] = _val;
else _settingsXML[_item] = _val;
trace ("Settings: store", _item, _val, _parent);//_settingsXML.toXMLString());
return true;
} catch (err:Error) {
trace ("Settings: Error modifying Settings("+_item+", "+_val+", "+_parent+"):", err);
}
return false;
}
public function init(_settingsSource:String = "assets/settings.xml"):void {
trace ("Settings: Initializing");
_settingsAvailable = false;
_settingsLoader = new URLLoader();// loader for settings xml
_settingsLoader.removeEventListener(Event.COMPLETE, settingsLoadComplete);
_settingsLoader.removeEventListener(IOErrorEvent.IO_ERROR, catchIOError);
_settingsLoader.addEventListener(Event.COMPLETE, settingsLoadComplete);
_settingsLoader.addEventListener(IOErrorEvent.IO_ERROR, catchIOError);
_settingsLoader.load(new URLRequest(_settingsSource));
}
public function applySettings(target:Object, proxyName:String = ""):Boolean {
if (!_settingsAvailable) {
trace ("Settings: Not initialized. Call Settings.instance.init() and verify that /assets/settings.xml exists.");
return false;
}
if (!target.hasOwnProperty("name") && (proxyName == "")) {
trace ("Settings: applySettings: no name");
return false;
}
if ((proxyName == "") && (target.hasOwnProperty("name"))) proxyName = target["name"];
trace ("Settings: proxyName set: ", proxyName);
var tempSettings:XMLList = _settingsXML.child(proxyName).children();
if (target is XML) with (target as XML) {
delete target.*;
if (_settingsXML.child(proxyName).length() != 0) {
for each (var tempXML:XML in tempSettings) {
target.appendChild(tempXML);
}
}
return true;
}
if (_settingsXML.child(proxyName).length() != 0) {
for each (var tempSetting:XML in tempSettings) {
trace ("Settings: setting", tempSetting.name().toString(), "to", tempSetting.toString());
if (tempSetting.name().toString() in target) {
try {
target[tempSetting.name().toString()] = tempSetting;
trace ("Settings: set", proxyName+"."+tempSetting.name().toString()+" to "+tempSetting );
} catch (err:Error) {
trace (err);
}
}
}
}
return true;
}
private function settingsLoadComplete(evt:Event):void {
trace ("Settings: settingsLoadComplete()");
_settingsXML = new XML(evt.target.data);
_settingsAvailable = true;
this.dispatchEvent(new Event(Event.COMPLETE));
}
private function catchIOError(err:IOErrorEvent):void {
trace ("Settings: IO Error loading settings:", err + ", generating new empty xml to store settings in.");
_settingsXML = <settings />;
_settingsAvailable = true;
}
}
}
internal class SingletonEnforcer {}
The nice thing about this method of construction is that if you were to try to instantiate one in another class (using "import singletonExample.*" and then "var mySettings:Settings = new Settings();") Flex Builder will bug you for a parameter to pass to the instantiator, one that you can't provide, the SingletonEnforcer. This is located outside the package in the same file as the singleton and can only be used within the package. I've heard you can instantiate by passing "null" but the solution is to check for null within the constructor and I'd rather get the IDE error ("Incorrect number of arguments" for Settings() or "Implicit coercion" error for "Settings(true) or Settings("hello")) than have a default value for the constructor and then check for it.
Using the singleton: So now that we have a singleton for Settings, how do we use it? Well, Settings.init() will attempt to load the file "assets/settings.xml". While loading this file, all attempts to use Settings will respond with a trace letting you know that Settings is still setting up. Once loaded, Settings will broadcast an event letting all listeners know that the new settings are ready for use. They can then use Settings.applySettings() to apply the settings to their objects.
Here's an example:
- Your Main class imports the package containing Settings
- Main inits Settings; this "first action" to Settings.instance will instantiate Settings because it doesn't yet exist:
Settings.instance.init(); - Main adds an event listener to respond to new settings becoming available:
Settings.instance.addEventListener(Event.COMPLETE, settingsLoaded); - Main responds to Settings.Event.COMPLETE by applying settings to its children:
function settingsLoaded(evt:Event):void {
trace ("Main: settings loaded");
Settings.instance.applySettings(child1);
Settings.instance.applySettings(child2, "style1");
}
And that's it! Now you can create the xml file with settings for these objects; let's use the above example code (child1 and child2, assuming child1 is an object with the name "child1"):
<?xml version="1.0"?>
<settings>
<child1>
<visible>false</visible>
<x>50</x>
<y>122</y>
</child1>
<style1>
<color>0xFF0000</color>
<myCustomProperty>This string will be passed to your myCustomProperty setter</myCustomProperty>
</style1>
</settings>Save this as "assets/settings.xml" and you can now assign defaults for your objects as xml rather than editing your source code. You can also reload the settings any time you want by calling Settings.instance.init(), which will rebroadcast the load complete, telling your listeners to reapply the settings.
There are a few things happening here worth mentioning. Note that we are not explicitly looking for values to set in Settings.applySettings. Instead, we create an XMLList consisting of all the children of the target's name or, alternately, the proxy name. The proxy name was initially included to support objects that do not have a "name" property but I've come to realize that it's a mighty convenient way to create a style to be applied to multiple objects with different names. Settings then takes the XMLList and, one at a time, checks for the existence of that XML element's name in the target. If it exists, it assigns the contents of the XML element to the target's property and, if it doesn't exist, it goes on to the next element. So we can modify any properties of the target, built in or custom, so long as they have a setter. Oh, and it's worth mentioning that Settings.init() has an optional parameter of "source", so if you don't want to use "assets/settings.xml" as your source you can call init on a different source. Probably useful, too, for loading variations of settings at runtime, but remember that this is not assigning values to every property; it's only applying properties that are in the settings file, so if a change is made to a property in one settings file and then no change is made in the second settings file you'll be looking at the property from the first file. So once you're happy with your settings be sure to do a clean start using only that settings file to verify that you're not missing any property values...
And that's all! Now we have a singleton xml-driven property modifier that will tell everyone it's ready to apply values and can load new defaults at runtime.