Skip to content

how to write an extension

jhugman edited this page Dec 1, 2012 · 1 revision

The pattern we have found works quite well is to have an Extension.js tightly bound with an ExtensionBackend.java, in this case it will be DemoPreferences.js and a corresponding DemoPreferencesBackend.java. DemoPreferences.js can talk to any javascript it likes, but the only native object it will communicate with directly is its native backend DemoPreferencesBackend.java.

Application code sample

So here is the beginning of the DemoPreferences.js module. This is the main interface for application developers. Typical usage may be:

//MyApplicationLogicUsingPreferences.js
// require is a CommonJS concept.
var prefs = require("DemoPreferences");
    
var url = prefs.get("the.url");
    
prefs.put("first.time.run", true);
prefs.put("username", username);
prefs.commit();

    
prefs.onUpdate("username", function (key, newUsername) {
    // the username has changed.
});

Starting out with an extension

Let's start with the code sample above:

//DemoPreferences.js
function DemoPreferences () {
}
    
// Singleton pattern. module.exports is CommonJS concept.
module.exports = new DemoPreferences();

var instance = DemoPreferences.prototype,
    _ = require("underscore");
    
/*
 * Lifecycle methods.
 * These will be called by the extension.
 */
instance.function (nativeObject) {
    // The native object can be called with this object
    var self = this;
    self.backend = nativeObject;
    self.storage = {};
};

Notice:

  • This is a single javascript file, that declares a CommonJS module.
  • We need a singleton so we can call methods like prefs.put(). We could've set functions on exports directly, but we've gone for the little bit more code. It doesn't make too much of a difference.
  • onLoad will be called when the extension is loaded.
  • The onLoad method takes a native object parameter. We'll keep it in this.backend.
  • We've declared a map called this.storage.

Let's start implement some of the methods called in MyApplicationLogicUsingPreferences.js:

//DemoPreferences.js
instance.get = function (key) {
    return this.storage[key];
};
    
instance.put = function (key, value) {
    // simplest thing that could possibly work
    this.storage[key] = value;
};

Fairly straightforward stuff, but we'll need to be able to persist the contents of this.storage to disk every so often. In Android, there is a commit method, so let's go with that. On iOS, the corresponding method is called synchronize. The semantics are slightly different, though not enough to change much of our usage day to day. We'll implement that commit a bit later in this post.

For now, let's think about how we're getting an existing preferences in a android.preferences.SharedPreferences object into javascript. This should probably done fairly close to the startup of the app, so let's start with a javascript method that the native backend will call a method in KirinPreferences.js with a javascript object full of preferences. Let's call it mergeOrOverwrite.

Formalizing the bridge communication

So far, we've got a DemoPreferences class in Javascript, and it has a backend, but hasn't called any methods on it yet. We'd like to call a mergeOrOverwrite method in javascript from native. We can encode what we've got so far in an IDL.

//preferences.idl.js
module.exports = {
    namespace: "org.kirinjs.example.generated.preferences",
    location: "extensions/preferences",
    classes: {
        "DemoPreferences": {
            docs: "mergeOrOverwrite called at onLoad() time",
            implementedBy: "javascript",
            methods: {
                "mergeOrOverwrite": [{"latestNativePreferences" : "object"}]
            }
        },
        "DemoPreferencesBackend": {
            docs: "Responsible for keeping preferences in javascript and native synchronized",
            implementedBy: "native",
            methods: {
            }
        }
    }
};

Running the build script now will cause a number of files to be generated:

  • a javascript file: extensions/preferences/stubs/DemoPreferences.stub.js, containing a stub implementations of the real DemoPreferences.js. This is intended to copy into DemoPreferences.js file.
  • a Java interface: org.kirinjs.example.generated.preferences.DemoPreferencesBackend with methods that the backend must implement.
  • a Java interface: org.kirinjs.example.generated.preferences.DemoPreferences which will represent the javascript module, but callable from Java.

Initialising this.storage with SharedPreferences.

Let's copy over the method stub from DemoPreferences.stub.js, and implement the mergeOrOverwrite method in DemoPreferences.js

//DemoPreferences.js
instance.mergeOrOverwrite = function (latestNativePreferences) {
    this.storage = _.extend(this.storage, latestNativePreferences);
};

Next, let's start the Android backend.

public class DemoPreferencesBackendImpl extends KirinExtensionAdapter implements DemoPreferencesBackend {
    
    private SharedPreferences mPreferences;

    private DemoPreferences mModule;
    
    // constructors
    public DemoPreferencesBackendImpl(Context context) {
        this(context, null);
    }

    public DemoPreferencesBackendImpl(Context context, SharedPreferences preferences) {
        super(context, "DemoPreferences");
        if (preferences == null) {
            preferences = PreferenceManager.getDefaultSharedPreferences(context);
        }
        mPreferences = preferences;
    }
    
    @Override
    public void onLoad() {
        super.onLoad();
        // This give us a Javascript module in Java.
        mModule = this.bindExtensionModule(DemoPreferences.class);

        // Now we call the method we just wrote.
        mModule.mergeOrOverwrite(new JSONObject(mPreferences.getAll()));
    }
    
    // ...
}

Notice:

  • The class extends KirinExtensionAdapter. This will provide some lifecycle, including calling corresponding lifecycle methods in the javascript module, and a couple of utility methods.
  • The class implements DemoPreferencesBackend. This will be an interface to mirror the methods in the IDL.
  • The class runs off the UI thread. That is to say – all methods are executed on a non-main thread and it is possible that methods are run in different threads concurrently. We could've implemented IKirinExtensionOnUiThread or even IKirinExtensionOnNonDefaultThread if our threading requirements demanded it.
  • The javascript module name DemoPreferences when calling the super constructor. Configuration notice: There's an extra step if you want to build a library of kirin extensions.
  • The mModule instance variable, of type DemoPreferences. This is the javascript module which we're writing in DemoPreferences.js.
  • mModule is set in onLoad, and becomes the object with which we will call the DemoPreferences.js methods.
  • We're calling mModule.mergeOrOverwrite() method, calling the corresponding method in DemoPreferences.js.

For extensions only:

Since we're making an extension, we need to tell Kirin. You should have an Application that implements IKirinApplication. The easiest way to do this is to make you Application extend KirinApplication. Adding the extension can be done in the onCreate() method:

@Override
public void onCreate() {
    super.onCreate();
    getKirin().getKirinExtensions().registerExtension(new DemoPreferencesBackendImpl(this));
}

For things that Android manages – like screens and fragments – we don't need to do this.

Implementing commit

Keeping things boneheadedly simple, let's make the commit write back the whole of the object we have in javascript. In reality, we'd worry about deltas and deletes.

//DemoPreferences.js
instance.commit = function () {
    var self = this;
    self.replacePreferences(self.storage);
};

Let's add a method to DemoPreferencesBackend in the IDL:

    //preferences.idl.js
    "DemoPreferencesBackend": {
        docs: "Responsible for keeping preferences in javascript and native synchronized",
        implementedBy: "native",
        methods: {
            "replacePreferences": [{"newPreferences":"object"}]               
        }
    }

If you run the build script again, you'll find that your implementation DemoPreferencesBackendImpl.java will be showing a compile time error, about not implementing all the methods in DemoPreferencesBackend.java.

Disclaimer: I should emphasise that the actual implementation here is presented for illustrative purposes. For real extensions, there is a little more complexity dealing with updating old values, removing keys etc. We would want to try and minimise traffic across the bridge, and keep code complexity in the Javascript, to keep the native parts as simple as possible.

Back in the backend:

    //DemoPreferencesBackendImpl.java
    @Override
    public void replacePreferences(JSONObject newValues) {
        try {
            Editor editor = mPreferences.edit();
    
            for (Iterator<String> iterator = newValues.keys(); iterator.hasNext();) {
                String key = iterator.next();
                Object value = newValues.get(key);
    
                if (value instanceof String) {
                    editor.putString(key, (String) value);
                } else if (value instanceof Boolean) {
                    editor.putBoolean(key, (Boolean) value);
                }  else if (value instanceof Integer) {
                    editor.putInt(key, (Integer) value);
                }  else if (value instanceof Float) {
                    editor.putFloat(key, (Float) value);
                }  else if (value instanceof Long) {
                    editor.putLong(key, (Long) value);
                }
            }
    
            editor.commit();
        } catch (JSONException e) {
            Log.e(C.TAG, "JSONException while committing preferences", e);
        }            
    }

Notice:

  • Type detection being ugly. It would be much simpler if Android had a simple Object get(String key) and void put(String key, Object value) method.

Review

We should now have get, put and commit to and from a persistent key value storage.

We are able to get a preferences back from previous runs of the app.

We have seen simple communication between javascript and native and back again, in a type safe manner.

Listening for preference updates

Now it would be nice to be able to register update listeners, as in the MyApplicationLogicUsingPreferences.js above.

I'll use the node.js events.EventEmitter to manage the listeners that may come in, but perhaps I will add an onUpdate method for Javascript to register functions on update of the preferences, from either javascript calling it, or it being changed by an android.preferences.PreferenceScreen.

We'll want to have two things, a preference listener to register with native, and a method of registering an interest in particular keys, both natively, and in javascript.

Let's start by writing the public interface for the javascript module.

//DemoPreferences.js
instance.onUpdate = function (key, callback) {
    var = self;
    self.backend.listenFor(key);
    self.emitter.on(key, callback);
};

Let's also decide what the bridge DemoPreferenceListener is going to look like. We'll declared it in javascript, and pass it from javascript to native. We've called this declared-in-one-language-passed-to-the-other-language "bridge-type".

Let's add a new class to the IDL:

    //preferences.idl.js
    "DemoPreferenceListener": {
        docs: "This is originates in Javascript, but is passed to native. Calling methods from native will call the corresponding js method",
        role: "request",
        
        methods: {
            "onPreferenceChange": [{"preferenceKey": "string"}, {"newValue": "any"}],
            "onFinish": []
        },
        
        validation: {
            mandatory: ["onPreferenceChange", "onFinish"]
        }
    }

Notes:

  • Bridge types come in two flavours: declared in javascript/used in native called "request" and; declared in native/used in javascript, called "response".

Of course, we should also be able to tell the DemoPreferencesBackend to start listening and relaying pertinent information to us. So let's add some methods to the backend's IDL.

    //preferences.idl.js
    "DemoPreferencesBackend": {
        docs: "Responsible for keeping preferences in javascript and native synchronized",
        implementedBy: "native",
        methods: {
            "replacePreferences": [{"newPreferences":"object"}],
            "addPreferenceListener":[{"listener":"DemoPreferenceListener"}],
            "listenFor": [{"preferenceName":"string"}]                
        }
    }

Notice:

  • the method signature is always a void return.
  • the parameters are declared as a list of parameter objects. The form of the parameter object is: {"parameterName":"type"}.
  • you can use types you have declared in the same file.
  • I've included an listenFor(preferenceName) method because I know that iOS isn't able to register interest in bulk, and can only observe KeyValue changes on individual keys.

Let's go back to DemoPreferences.js, specifically the onLoad method:

//DemoPreferences.js
instance.onLoad = function (nativeObject) {

    var self = this,
        events = require("events"),
    PreferenceListener = require("./DemoPreferenceListener");

    // The native object can be called with this object
    self.backend = nativeObject;
    self.storage = {};
    self.emitter = new events.EventEmitter();

    self.backend.addPreferenceListener(new PreferenceListener({
        onPreferenceChange: function (preferenceName, newValue) {
            self.storage[preferenceName] = newValue;
            self.emitter.emit(preferenceName, newValue);
            return true;
         },
     
         onFinish: function () {
             // NOP
             return false;
         })
    });
};

Notice:

  • Requiring the events module. This will provide us with the EventEmitter object.
  • Requiring the DemoPreferenceListener module, and that it is a single constructor.
  • Construction of the listener, using an object containing all the methods that will be called by native.
  • The return values of the methods of the listener. In the event of a falsey return value from any of these methods, kirin will discard the listener, and native will not be able to call it again. Because we are going to be listening over multiple calls of the onPreferenceChange method, we will want to keep the listener object around for a little while.
  • Once the backend has finished with the listener, it should call onFinish, and kirin.js can dispose of the object. The onFinish method is the only one to return falsey.

We'll add a pretty bone-headed implementation for Android. This is only for illustration purposes only.

//DemoPreferencesBackendImpl.java
private DemoPreferenceListener mJavascriptPreferenceListener;

@Override
public void addPreferenceListener(DemoPreferenceListener listener) {
    mPreferenceListener = listener;
    mPreferences.registerOnSharedPreferenceChangeListener(new OnSharedPreferenceChangeListener() {
        
        @Override
        public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
            mJavascriptPreferenceListener.onPreferenceChange(key, sharedPreferences.getAll().get(key));
        }
    });        
}

@Override
public void listenFor(String preferenceName) {
    // NOP
}

@Override
public void onUnload() {
    mModule.onFinish();
    super.onUnload();
}

Notice:

  • addPreferenceListener is declared in the DemoPreferencesBackend.java. It accepts a DemoPreferenceListener listener. It is stashed for later use as mJavascriptPreferenceListener
  • The underlying mPreferences object registers its own OnSharedPreferenceChangeListener.
  • When a preference change has been detected, the DemoPreferenceListener listener we were passed is informed, as if it were a Java object.
  • This is an inefficient implementation because every change is reported to javascript, causing unnecessary javascript to be executed. This could be mitigated by keeping track of keys that javascript has asked the backend to listenFor.
  • This is a seriously inefficient implementation, depending on the implementation of SharedPreferences.getAll(). In reality, would want to get the value straight from the sharedPreferences object. It is made much harder because we don't know the type of the value, so we don't know which get method to use. We should probably use an Accessor pattern.
  • During the onUnload part of the extension's lifecycle, the onFinish method of the mJavascriptPreferenceListener is called. This will cause kirin.js to dispose of the listener object so it is no longer available.

Review

We have described an implementation of DemoPreferences accessible from javascript, implemented in Android.

We have discussed:

  • Using the IDL to generate the interfaces that make type-safe calling possible.
  • Using the IDL to generate bridge-type objects that can be passed to native, which native can call methods upon
  • Cleanup of those callback bridge-type objects.

I hope I've also demonstrated:

  • There is no code pollution in the javascript code, and only a little in the Android. I have written what appears to be idiomatic node.js flavoured javascript and idiomatic Android/java.
  • We can now take the javascript and the IDL we used to generate the Android implementation and use it to generate protocols in iOS.