Writing Astro Plugins
Astro provides the idea of a "plugin", which is a combination of native code and JavaScript running in the "app" that allows you to initialize and co-ordinate state changes in the native portion of the UI from JavaScript.
A typical plugin consists of three pieces:
- A JavaScript class (mostly a proxy object)
- An iOS class that inherits from
Astro.Plugin
- An Android class that inherits from
com.mobify.astro.AstroPlugin
Both native plugin implementations will conform to the same set of methods and events that the JavaScript plugin class uses. The core concepts for building an Astro plugin is the same whether you are working on iOS or Android, but the class names and conventions differ slightly by platform.
An Astro plugin communicates between the JavaScript plugin instance and native plugin instance using the "bridge". This is a message bus that can deliver messages from JavaScript to native and back.
JavaScript
The first step in building an Astro plugin is to define the API that the
JavaScript class will expose. Astro plugins are built as JavaScript modules that
export a single constructor function (class). Plugins should attach a property
to the constructor function named 'pluginName' that is the same as the
constructor function (eg. SharingPlugin.pluginName = 'SharingPlugin';
). This
property provides the name that the native side will use to resolve the
associated native plugin class (resolution differs between iOS and Android...
the simplest way to keep everything correct is to use the same class
name for all pieces of a plugin: JavaScript, iOS, and Android).
Initialization
All Astro Plugins must implement an init
method. This method can take parameters
if the plugin has dependencies. Typically the init
method will delegate directly
to the PluginManager.createPlugin
method to create the native plugin counterpart.
An example init
:
MyPlugin.init = function(options, callback) {
return PluginManager.createPlugin(MyPlugin, options, callback)
}
In some cases a plugin is purely JavaScript. This could be because the plugin
simply composes multiple plugins or it is a feature that can be implemented
purely in JavaScript (maybe access to local storage). In this case the init
method can just delegate directly to the constructor function. However, because
init
must return a Promise, you will need to wrap the constructed object
in a Promise manually. Eg.
const MyPlugin = function() {}
MyPlugin.init = function(options, callback) {
return Promise.resolve(new MyPlugin())
}
Methods
Plugin methods should be attached to the constructor function prototype. If the
method is a simple proxy for a native plugin method, use the Astro.nativeRpcMethod
function which will generate a method that proxies to the native method for you.
Full example
import Astro from 'astro-full'
import PluginManager from 'plugin-manager'
/**
* Constructor
*/
const MyPlugin = function() {}
/**
* Defines the plugin name, which is necessary for
* initialization of the plugin on the native side.
*/
MyPlugin.pluginName = 'MyPlugin'
MyPlugin.init = function(options, callback) {
return PluginManager.createPlugin(MyPlugin, options, callback)
}
/**
* Set the image path of the image to display in this plugin.
* Note: This demonstrates using the proxy method generator for a
* native plugin method.
*/
MyPlugin.prototype.setImagePath = Astro.nativeRpcMethod('setImagePath', ['path'])
/**
* A simple delay method to demonstrate a pure-JavaScript plugin method.
*/
MyPlugin.prototype.delay = function(timeout, callback) {
setTimeout(callback, timeout)
}
export default MyPlugin
Android
Begin by creating a new class within your project. This class must inherit from
com.mobify.astro.AstroPlugin
.
Constructor
Create a constructor if your plugin needs to do some initialization when it's created. This includes setting up any UI components and adding them to the plugin's layout. For example:
public MyPlugin(AstroActivity activity, PluginResolver pluginResolver) {
super(activity, pluginResolver);
// Other initialization...
}
RPC Methods
Add any other methods that you want to expose through the JavaScript side of the plugin as normal methods. These are called RPC methods and can accept parameters that conform to the types documented here.
To expose these methods to the JavaScript counterpart of the plugin, annotate
each RPC method with the @RpcMethod
annotation. Pass the method name and
list of parameter names to the annotation.
@RpcMethod(methodName = 'setImagePath', parameterNames = {"path" /*, other parameter names... */ })
public void setImagePath(String path)
{
// ...
}
Asynchronous Methods
Using the @RpcMethod
annotation marks the method as a synchronous method. This
means that the bridge will send the return value from the method back to the
JavaScript caller when the method finishes. It is possible to make the method
asynchronous by using the special @AsyncRpcMethod
annotation. The usage is
the same, except that your Java function must take an RpcResponse
object as
the first parameter (and then any other parameters you function accepts). Because
the method is now asynchronous, the bridge will ignore the return value from the
function. To communicate the return value from the async method, use one of the
RpcResponse
object methods setResult
or setError
and then call send()
.
Plugin UI (Views)
Android plugin classes can provide a UI component by implementing the getView
method. There are no requirements on what kind of view you export as long as
it inherits from the standard Android android.view.View
class. A typical
pattern is to create a container view in the plugin constructor, configure it,
add any subviews, and then return this container view from getView
.
Note that Android uses "dp" instead of "pixels" for UI coordinates - see here for more information.
Registration
Once you have the Android plugin class created you need to register it with Astro. This is the final link needed so that Android can find the correct plugin class when a JavaScript plugin makes a method call across the bridge.
Add these registration calls in your MainActivity
that inherits from
AstroActivity
. The PluginManager
(which is an instance that you have access
to within the MainActivity
's OnCreate
method) has a register
method that
takes the plugin class to be registered.
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Register plugins.
// ...
pluginManager.register(MyPlugin.class);
}
Error handling
The Astro Android SDK does not handle exceptions thrown from your plugin (ie. if your plugin does something that results in an exception being thrown, your app will crash). There may be times when you want to communicate an error from your plugin to app.js to handled there. There are two ways to do this depending on if your method is synchronous or asynchronous.
Synchronous Methods
If the method is marked as synchronous (@RpcMethod
), you will need to create a
custom AstroException
subclass and throw it (eg. DrawerNotInitializedException).
The Astro SDK will catch any exception that inherits from AstroException
and
return the message string from that exception as an error to app.js.
Asynchronous Methods
If the method is marked as asynchronous (@AsyncRpcMethod
), just use the
RpcResponse
object passed to your method to communicate the error by calling
.setError(String)
and then .send()
.
iOS
The process of building the iOS portion of an Astro plugin is similar to
Android. Begin by creating a new class in your iOS project and inheriting from
Astro.Plugin
.
Initializer
Create an initializer (constructor) for the class. This initializer must call
the required init that Astro.Plugin
defines. The following example provides
a basic initializer that provides a good starting point:
public required init(address: MessageAddress, messageBus: MessageBus, pluginResolver: PluginResolver, options: JSONObject?) {
super.init(address: address, messageBus: messageBus, pluginResolver: pluginResolver, options: options)
// Other initialization...
}
RPC Methods
An iOS plugin class can export methods to its JavaScript counterpart similar to how Android does it. One notable difference is that Swift does not provide usable runtime reflection capabilities and so we have to do a bit more manual work to hook up the RPC methods.
Begin by writing the function as a regular instance function. By convention we annotate all RPC methods in iOS with an "@RpcMethod" comment as follows:
/// @RpcMethod
func setImagePath(path: String, respond: RPCMethodCallback) {
// ...
}
All RPC methods must take a respond: RPCMethodCallback
parameter. By
convention this parameter is always the last parameter. If your method does
not take any parameters, it should still take the single respond
parameter.
The iOS SDK never uses the return value from an RPC method. Instead the return
value is always communicated across the bridge using the respond
object. To
return a result, call respond(.result({object}))
. You can accept or return
any type listed in the documentation
here.
Asynchronous methods
A method can be marked as asynchronous when it is registered using the
registerAsyncRPCMethod()
method (see below). Other than that an asynchronous
method is the same as a regular RPC method in iOS (Swift).
RPC Method Registration
RPC methods must be registered in iOS. Each RPC method is registered within the
init
method of the plugin by calling addRPCMethodShim
(for synchronous
methods) or addAsyncRPCMethodShim
(for asynchronous methods). Method parameters
are usually unpacked in the RPC method registration by using helper methods
found in the MethodShimUtils
static class. Use getArg
for RPC method parameters
that must be provided. The helper method will also validate that the parameter
is of the type you specified (based on inferring the type you are assigning to).
If the parameter is not supplied the getArg
helper method will automatically
respond with an error code and return nil
. You can use if let
optional
binding, which will just skip calling your RPC method if getArg
returns nil
.
Eg.
self.addRPCMethodShim("setMainViewPlugin") { params, respond in
////////// This will be autogenerated at some point //////////
if let address: MessageAddress = MethodShimUtils.getArg(params, key: "address", respond: respond) {
self.setMainViewPlugin(address, respond: respond)
}
/////////////////////////////////////////////////////////////
}
For optional arguments you can use the getOptionalArg
on MethodShimUtils
.
This method may return nil
if the parameter wasn't supplied. It will still
verify the parameter type is correct if it has been supplied (using the same
method that getArg
uses (type inference)). Eg.
if let options: JSONObject? = MethodShimUtils.getOptionalArg(params, key: "options", respond: respond) {
////////// This will be autogenerated at some point //////////
self.methodWithOptions(options, respond: respond)
/////////////////////////////////////////////////////////////
}
Plugin UI (Views)
iOS plugin classes that want to export a UI component must conform to the
ViewPlugin
protocol. The protocol exposes a single UIViewController property.
So to export a UI from your plugin, add the property and then compose the
plugin UI in that viewController
's .view
property. For simple plugin views
it is common for the type of the viewController
to be a base UIViewController
as the plugin class itself can coordinate the UIViewController's view
.
For more complex UI's it might make sense to create a custom UIViewController
subclass and return that in your plugin's viewController
property. In this
case your plugin would coordinate with your custom UIViewController: the plugin
arbitrating communication from JS and managing state and the custom view
controller managing the UI, animations, etc. for the plugin. Apply solid
engineering practices as you build your plugin and split it into separate
classes if it starts to take on too many responsibilities.
Note that iOS uses "points" instead of "pixels" for UI coordinates - see here for more information.
Registration
As with Android plugin classes, iOS plugins must be registered before they are
visible to the JavaScript counterpart. In the AppDelegate
of your Astro
iOS project add the registration calls in the pluginRegistrations
block
parameter of the AstroViewController
. The block receives a parameter with a
method you can use to do the registration. Eg.
astroViewController = AstroViewController(appJSURL: URL(string: "app.js")!, launchOptions: launchOptions) { pluginRegistrar in
pluginRegistrar.registerPlugin(name: "MyPlugin", type: MyPlugin.self)
}
Low-level details
- Note: this information covers details of the Astro JavaScript implementation that aren't typically used by plugin authors.
From the JavaScript side, RPC calls can be made by calling
Astro.postMessage(address, message, callback)
with an address as given by the
Plugin Manager message of the form:
{
method: '<string: method name as annotated>',
params: {
<key: value pairs of parameters, where keys are the annotated parameter names>
}
}
When the RPC call is ready to return its value, it will invoke
Astro.receiveMessage(data)
with the response RPC message, and the callback
passed to Astro.postMessage()
for this call will be called with a single
argument that is the result of the RPC call.