Menu

Astro Android Architecture Deep Dive

Structure

  1. Introduction
  2. Event Emitter
  3. Messaging
  4. JavaScript -> Java Bridge
  5. Plugins
  6. RPC: JS -> JS
  7. Events: Java -> JS
  8. 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.

  1. An event on a plugin. This takes the form of ObjectClass:<object_hash_code>
    • Used to message the plugin directly
  2. 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
  3. 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.

Messages 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.