Starter is a starter kit for building hybrid apps, it contains:
- An HTML5 application
- Two native projects
- And a set of tools files
Starter focuses to two platforms:
- iOS 9+
- Android 6.0+ (API level 23)
If you need more backward compatibility or more exotic platforms like Windows Phone or BlackBerry, use something else!
Following tools are mandatory for a full use of Starter:
- XCode: Even if you don't feel right with it, there is no other choice for iOS.
- Android Studio: The best IDE for building Android apps.
- GNU make: After more than 25 years, the old
make
build tool still rule them all! - Jenkins: The king of continuous integration.
- fastlane: The game changer for stores submission.
If you use Starter you have to modify manually the native projects, they are located in platforms
directory and they are both named AppShell
.
Both projects are Single View Applications with a Fullscreen WebView:
- Starter uses android.webkit.WebView class on Android.
- Starter uses the new WKWebView class on iOS (introduced in iOS 8).
More precisely Starter uses the method loadFileURL of WKWebView class introduced in iOS 9!
Android and iOS are multitasking platforms, applications can be paused and can be resumed. To handle these features Starter sends some events from native code to Javascript. The events are named pause
and resume
.
On Android events are dispatched by the com.starter.appshell.MainActivity like this:
this.mWebView.evaluateJavascript("document.dispatchEvent(new Event('pause'));", null);
this.mWebView.evaluateJavascript("document.dispatchEvent(new Event('resume'));", null);
And on iOS by the ViewController like this:
self.webView!.evaluateJavaScript("document.dispatchEvent(new Event('pause'));", completionHandler: nil)
self.webView!.evaluateJavaScript("document.dispatchEvent(new Event('resume'));", completionHandler: nil)
Finally events are handled in Javascript like this:
document.addEventListener('pause', function (e) {...});
document.addEventListener('resume', function (e) {...});
In hybrid applications, Javascript needs to call some native code. To do this, the native projects inject an object called platform
in Window object before loading HTML.
On Android platform
object look like this:
window.platform = {
name: function () {
return 'android';
},
foo: function (message) {
android.foo(message);
},
bar: function (message, callback) {
var uuid = this._uuid();
this._callbacks[uuid] = callback;
android.bar(message, uuid);
},
_callbacks: {},
_uuid: function () {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0;
var v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
},
_invoke: function (uuid, err, data) {
this._callbacks[uuid](err, data);
delete this._callbacks[uuid];
},
};
The
android
object is introduced by the addJavascriptInterface method of android.webkit.WebView class. Alsoandroid.foo()
andandroid.bar(...)
functions are defined by the methods of com.starter.appshell.JavascriptInterface class (see android.webkit.JavascriptInterface annotation). Last but not the least,_callbacks
,_uuid
and_invoke
are private properties, they are used to support async function callback.
And com.starter.appshell.MainActivity injects it like this:
try (InputStream stream = this.getAssets().open("platform.js")) {
byte[] buffer = new byte[stream.available()];
stream.read(buffer);
this.mWebView.evaluateJavascript(new String(buffer), null);
}
catch (IOException ex) {
}
On iOS, things are quite the same, platform
object looks like this:
window.platform = {
name: function () {
return 'ios';
},
foo: function (message) {
webkit.messageHandlers.handler.postMessage({
method: 'foo',
message: message,
});
},
bar: function (message, callback) {
var uuid = this._uuid();
this._callbacks[uuid] = callback;
webkit.messageHandlers.handler.postMessage({
method: 'bar',
message: message,
callback: uuid,
});
},
_callbacks: {},
_uuid: function () {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0;
var v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
},
_invoke: function (uuid, err, data) {
this._callbacks[uuid](err, data);
delete this._callbacks[uuid];
},
};
Here
webkit.messageHandlers.handler
object is introduced by addScriptMessageHandler method of WKUserContentController class and posted messages are received by ScriptMessageHandler class.
And it is injected by ViewController like this:
let platform = NSURL(fileURLWithPath: NSBundle.mainBundle().pathForResource("platform", ofType: "js")!)
self.webView!.evaluateJavaScript(try! String(contentsOfURL: platform), completionHandler: nil)
Starter uses the same callback model as node.js, a function with two arguments: err
and data
. They are typically used like this:
function (err, data) {
if (err)
throw err;
// data is available here
}
As Starter can't provide the function directly to the native code, a unique identifier is generated by the _uuid
function of platform
object. When native code needs to invoke this callback, it simply calls the _invoke
function with the given identifier.
On Android:
this.mWebView.post(new Runnable() {
@Override
public void run() {
JavascriptInterface.this.mWebView.evaluateJavascript("platform._invoke('" + callback + "', null, true);", null);
}
});
The callback is not invoked on the UI thread (see post method).
And on iOS:
self.viewController.webView!.evaluateJavaScript("platform._invoke('" + callback + "', null, true);", completionHandler: nil)
You have to mock the platform
object during development phase in the browser, you can do something like this:
window.platform = window.platform || {
name: function () {
return 'www';
},
foo: function (message) {
alert(message);
},
bar: function (message, callback) {
callback(null, confirm(message));
},
};
As you can see the object is defined only if it doesn't exist (see index.html).
Each project can define in its own application manifest a property named StartURL
. If this property is defined, the application starts in viewer mode. That allows the application to load this url in the WebView.
See AndroidManifest.xml and Info.plist
The WebView is initialized like this on Android:
String url = "file:///android_asset/www/index.html";
try {
ApplicationInfo ai = getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
url = (String) ai.metaData.get("StartURL");
}
catch (Exception ex) {
}
this.mWebView.loadUrl(url);
More information on
assets
directory can be found here.
Once again, things are equivalent on iOS:
if let url = NSBundle.mainBundle().objectForInfoDictionaryKey("StartURL") as? String {
self.webView!.loadRequest(NSURLRequest(URL: NSURL(string: url)!))
}
else {
let index = NSURL(fileURLWithPath: NSBundle.mainBundle().pathForResource("index", ofType: "html", inDirectory: "www")!)
self.webView!.loadFileURL(index, allowingReadAccessToURL: index.URLByDeletingLastPathComponent!)
}
GNU make goals are defined in Makefile file. Its main purpose is to copy the HTML5 application located in src
directory to native projects:
- On Android the application is copied to
platforms/android/app/src/main/assets/www
- And on iOS to
platforms/ios/www
If the HTML5 application needs to be bundled with tools like browserify or webpack, it must be done here! Let's say that the Makefile knows both worlds (native and Javascript).
fastlane handles following lifecycle tasks of native projects:
- Run units tests and UI tests
- Build application
- Submit application to store
Good tool or bad tool ? fastlane allows you to manipulate native projects in a uniform way!
Starter provides following lanes for both platforms:
test
: Runs all the testscompile
: Compile the applicationstore
: Submit the application
For example to build iOS native project, use
fastlane ios compile
Check fastlane files for more information: Appfile, Fastfile.
Jenkins pipeline is defined in Jenkinsfile file. Normally Jenkins pipeline should execute: