Cordova学习笔记(二)JS与Android原生交互
本文由 小茗同学 发表于 2016-06-30 浏览(9701)
最后修改 2016-11-16 标签:cordova phonegap native 原生 交互 javascript android
[TOC]

Android自带做法

先来说一下我们常规的JS与原生交互的实现方式。

下面只是简单介绍,更详细的部分可以参考我另外写的这篇文章:Android原生与JS交互总结

js调用原生

通过给方法添加@JavascriptInterface注解,然后通过mWebView.addJavascriptInterface(object, name)将刚才那个方法所在的类注入JS,然后js就可以直接通过name.方法名()来调用刚才那个方法。

几个要点:

  1. 这个方法是同步的,可以直接返回值给js;
  2. 支持根据参数个数重载,但不支持根据参数类型重载;
  3. 支持一些简单的js与原生数据转换;
  4. 只需要对webview全局注入一次,无需针对每个页面重新注入;
  5. 对iframe的支持不同版本Android不一样,有的会注入到iframe里面,有的不会;
  6. Android4.2开始才增加@JavascriptInterface注解,目的是为了解决一个漏洞,在此之后,只有添加了这个注解的方法才会被注入JS。

原生调用js

一般都是通过mWebView.loadUrl('javascript:xxx')来执行一段JS,据说这个方法有一个bug,就是执行的时候如果输入法是弹出的,执行后输入法会自动消失,我暂未亲测。

这个方法最大的缺点是无法优雅的解决异步回调问题,鉴于此,Android4.4开始增加了如下方法:

mWebView.evaluateJavascript(script, resultCallback)

有了这个方法就可以非常方便的实现js的异步回调,但是毕竟低于Android4.4的版本还是有比较大的份额,所以一般还是得自己另行解决异步回调的问题。

Cordova/PhoneGap的实现方式

JS调用原生

这里不作特别细致的源码分析,只是大概走一下整个流程。我们这里以调用摄像头拍照为例分析一下整个过程。

调用摄像头拍照有2个方法,一个navigator.device.capture.captureImage,一个是navigator.camera.getPicture,我们这里以前面一个方法为例。

我们从如下代码开始调用:

navigator.device.capture.captureImage(function(mediaFiles)
{
	console.log(mediaFiles);
});

然后进入capture.js:

Capture.prototype.captureImage = function(successCallback, errorCallback, options){
	_capture("captureImage", successCallback, errorCallback, options);
};

然后进入cordova.js中的:

androidExec(success, fail, service, action, args)

这里面有一个jsToNativeBridgeMode,cordova的jsToNative有2种实现方式:

jsToNativeModes = {
	PROMPT: 0, // 采用prompt实现
	JS_OBJECT: 1 // 采用传统的 addJavaScriptInterface 实现
},

默认采用addJavaScriptInterface方式,也就是这里所说的JS_OBJECT,如果不存在window._cordovaNative对象,则采用prompt方式替代,那么这里的window._cordovaNative又是从哪里来的呢?

CordovaLib项目中的SystemWebViewEngine.java文件中有这样一个方法:

private static void exposeJsInterface(WebView webView, CordovaBridge bridge) {
	if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1)) {
		Log.i(TAG, "Disabled addJavascriptInterface() bridge since Android version is old.");
		// Bug being that Java Strings do not get converted to JS strings automatically.
		// This isn't hard to work-around on the JS side, but it's easier to just
		// use the prompt bridge instead.
		return;
	}
	SystemExposedJsApi exposedJsApi = new SystemExposedJsApi(bridge);
	webView.addJavascriptInterface(exposedJsApi, "_cordovaNative");
}

其含义是,如果当前Android版本>=4.2,那么直接采用Android原生提供的addJavaScriptInterface方法来注入一个名叫SystemExposedJsApi的对象,否则不作任何处理(只弹了一个提示),为什么这么做呢?注释里面是说“由于Java的String不会自动转换成js的string,虽然在js端比较容易处理,但是采用prompt方式替代更容易”,我觉得还有另外一个原因,就是4.2之前存在的那个注入漏洞。

这个SystemExposedJsApi类仅仅只注入了3个方法:

@JavascriptInterface
public String exec(int bridgeSecret, String service, String action, String callbackId, String arguments) throws JSONException, IllegalAccessException {
	return bridge.jsExec(bridgeSecret, service, action, callbackId, arguments);
}

@JavascriptInterface
public void setNativeToJsBridgeMode(int bridgeSecret, int value) throws IllegalAccessException {
	bridge.jsSetNativeToJsBridgeMode(bridgeSecret, value);
}

@JavascriptInterface
public String retrieveJsMessages(int bridgeSecret, boolean fromOnlineEvent) throws IllegalAccessException {
	return bridge.jsRetrieveJsMessages(bridgeSecret, fromOnlineEvent);
}

这3个方法最终都是调用CordovaBridge对应的方法,分别是:

  • exec 执行,几乎所有方法的执行都是都过这个方法
  • setNativeToJsBridgeMode 设置原生调用JS的桥接模式
  • retrieveJsMessages 从原生获取JS消息

好了,说了一大通再回到刚开始那里,如果是采用prompt方式呢?

在cordova.js中找到如下代码:

define("cordova/android/promptbasednativeapi", function(require, exports, module) {
/**
 * 实现ExposedJsApi.java的API,但是采用prompt()实现
 */
module.exports = {
	exec: function(bridgeSecret, service, action, callbackId, argsJson) {
		return prompt(argsJson, 'gap:'+JSON.stringify([bridgeSecret, service, action, callbackId]));
	},
	setNativeToJsBridgeMode: function(bridgeSecret, value) {
		prompt(value, 'gap_bridge_mode:' + bridgeSecret);
	},
	retrieveJsMessages: function(bridgeSecret, fromOnlineEvent) {
		return prompt(+fromOnlineEvent, 'gap_poll:' + bridgeSecret);
	}
};

});

可以看到,promptbasednativeapi提供了和ExposedJsApi.java一模一样的3个方法,只不过调用的都是prompt方法,然后我们在Android端的SystemWebChromeClient找到如下方法:

public boolean onJsPrompt(WebView view, String origin, String message, String defaultValue, final JsPromptResult result) {
	// 不像@JavascriptInterface方式,prompt调用总是在UI线程
	String handledRet = parentEngine.bridge.promptOnJsPrompt(origin, message, defaultValue);
	if (handledRet != null) // 只要返回不是null,就认为是特殊的prompt消息
	{
		result.confirm(handledRet);
	}
	else
	{
		//正常的prompt处理
	}
	return true;
}

CordovaBridge.promptOnJsPrompt最终也还是和SystemExposedJsApi一样,调用的那3个方法,所以,至此可以发现,无论哪种bridgeMode最终调用的方法是一样的。

回到androidExec方法:

/**
 * 所有执行Android方法都是调用这个
 * @param {Object} success 成功回调
 * @param {Object} fail 失败回调
 * @param {Object} service 原生的类名
 * @param {Object} action 原生的方法名
 * @param {Object} args 参数,数组格式,个人觉得采用json格式会更好
 */
function androidExec(success, fail, service, action, args) {
	if (bridgeSecret < 0) {
		// cordova为了安全考虑,会在页面初始化的时候执行一个“gap_init:”的东西,这里面就是
		// 在原生随机生成一个数字作为密钥,然后返回给js,js获取之后保存下来,每次执行exec时都必须
		// 携带这个密钥,否则视作不合法的执行,页面每次刷新密钥都会重新生成
		throw new Error('exec() called without bridgeSecret');
	}
	// 默认采用JS_OBJECT桥接模式
	if (jsToNativeBridgeMode === undefined) {
		androidExec.setJsToNativeBridgeMode(jsToNativeModes.JS_OBJECT);
	}

	// 将参数中的 ArrayBuffers 转换成字符串
	for (var i = 0; i < args.length; i++) {
		if (utils.typeName(args[i]) == 'ArrayBuffer') {
			args[i] = base64.fromArrayBuffer(args[i]);
		}
	}
	// 这里生成一个callbackId,格式是:类名+一个递增的数字
	var callbackId = service + cordova.callbackId++,
		argsJson = JSON.stringify(args);

	// 只要成功和回调有一个被设置了就将这2个方法放到全局的回调池中去(姑且这么称呼)
	if (success || fail) {
		cordova.callbacks[callbackId] = {success:success, fail:fail};
	}
	// nativeApiProvider.get()就是获取合适的bridgeMode,然后执行
	// 根据上面的分析可以知道,这里的执行可能是直接调用_cordovaNative.exec,也有可能是调用prompt实现
	var msgs = nativeApiProvider.get().exec(bridgeSecret, service, action, callbackId, argsJson);
	// 如果是JS_OBJECT模式并且原生没有接收到参数,换成prompt模式再执行一次,执行完了再换回JS_OBJECT模式
	// 这种情况非常少见,仅限少数设备(如Galaxy S2)当包含特殊Unicode字符时出现,我们可以完全忽略这段代码
	if (jsToNativeBridgeMode == jsToNativeModes.JS_OBJECT && msgs === "@Null arguments.") {
		androidExec.setJsToNativeBridgeMode(jsToNativeModes.PROMPT);
		androidExec(success, fail, service, action, args);
		androidExec.setJsToNativeBridgeMode(jsToNativeModes.JS_OBJECT);
	} else if (msgs) {
		// 如果exec执行后直接返回了东西,将返回的消息放入数组,然后异步取出再处理
		//至于这里为何要先放入数组然后再异步取出还没太搞明白
		messagesFromNative.push(msgs);
		// Always process async to avoid exceptions messing up stack.
		nextTick(processMessages);
	}
}

js暂时说到这里,进入CordovaBridge.jsExec之后调用:

pluginManager.exec(service, action, callbackId, arguments);

PluginManager根据service名称获到相应的CordovaPlugin,然后再:

plugin.execute(action, rawArgs, callbackContext)

由于Capture是继承自CordovaPlugin的,所以最终调用的是Capture.execute,里面根据action来调用指定的方法:

if (action.equals("captureImage")) {
	this.captureImage(pendingRequests.createRequest(CAPTURE_IMAGE, options, callbackContext));
}

然后:

Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE);
//省略其它代码
this.cordova.startActivityForResult((CordovaPlugin) this, intent, req.requestCode);

至此就成功启动摄像头了。

原生调用js

讲了这么久,终于到了原生调用js了。

补充:

虽然调用摄像头拍照并返回结果按照我们常规理解是异步的,但其实上面启动完摄像头之后,原生立即返回了js一个类似下面这样的消息:

'42 S11 CoreAndroid52457735 {"action":"pause"}'

前端接收到之后有一个专门处理消息的方法processMessages,这个字符串按照一定规则生成,比如S表示成功,S后面的1表示keepCallback,具体我就懒得分析了,最后是调用了一个onMessageFromNative方法,内部调用了:

cordova.fireDocumentEvent('pause')

这里面自定义了一个名为pause的事件并触发,具体这一步是干嘛还没仔细看。

下面说说原生是如何调用js的。

用户拍照并点击确定后会触发Capture.javaonActivityResult方法(当然取消拍照也会触发):

pendingRequests.resolveWithSuccess(req)

然后

req.callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.OK, req.results));

然后

webView.sendPluginResult(pluginResult, callbackId)

然后

nativeToJsMessageQueue.addPluginResult(pluginResult, callbackId);

然后

nativeToJsMessageQueue.enqueueMessage(message)

然后:

queue.add(message); // 将返回的消息加入队列
if (!paused)
{
	activeBridgeMode.onNativeToJsMessageAvailable(this);
}

上面的onNativeToJsMessageAvailable就是原生调用js,具体怎么调,我们再来说说cordova的原生调用js几种方式。

在cordova.js中有这样一段代码:

nativeToJsModes = {
	// Polls for messages using the JS->Native bridge.
	// JS自己执行一个计时器每50毫秒主动到原生的消息队列获取消息
	// 说实话这种方式我也是醉了,不予置评,貌似几乎没有采用这种方式的情况
	POLLING: 0,
	// For LOAD_URL to be viable, it would need to have a work-around for
	// the bug where the soft-keyboard gets dismissed when a message is sent.
	// 采用传统的loadUrl('javascript:xxx')的方式,这种方式存在输入法自动消失的bug
	LOAD_URL: 1,
	// For the ONLINE_EVENT to be viable, it would need to intercept all event
	// listeners (both through addEventListener and window.ononline) as well
	// as set the navigator property itself.
	// 采用window.ononline和window.onoffline事件来通知js去原生获取消息,
	// 原生端则是通过频繁的调用webView.setNetworkAvailable(true/false)来让浏览器联网/掉线
	// 从而触发online和offline,这种方式我一直在纠结其速度、性能究竟如何?
	ONLINE_EVENT: 2
},

默认采用ONLINE_EVENT,然后我们再回到Android端,NativeToJsMessageQueue.BridgeMode内部类:

public static abstract class BridgeMode {
	public abstract void onNativeToJsMessageAvailable(NativeToJsMessageQueue queue);
	public void notifyOfFlush(NativeToJsMessageQueue queue, boolean fromOnlineEvent) {}
	public void reset() {}
}

BridgeMode有3种实现,其实你应该猜到了,就是上面提到的3中交互方式:

轮询

轮询就是采用这种方式,由于是js主动定时到原生获取消息,所以这种方式无需任何操作,所以才叫NoOP。

public static class NoOpBridgeMode extends BridgeMode {
	@Override public void onNativeToJsMessageAvailable(NativeToJsMessageQueue queue) {
	}
}

loadUrl

采用 webView.loadUrl(“javascript:”) 来执行js代码:

public static class LoadUrlBridgeMode extends BridgeMode {
	//省略部分代码
	@Override
	public void onNativeToJsMessageAvailable(final NativeToJsMessageQueue queue) {
		cordova.getActivity().runOnUiThread(new Runnable() {
			public void run() {
				String js = queue.popAndEncodeAsJs();
				if (js != null) {
					engine.loadUrl("javascript:" + js, false);
				}
			}
		});
	}
}

online/offline事件

通过webView.setNetworkAvailable(true/false)来设置webview的联网与掉线,从而触发js的window.ononline和window.onoffline事件,然后再主动通过retrieveJsMessages到原生的消息队列获取消息。

public static class OnlineEventsBridgeMode extends BridgeMode {
	// 省略部分代码
	@Override
	public void onNativeToJsMessageAvailable(final NativeToJsMessageQueue queue) {
		delegate.runOnUiThread(new Runnable() {
			public void run() {
				if (!queue.isEmpty()) {
					ignoreNextFlush = false;
					delegate.setNetworkAvailable(online);
				}
			}
		});
	}
}

其中,OnlineEventsBridgeMode内部有一个名叫OnlineEventsBridgeModeDelegate的接口,在SystemWebViewEngine.java中实现了它:

@Override
public void setNetworkAvailable(boolean value) {
	webView.setNetworkAvailable(value);
}

添加事件的时候好像有一个window和document的处理,懒得仔细看了,因为鄙人非常不看好这种方式。

function hookOnlineApis() {
	function proxyEvent(e) {
		cordova.fireWindowEvent(e.type);
	}
	// The network module takes care of firing online and offline events.
	// It currently fires them only on document though, so we bridge them
	// to window here (while first listening for exec()-releated online/offline
	// events).
	window.addEventListener('online', pollOnceFromOnlineEvent, false);
	window.addEventListener('offline', pollOnceFromOnlineEvent, false);
	cordova.addWindowEventHandler('online');
	cordova.addWindowEventHandler('offline');
	document.addEventListener('online', proxyEvent, false);
	document.addEventListener('offline', proxyEvent, false);
}
function pollOnceFromOnlineEvent() {
	pollOnce(true);
}
function pollOnce(opt_fromOnlineEvent) {
	if (bridgeSecret < 0) {
		// This can happen when the NativeToJsMessageQueue resets the online state on page transitions.
		// We know there's nothing to retrieve, so no need to poll.
		return;
	}
	// 主动调用原生方法获取队列中的消息
	var msgs = nativeApiProvider.get().retrieveJsMessages(bridgeSecret, !!opt_fromOnlineEvent);
	if (msgs) {
		messagesFromNative.push(msgs);
		// 同步处理消息因为我们确定消息一定在队列的最上面
		processMessages();
	}
}

点击拍照并确定之后,收到一个指定格式的字符串,里面包含了刚拍完照片的路径,然后再根据callbackId去回调我们最早设置的2个回调函数:

好了,终于差不多讲完了,写这么多估计看的人早糊涂了,因为感觉写的太乱了。

总结

终于可以来个总结了。

js调用原生这一块,Android4.2以上(包括)直接采用@JavaScriptInterface注入3个方法,其中最重要的是exec,所有的方法调用都是通过exec来执行,而4.2以下版本由于安全以及其它一些兼容性问题,采用prompt变相实现,这一点我觉得能接受。

原生调用js这一块,轮询就不用说了,太low了,效率也低,无论从哪个角度都不可取;仅仅是为了规避loadUrl执行js时会隐藏输入法这一个问题,转而采用令人无语的online事件,前后端各种无缝配合最终才实现原生传递数据到js,个人觉得有点小题大做了,一个是我觉得输入法自动隐藏那个小问题我能接受,毕竟在用户打字的时候触发某个消息这种情况不是很常见,二来是否可以有其它方法来规避这个问题?而且Android4.4开始已经提供了原始的执行js的方法而非loadUrl变相实现。

以上仅是个人观点,略太偏激,可能是我眼光太浅有些没考虑到的地方。

mui的HTML5+sdk实现

//TODO

补充:JS注入漏洞

具体参见 Android WebView的Js对象注入漏洞解决方案 一文。

这里只是简单的转载,本人没有亲测。

由于Android4.2之前,无需给注入的方法添加@JavascriptInterface注解即可注入所有那个类的所有方法,包括getClass,所以:

  1. WebView添加了JavaScript对象,并且当前应用具有读写SDCard的权限,也就是:android.permission.WRITE_EXTERNAL_STORAGE
  2. JS中可以遍历window对象,找到存在getClass方法的对象的对象,然后再通过反射的机制,得到Runtime对象,然后调用静态方法来执行一些命令,比如访问文件的命令.
  3. 再从执行命令后返回的输入流中得到字符串,就可以得到文件名的信息了。然后想干什么就干什么,好危险。

核心JS代码如下:

function execute(cmdArgs)
{
	for (var obj in window)
	{
		if ("getClass" in window[obj])
		{
			alert(obj);
			return  window[obj].getClass().forName("java.lang.Runtime")
				 .getMethod("getRuntime",null).invoke(null,null).exec(cmdArgs);
		}
	}
}