Getting some HTML5 Local Storage goodness in AS3
After the success of making Flash and Unity play nice with each other, it's time to add HTML5 to the mix. This time around: mashing LocalStorage
and SharedObjects
together!
A bit of background
At work, we're currently soft-launching a game, with Facebook as one of the platforms. We use a SharedObject
to hold some params, one of which being if you'd played the tutorial or not. Basically, if this param was set, you'd join the game normally, otherwise, we'd assume that you're a new player and start you on the tutorial.
While testing, it came out that on Safari on Mac, everytime you reloaded the page, you'd play the tutorial. Long story short, Safari has a default setting stopping 3rd party websites from setting cookies (and by extension SharedObjects
). As our Facebook app is served in an iFrame, it's considered a 3rd party, and so SharedObjects
would never get saved.
After playing around with some of the different, proposed fixes on StackOverflow, nothing was working. I could get PHP cookies to work, and the SharedObject
to work on our own domain, but I couldn't read/write them when on Facebook itself.
A friend of mine, Amaury suggested going through LocalStorage, and behold it worked!
Because, obviously, if something is bad in Flash, it's OK in JavaScript ;)
WebStorageLSO
With that in mind, and a public holiday to waste, WebStorageLSO
was born!
Basically, when we init the WebStorageLSO
class, it writes some JavaScript bridge code to handle all your CRUD desires. Then, when you want to do something, ActionScript calls JavaScript, which does all the heavy lifting.
Each WebStorageLSO
can be given a name (so you can have more than one), which is used to prefix all the keys, so there's less potential conflict with other code accessing LocalStorage
. You can also specify if you want the WebStorageLSO
to persist until you remove it, or just for the session. Session WebStorageLSOs
will last through page reloads and refreshes, but are cleared when the tab/browser is closed.
One thing to keep in mind; LocalStorage
is a simple key/value String pairing, so any value that's not a String gets automatically converted to a String when it's saved. Depending on what you're saving, this may or may not be a problem.
Related to that, there's no automatic serialisation to AMF bytecode, as you get with AS3 SharedObjects
, so you'll need to serialise any objects yourself (XML or JSON will do the trick).
Pros & cons
WebStorageLSO
isn't a straight swap for SharedObjects
, so you'll need to decide if it's good for you.
Pros:
- It works when
SharedObjects
won't - You get so much more storage by default: around 5MB (though Wikipedia says anywhere up to 25MB), which trounces Flash's 100Kb. Ask for more than than in Flash, and you get a prompt. In
LocalStorage
though, that's all you get - They're a bit more accessible than
SharedObjects
- you can edit values in any dev console - You can have session
WebStorageLSOs
, which clean themselves up when the tab is closed
Cons:
- It needs JavaScript/
LocalStorage
to work. Happily, it's supported all the way down to IE8, but you should know that users can disable it for your site (and apparently in Safari private browsing mode, it's available, but you can't save anything -WebStorageLSO
catches this case) - Values are
Strings
only, meaning serialisation/conversion needs to be done manually - You can edit values in any dev console; so either don't stock sensitive stuff, or do some sort of encrypting
- It uses
ExternalInterface
, which isn't the quickest
The code
Here's the code; you can also download it below. Also below, but which isn't necessary, is the un-minified version of the JavaScript used.
NOTE: if you're familiar with LocalStorage
, you might know that there's a StorageEvent
that gets fired when the storage is updated. I was originally going to bind it as a quick way to check if someone is tampering with the saved data, but unfortunately it only fires for other windows - i.e. if you had two tabs open, and tab A changes a value, tab B would be the only one that recevies the event.
It does fire if you modify the storage using the developer console in Chrome and IE, but not in Firefox - and I think it only does it in IE because they implemented it wrong.
This behaviour is normal, but seems a bit lame. Anyway, seeing as you probably won't be having your game open in two separate tabs, I didn't see much use for it.
The code:
package
{
import flash.external.ExternalInterface;
/**
* An interface for using a web page's LocalStorage as a local SharedObject
* @author Damian Connolly
*/
public class WebStorageLSO
{
/********************************************************************************/
private static const M_JS_IS_AVAILABLE:String = "WebStorageLSO.isAvailable"; // the js function to check if we're available
private static const M_JS_ADD:String = "WebStorageLSO.add"; // the js function to add something to web storage
private static const M_JS_GET:String = "WebStorageLSO.get"; // the js function to get something from web storage
private static const M_JS_REMOVE:String = "WebStorageLSO.remove"; // the js function to remove something from web storage
private static const M_JS_REMOVE_ALL:String = "WebStorageLSO.removeAll"; // the js function to remove everything from this lso from web storage
private static const M_EI_CALL_ERROR:String = "ExternalInterfaceCallError"; // what we return from _callJS when we couldn't call our method (as it can legitimately return null)
/********************************************************************************/
private static var m_isAvailable:Boolean = false; // is web storage available?
private static var m_hasCheckedIfAvailable:Boolean = false; // have we checked if web storage is available?
private static var m_isInited:Boolean = false; // have we inited yet (i.e. copied our js)
/********************************************************************************/
/**
* Checks if web storage is available and that we can write to it
*/
public static function get isAvailable():Boolean
{
// if we've already checked, just return
if ( WebStorageLSO.m_hasCheckedIfAvailable )
return WebStorageLSO.m_isAvailable;
// from this point on, we're considered to have checked
WebStorageLSO.m_hasCheckedIfAvailable = true;
// we need to be inited before we can do anything
if ( !WebStorageLSO.m_isInited )
{
// if we couldn't init, then just return
if ( !WebStorageLSO._init() )
{
WebStorageLSO.m_isAvailable = false;
return WebStorageLSO.m_isAvailable;
}
}
// we need external interface
if ( !ExternalInterface.available )
{
WebStorageLSO.m_isAvailable = false;
return WebStorageLSO.m_isAvailable;
}
// call our external method
WebStorageLSO.m_isAvailable = WebStorageLSO._callJS( WebStorageLSO.M_JS_IS_AVAILABLE );
return WebStorageLSO.m_isAvailable;
}
/********************************************************************************/
// called when we want to init the WebStorageLSO JS code
private static function _init():Boolean
{
// if we've already inited, return
if ( WebStorageLSO.m_isInited )
return true;
// from this point on, consider us inited
WebStorageLSO.m_isInited = true;
// we need external interface
if ( !ExternalInterface.available )
{
trace( "3:Can't init WebStorageLSO as ExternalInterface isn't available" );
return false;
}
// write our external js
if ( WebStorageLSO._callJS( WebStorageLSO.M_JS_CODE ) == WebStorageLSO.M_EI_CALL_ERROR )
{
trace( "3:Couldn't write our external JS to init WebStorageLSO" );
return false;
}
return true;
}
// calls an external js function
private static function _callJS( funcName:String, ... parameters ):*
{
// if no external interface, just return
if ( !ExternalInterface.available )
return WebStorageLSO.M_EI_CALL_ERROR;
// wrap in a try..catch as it can fail
try
{
// if we have any parameters, unshift the function name and use the apply() method
if ( parameters != null && parameters.length > 0 )
{
parameters.unshift( funcName );
return ExternalInterface.call.apply( null, parameters );
}
else
return ExternalInterface.call( funcName );
}
catch ( se:SecurityError ) { trace( "3:A security error occured when trying to call external web storage function '" + funcName + "': Error " + se.errorID + ": " + se.name + ": " + se.message ); }
catch ( e:Error ) { trace( "3:Couldn't call external web storage function '" + funcName + "': Error " + e.errorID + ": " + e.name + ": " + e.message ); }
return WebStorageLSO.M_EI_CALL_ERROR;
}
/********************************************************************************/
private var m_name:String = null; // the name for our lso
private var m_isForSession:Boolean = false;// is this for a session, or for keeps?
private var m_internal:Object = null; // our internal "lso", for speed, and in the case where WebStorage isn't available
/********************************************************************************/
/**
* Creates a new WebStorageLSO object. This lets us save key/value pairs in the browsers
* WebStorage
* @param name The name for this shared object. If non-null and non-empty, all keys will
* be prepended with this, plus a "." to avoid possible conflicts with other data in
* shared storage
* @param isForSession If this data just for the session, or for keeps. Session data will
* persist across web page reloads and restores, but will be cleared when the tab is closed. If
* false, data will persist until explicitly cleared
*/
public function WebStorageLSO( name:String = "WebStorageLSO", isForSession:Boolean = false )
{
this.m_name = ( name == null || name == "" ) ? null : name + ".";
this.m_isForSession = isForSession;
this.m_internal = new Object;
}
/**
* Destroys the WebStorageLSO, clears any info from the disk, and clears it for garbage collection
*/
public function destroy():void
{
// clear our internal cache
for ( var key:String in this.m_internal )
{
this.m_internal[key] = null;
delete this.m_internal[key];
}
// call js to remove anything external
if ( WebStorageLSO.isAvailable )
WebStorageLSO._callJS( WebStorageLSO.M_JS_REMOVE_ALL, this.m_isForSession, this.m_name );
}
/**
* Adds an item to WebStorage
* @param key The String key that we want to save our value under. It will be prepended with
* the name of the WebStorageLSO (unless null was passed)
* @param value The String value that we want to save
* @return True if we could save the item, false otherwise
*/
public function addItem( key:String, value:String ):Boolean
{
// get our real key
key = this._getKey( key );
// add it to our internal object
this.m_internal[key] = value;
// check to see if we can save it
if ( WebStorageLSO.isAvailable )
{
// call our external js
if ( !( WebStorageLSO._callJS( WebStorageLSO.M_JS_ADD, this.m_isForSession, key, value ) as Boolean ) )
{
trace( "3:Can't store a value for key '" + key + "'. This could either be because JavaScript or WebStorage aren't available, or that we've run out of space" );
return false;
}
}
else
trace( "3:Can't store value '" + value + "' with name '" + key + "' in the LSO as it's not available" );
return true; // we still saved it internally
}
/**
* Gets the String value for a specific key
* @param key The key for the value that we're looking for
* @return The String value, or null if we don't have it, or couldn't get it
*/
public function getItem( key:String ):String
{
// get our real key
key = this._getKey( key )
// if we have it in our internal lso, then just return it
if ( key in this.m_internal )
return this.m_internal[key];
// call our external js to return the object
var ret:String = null;
if ( WebStorageLSO.isAvailable )
ret = WebStorageLSO._callJS( WebStorageLSO.M_JS_GET, this.m_isForSession, key ) as String;
if ( ret != WebStorageLSO.M_EI_CALL_ERROR )
{
this.m_internal[key] = ret;
return ret;
}
return null;
}
/**
* Removes a key/value pair from web storage
* @param key The key for the key/value pair that we want to remove
* @return True if the pair was removed (or doesn't exist to be removed), false otherwise
*/
public function removeItem( key:String ):Boolean
{
// get our real key
key = this._getKey( key );
// check if we need to remove it from our internal
var isRemovedFromInternal:Boolean = false;
if ( key in this.m_internal )
{
isRemovedFromInternal = true;
this.m_internal[key] = null;
delete this.m_internal[key];
}
// try and remove it from our external
var isRemovedFromExternal:Boolean = false;
if ( WebStorageLSO.isAvailable )
isRemovedFromExternal = WebStorageLSO._callJS( WebStorageLSO.M_JS_REMOVE, this.m_isForSession, key ) as Boolean;
return ( isRemovedFromInternal || isRemovedFromExternal );
}
/**
* The String version of the LSO
*/
public function toString():String
{
return "[WebStorageLSO name: " + this.m_name + "]";
}
/********************************************************************************/
// gets the key, prepended with our name, if we have one
private function _getKey( key:String ):String
{
// if we have a name, add it
return ( this.m_name != null ) ? this.m_name + key : key;
}
/********************************************************************************/
// the js that we're going to write to the page for our methods
private static const M_JS_CODE:XML = <script>
<![CDATA[
function(){window.WebStorageLSO=window.WebStorageLSO||{isInited:false,ls:null,ss:null,isAvailable:function(){if(!this._hasStorage())
return false;try{this.ss.setItem('test','1');this.ss.removeItem('test');return true;}catch(e){return false;}
return true;},add:function(isForSession,key,val){if(!this._hasStorage())
return false;try{if(isForSession)
this.ss.setItem(key,val);else
this.ls.setItem(key,val);}catch(e){return false;}
return true;},get:function(isForSession,key){if(!this._hasStorage())
return null;return(isForSession)?this.ss.getItem(key):this.ls.getItem(key);},remove:function(isForSession,key){if(!this._hasStorage())
return false;if(isForSession)
this.ss.removeItem(key);else
this.ls.removeItem(key);return true;},removeAll:function(isForSession,keyStub){if(!this._hasStorage())
return false;if(typeof keyStub==='undefined'||keyStub===null||keyStub==='')
{if(isForSession)
this.ss.clear();else
this.ls.clear();}
else
{var s=(isForSession)?this.ss:this.ls;for(var i=s.length-1;i>=0;i--)
{var key=s.key(i);if(key.indexOf(keyStub)==0)
s.removeItem(key);}}
return true;},_hasStorage:function(){if(!this.isInited)
{this.isInited=true;try{this.ls=window.localStorage;this.ss=window.sessionStorage;}catch(e){console.error("Couldn't init WebStorageLSO: "+e);}
if(this.ls===undefined||!this.isAvailable)
{this.ls=null;this.ss=null;}}
return(this.ls!==null&&this.ss!==null);},}}
]]>
</script>;
}
}
P.S.
If anybody knows of a proper way of getting SharedObjects
working in a 3rd party domain (e.g. iFrame) in Safari, I'd love to know about it.
Updates
15/05/14:
Some quick bug fixes and updates:
- Fixed some bugs relating to knowing when calling
ExternalInterface
worked or not removeAll()
is nowdestroy()
- There's an internal
Object
that's used as a cache, so that getting values that have already been fetched is quicker
Comments
Submit a comment