-
Notifications
You must be signed in to change notification settings - Fork 22
how to write an extension
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
.
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.
});
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 onexports
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 inthis.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
.
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 realDemoPreferences.js
. This is intended to copy intoDemoPreferences.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.
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 evenIKirinExtensionOnNonDefaultThread
if our threading requirements demanded it. - The javascript module name
DemoPreferences
when calling thesuper
constructor. Configuration notice: There's an extra step if you want to build a library of kirin extensions. - The
mModule
instance variable, of typeDemoPreferences
. This is the javascript module which we're writing inDemoPreferences.js
. -
mModule
is set inonLoad
, and becomes the object with which we will call the DemoPreferences.js methods. - We're calling
mModule.mergeOrOverwrite()
method, calling the corresponding method inDemoPreferences.js
.
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.
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)
andvoid put(String key, Object value)
method.
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.
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
, andkirin.js
can dispose of the object. TheonFinish
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 theDemoPreferencesBackend.java
. It accepts aDemoPreferenceListener listener
. It is stashed for later use asmJavascriptPreferenceListener
- The underlying
mPreferences
object registers its ownOnSharedPreferenceChangeListener
. - 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, theonFinish
method of themJavascriptPreferenceListener
is called. This will causekirin.js
to dispose of the listener object so it is no longer available.
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.