Astro Android Architecture Deep Dive
Structure
- Introduction
- Event Emitter
- Messaging
- JavaScript -> Java Bridge
- Plugins
- RPC: JS -> JS
- Events: Java -> JS
- Events: JS -> JS
Introduction
At a high level, Astro enables you to build great Native Android Apps using a JavaScript based API. To enable this, the lowest levels of Astro consist of an asynchronous event driven system, with richer and more convenient modes of communication layered on top.
This model is simple and flexible, and naturally suits the JavaScript event loop.
The documentation provided here will outline the flow of messages in and out of JavaScript and through the Native messaging system, including facilities for asynchronous RPC dispatch from JavaScript onto native objects and a simple event system.
The Event Emitter
The core of the native side of Astro's message passing system is an event emitter (implemented in
astro/android/src/main/java/com/mobify/astro/messaging/EventEmitter.java
) with strings as
event names, and JSON data payloads. JSON is used as a data interchange format for its ease of
serialization and deserialization in JavaScript, and other languages, as well as its human
readability.
The event emitter can registers callbacks for certain events, and can also trigger those events.
All messages in Astro are sent using our event emitter. What we call "addresses" at the messaging level, are equivalent to "event names" at the event emitter level.
Types of Events
There are a few types of events that we trigger in Astro.
- An event on a plugin. This takes the form of
ObjectClass:<object_hash_code>
- Used to message the plugin directly
- An event on a plugins "events" address. This takes the form of
ObjectClass:<object_hash_code>:events
- Used to trigger an event on a plugin that other plugins may listen to
- A global event. This takes the form of
GlobalEventAddress:<event_name>
- Used to trigger a global event that any code may listen to
The event emitter API is quite minimal, there are two primary methods for interacting with it:
on(String eventName, JSONCallback eventHandler)
and
trigger(String eventName, JSONObject eventData)
The on()
method registers an event handler, a subclass of JSONCallback that implements a
run(JSONObject eventData)
method, that will be invoked when the trigger()
method is called with
the same event name.
The data passed as eventData
will also be passed to the event handler. The passed data should be
considered immutable, as it may be passed to other handlers.
The event emitter maintains a list of handlers for each event, and a hash map from event names to the list of handlers.
There is one last method for manipulating the event emitter:
off(String eventName, JSONCallback callback)
This removes the specified handler from the event emitter, so that subsequent events with that name will no longer invoke the specified callback.
On Threading
The event emitter defines an abstract subclass of JSONCallback, called MainThreadJSONCallback.
This allows the developer to insist that the callback they provide be run on the main thread, by
overriding the runOnMainThread(JSONObject data)
method, rather than run(JSONObject data)
.
This is useful for callbacks that will manipulate the UI, as on Android, that must be done from code running in the UI thread. All of the message passing discussed later is dispatched on the main thread for this reason.
Finally, EventEmitter
has been designed in a thread safe way: mutations of the event handler map
and handler lists are synchronized, and event triggering operates on a copy of the relevant handler
list, so it should not block subsequent event dispatch triggered from other threads, or as a
consequence of its handlers.
Messaging
We build an asynchronous messaging layer on top of the event emitter by adding few addressing concepts and some base classes for dispatching messages.
There are four kinds of messages in Astro, most of which are specializations of a base Message.
Diagram:
Message
|
|
V
SenderMessage
/ \
/ \
/ \
V V
RpcMessage EventMessage
/\
/ \
/ \
V V
RpcRequest RpcResponse
Message
A message object provides a loose schema for sending JSON data through the system.
Message
s can have headers: string key/value pairs that will be serialized into the top level of a
JSON object, and are used for messaging system functionality. Messages
also have a special key
named payload
, whose value is the data payload of the message.
The Message
class has a corresponding MessageListener
abstract base class that objects receiving
messages inherit from. It can be found at astro/android/src/main/java/com/mobify/astro/messaging/MessageListener.java
.
It has a method for deriving a unique address for the object,
String getInstanceAddress(AddressableObject)
, implementing the
com.mobify.astro.messaging.AdressableObject
interface, a method for setting up message listening,
void listenForInstanceEvents()
, and a method for message dispatch,
void onMessage(String address, Message message)
.
The address is used as an event name in the event emitter, and the message dispatch method
void onMessage(String address, Message message)
will be called by an event handler, registered with the event
emitter, that will process the JSON data coming out of the event emitter into an appropriate
Message
or Message
subclass instance, depending on its content.
A Message
can be sent by calling its sendTo(String address)
method with an address
SenderMessage
SenderMessage
extends the Message
model, adding a header named senderAddress
that identifies
the object that sent the message. It's a small change, but it enables things we built on top of
it.
SenderMessage has an associated SenderMessageListener
abstract class that extends from
MessageListener
. It requires that extenders implement a
void onMessage(String address, SenderMessage senderMessage)
method, and implements
void onMessage(String address, Message message)
that checks if the message is in fact a
SenderMessage
, and calls void onMessage(String address, SenderMessage senderMessage)
with the
message, but downcast to SenderMessage
, otherwise (e.g. the case of a simple Message
instance) it
does nothing.
RpcMessage
, RpcRequest
and RpcResponse
Building on SenderMessage
, we can implement a simple request/response messaging pattern that is
useful for representing asynchronous remote procedure calls. In Astro these calls aren't remote in
the sense of happening on a different machine, but they are procedure calls between language
environments, specifically from JavaScript out to native code.
We add an additional header called id
, an opaque identifier used to track request/response
pairs.
RpcMessage
RpcMessage
is an abstract base class that adds a couple of utility methods for getting and setting
the id
identifier.
RpcRequest
RpcRequest
builds on RpcMessage
by adding two fields to represent the details of sending an RPC
request: method
, a string naming the method to be invoked and params
, a dictionary of parameter
names and their values. This is loosely inspired by the
JSON RPC specification with the notable difference that
parameters are specified in the form of a dictionary of key/value pairs, rather than a list of
ordered values. This choice was made to facilitate debugging and human readability of the messages.
Serialized Example:
{
"id": "11",
"senderAddress": "AstroWorkerPlugin:0",
"payload": {
"method": "navigate",
"params": {
"url": "http:\/\/www.mobify.com"
}
}
}
RpcResponse
RpcResponse
also builds on RpcMessage
. It adds no headers, but its payload has a particular
structure:
"payload": {
"result": <the result, a primitive or a JSON Object>
"error": <An object representing an error condition, null if there was none>
}
Serialized Example
{
"id": "11",
"senderAddress": "WebViewPlugin:428081806",
"payload": {
"result": null,
"error": false
}
}
RpcMessageListener
Objects wishing to respond to RPC requests should inherit from the RpcMessageListener
class. It
provides facilities for receiving RPC requests, unpacking their parameters and dispatching
synchronously, or asynchronously to another method defined on the class.
In the case of synchronous dispatch, the method will be called and its return value, or any resulting exceptions, will be packaged into a response and sent.
In the case of asynchronous dispatch, the method will be called, but its first argument will be an
RpcResponse
object set up to reply to the corresponding request. This object can be passed
around until the request can be fulfilled, and then sent by passing a result to its send()
method.
Alternatively, an exception can be passed to send()
to indicate that an error has occurred. These
values will be appropriately placed in the JSON, and the response will be sent to the sender of the
corresponding RpcRequest
.
RpcMessageListener extends from SenderMessageListener. It implements
void onMessage(String address, SenderMessage)
which checks if the message is an
RpcRequest
. If so it calls void onMessage(String address, RpcRequest rpcRequest)
with the
message, but downcast to RpcRequest
, otherwise (e.g. the case of a SenderMessage
instance) it
does nothing. The implementation of void onMessage(String address, RpcRequest rpcRequest)
then
performs dispatch of the RpcRequest
as described below.
@RpcMethod
and @AsyncRpcMethod
annotations
@RpcMethod
To mark a synchronous method as callable through the RPC mechanism, it should be annotated with
the @RpcMethod
annotation. This requires that you provide a string methodName
and an array
literal of strings parameterNames
, e.g:
@RpcMethod(methodName = "setContentView", parameterNames = {"address"})
public void setContentView(String address) { ... }
@AsyncRpcMethod
To mark an asynchronous method as callable through the RPC mechanism, it should be annotated with
the @AsyncRpcMethod
annotation, this annotation takes the same parameters as @RpcMethod
, but the
method itself should take a RpcResponse
instance as its first argument:
@AsyncRpcMethod(methodName = "setLeftIcon", parameterNames = {"url"})
public void setLeftIcon(RpcResponse response, String url) { ... }
Details of RPC Dispatch
When RpcMessageListener
instances are constructed, the annotations are validated by checking that
their number of arguments agree with the method's. After that, a mapping is built from the
"methodName" parameters to the implementing methods.
When an RpcRequest
is received by an object, the method is looked up by name, a response object is
created, the parameters are unpacked into a list and ordered as they are in the annotation and the
method is invoked using reflection.
In the case of synchronous dispatch, the return value of the method, or any exceptions thrown as a result of its invocation, are set on the response, and it is sent immediately following the method call.
In the case of asynchronous dispatch, the method is called with the prepared RpcResponse
object as
its first argument. The method's implementer is responsible for ensuring that the
response is sent at a later time. Asynchronous RPC methods are useful for wrapping any process that
might not finish immediately, such as network access, or computationally intensive tasks that should
be kept off the application's main thread.
If any arguments are missing, this results in an error RpcResponse
being sent, and no attempt is
made to call the method. In the case of a type mismatch between the parameters provided in the
request and the arguments of the implementing method, an exception will thrown by the reflection
API, and will in turn be serialized as part of an error response.
EventMessage
EventMessage
builds on SenderMessage in a similar way to RpcMessage
/RpcRequest
, adding two
headers: eventName
, a string, and params
, a dictionary similar to the params
used in
RpcRequest
. The difference is primarily that there is no id
, and no corresponding response.
Serialized Example
{
"senderAddress": "WebViewPlugin:133098902",
"payload": {
"eventName": "pageRendered",
"params": {}
}
}
Events are one-way messages that may be received by several receiving objects. They are sent to a special address, consisting of the sender's address followed by the string ":events". Other objects interested in receiving these events must subscribe to them. There is also a special case of global events, which don't originate from any particular object in Astro's plugin model, and represent system events.
The use of Events will be covered in a later section.
JavaScript -> Java Bridge
On Android, the JavaScript to Java bridge is implemented using the Android WebViews's
@JavaScriptInterface
annotation. A single method taking and address a payload as arguments, named
Astro.exec(address, messageString)
is exposed to JavaScript.
The native side implementation of this deserializes the string messageString
into a JSONObject
and feeds this object into a factory that constructs a Message
or Message
subclass instance of
the appropriate type, depending on what headers are present and the form of the payload (e.g. the
presence of "methodName"
and "params"
keys in the payload or an "id"
header). This message is
then immediately sent to the address specified.
The dispatch of this message, along with the previously described RPC and eventing infrastructure, enables interoperability between JavaScript code running in a web view and the native parts of the system.
Plugins
Astro provides a plugin based architecture for Native features. A plugin will generally consist of
at least one Native class, a javascript proxy for asynchronously calling methods of instances of
that class and receiving events from them. Plugins are initialized, and can communicate through, the
PluginManager
.
All Native plugin classes descend from the AstroPlugin
base class, which provides the
aforementioned RPC Messaging behaviour to all methods annotated with @RpcMethod
or
@AsyncRpcMethod
, the ability to send events, and hooks for accessing views, if the plugin provides
a native view.
Plugins are initialized by calling createPlugin(String pluginName)
on the singleton instance of
the Plugin Manager, passing the name of the plugin class that should have an instance created. This
will initialize the plugin instance, compute its "address" in our messaging scheme, and inserts it
into a table mapping address strings to plugin instance references, then returns that address to the
JavaScript caller.
This table of references is used when plugins need to communicate with each-other entirely on the native side, e.g.. to set up a hierarchy of native views.
The plugin manager also has a method register(Class<? extends AstroPlugin> astroPlugin)
, that must
be called on each plugin class during application initialization to initially register plugin
availability.
RPC: Javascript to Javascript
We have added the ability to use our JavaScript RPC client code to invoke code running in a different JavaScript context. The JavaScript bridge code intercepts messages going through the regular message dispatch chain, and if they are flagged as being a JS RPC request, they are sent into JavaScript, and dispatched there. The responses are similarly dispatched. At present this mechanism only works for requests sent from WebViewPlugin instances and directed at the worker to avoid the complications of needing to validate that they can be dispatched or not.
Events: Java to JavaScript
Native plugins inherit from the AstroPlugin
abstract base class which exposes a void triggerEvent(String eventName, JSONObject params)
method, which crafts an event method that is sent to the events
address of the plugin, which is the plugin's address followed by ":events"
. On initialization,
the plugin's JavaScript proxy causes the worker to listen on this address. These events are also
relayed into JavaScript on receipt and not dispatched further natively.
Events: JavaScript to JavaScript
The astro-client.js
library can be loaded into WebViewPlugin web views, and exposes a method
Astro.trigger(eventName, params)
. When called this method will cause the WebViewPlugin to produce
a message just as in the above Java to JavaScript case, which is dispatched identically by the
worker. This also implies that events triggered from native plugins are indistinguishable from
events triggered by JavaScript running int he context of a web view in a WebViewPlugin
.
Conversely, the WebViewPlugin JavaScript plugin proxies each expose a trigger(eventName, params)
method which will relay the related event message into JavaScript in their web view. These events
can be listened for by a call to Astro.on(eventName, callback)
in JavaScript inside the web view.
Messages to be dispatched in the web view of a WebViewPlugin instance will be queued while the WebView is in a loading state, and flushed when it has loaded, though no guarantee of the readiness of the JavaScript in the web view to receive messages is provided.