前言

Hybrid 话题比较大,但是这是app以后的主流开发趋势,本着不落后于时代的初心,笔者也开始一步一步的记录学习心得, 由于目前项目所使用的是cordova的方案,于是便决定先跟cordova这个比较奇葩的框架一磕到底

1.Hybrid 与 JS

经常听同事说,“js是最好的语言”,“js会一统天下”。不得不承认在pc网站发展中,js一路披荆斩棘,到如今的前后端通吃 发展历程着实令人瞩目,移动端兴起时,js也想快速入侵,完成新大陆的统一,不过这里,由于Google的遏制,js的在移动端 android上的发展遭受了一些阻力,臭名昭著的WebView让js等前端技术到了现在也并没有完全拿下这块要地。当然大公司有 大公司的政策,民间有民间的对策,为了推动移动端H5技术的发展,其中也涌现了不少优秀的JSBrige框架。

1.1 何为JSBrige

在android 原生开发中,UI开发只是其中一部分,但H5开发再怎么厉害,最多只能解决这一部分的问题,但是app开发不同于 pc开发,多变的应用场景,让app需要适配各种场景,webview在这一部分拙荆见肘。webview为了弥补这一短板,提供了JS与 原生调用通道,原生提供各种各样的插件通过这个通道供js调用。这其中逻辑交错,框架便应运而生。

1.2 Cordova

cordova,Apache基金维护的一个开源项目,其提供了一套app的JS插件编写标准,让H5与原生交互变得优雅起来。

cordova 架构图

2.实现原理

分析这些插件原理,还是要从webview与JS调用过程中来下手,最后将插件框架分为 JS层标准和原生层 两个部分来实现即可。 (笔者android原生开发出身,ios开发不甚了解,故不多言)

2.1 webview与JS通信

2.1.1 JS调用webview

1.addJavascriptInterface 实体注册

首先我们创建一个原生实体对象,并用JavascriptInterface来进行安全注解 使用了改注解,才能将方法安全的暴漏给JS去调用,防止JS随便通过直接访问 对象的getClass等方法来进行反射操作。在api17之后生效。

1
2
3
4
5
6
7
8
public class BrigeBean {

    @JavascriptInterface
    public void connect(String msg){
        Log.d("BrigeBean",msg);
    }

}

然后配置webview,并且注册实体对象,实体注册之后,第二个参数“Brige”就是JS调用webview的句柄

1
2
3
4
5
6
7
8
9
private void initView() {
    webview = (WebView) findViewById(R.id.webview);
    webview.setWebChromeClient(new WebChromeClient());
    webview.setWebViewClient(new WebViewClient());
    webview.loadUrl("file:////android_asset/index.html");
    webview.getSettings().setJavaScriptEnabled(true);
    //实体注册
    webview.addJavascriptInterface(new BrigeBean(),"Brige");
}

然后在H5进行调用即可

1
2
3
4
5
6
 <script>
    function callAndroid(){
        //JS调用
        Brige.connect("js调用了android中的connect方法");
    }
</script>

这种方法是官方提供的,是比较正规的方法。

2.拦截url方法

webview配置,主要是在WebViewClient中重写shouldOverrideUrlLoading方法,具体如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private void initView_withUrl() {
    webview = (WebView) findViewById(R.id.webview);
    webview.setWebChromeClient(new WebChromeClient());
    webview.setWebViewClient(new WebViewClient(){
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
            //在该方法中可以获取全部uri的信息
            String scheme = request.getUrl().getScheme();
            String authority = request.getUrl().getAuthority();
            String arg1 = request.getUrl().getQueryParameter("arg1");
            String arg2 = request.getUrl().getQueryParameter("arg2");
            Log.d("webviewurl",scheme+"--"+authority+"--"+arg1+"--"+arg2);
            return true;
        }
    });
    webview.loadUrl("file:////android_asset/index_url.html");
    webview.getSettings().setJavaScriptEnabled(true);
    webview.setWebContentsDebuggingEnabled(true);
}

在js中通过如下方式调用,遵循一个uri的标准方式书写即可

1
2
3
4
5
6
<script>
    function callAndroid(){
        //JS调用
        document.location = "js://webview?arg1=111&arg2=222";
    }
</script>

3.通过prompt方法拦截

webview配置如下,对WebChromeClient中的onJsPrompt方法进行重写,js传下来的值在message参数字段中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
private void initView_withPrompt() {
    webview = (WebView) findViewById(R.id.webview);
    webview.setWebChromeClient(new WebChromeClient(){

        @Override
        public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
            result.confirm("webviewtojs");
            return true;
        }

    });
    webview.setWebViewClient(new WebViewClient());
    webview.loadUrl("file:////android_asset/index_prompt.html");
    webview.getSettings().setJavaScriptEnabled(true);
    webview.setWebContentsDebuggingEnabled(true);
}

JS调用代码如下,通过调用confirm,alert或者prompt来分别对webview中的方法进行调用。

1
2
3
4
5
6
7
<script>
    function callAndroid(){
        //JS调用
        var result = prompt("jstowebview");
        console.log(result);
    }
</script>

那么这几个方法各自的优缺点呢?

方法 优点 缺点
实体注册 配置标准化且方便,并且可以直接获取到来自原生的返回值 存在安全隐患,在api17以前需要通过其他方式来规避
拦截url 不存在安全隐患 配置麻烦,且不能直接获得返回值
拦截prompt 不存在安全隐患 配置麻烦,prompt可以直接设置返回值,需要对弹出框进行重写处理

2.1.2 webview调用JS

1.通过loadUrl的方式

在h5中先声明一个js方法

1
2
3
4
5
6
<script>
    function callFromWebview(msg){
        //JS调用
        console.log(msg);
    }
</script>

在原生通过loadUrl方法去调用,注意要等主体h5渲染完成之后才能进行调用,所以这里 使用了一个延时操作来模拟

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private void initView_loadUrltojs() {
    webview = (WebView) findViewById(R.id.webview);
    webview.setWebChromeClient(new WebChromeClient());
    webview.setWebViewClient(new WebViewClient());
    webview.loadUrl("file:////android_asset/index.html");
    webview.getSettings().setJavaScriptEnabled(true);
    webview.postDelayed(new Runnable() {
        @Override
        public void run() {
            //调用js的声明方法
            webview.loadUrl("javascript:callFromWebview(123)");
        }
    },5000);
}

2.使用evaluateJavascript方法

h5同上,这里重点是webview的调用改变一下。

1
2
3
4
5
6
webview.evaluateJavascript("javascript:callFromWebview(123)", new ValueCallback<String>() {
    @Override
    public void onReceiveValue(String value) {

    }
});

在onReceiveValue中会得到js返回的数据,这一点,比前者要方便的多。

那么corodva会用那种方式来完成js调用呢?

2.2 cordova JS层分析

cordova在js层最关键的一个文件就是cordova.js文件。想要调用原生插件,在index.html挂载该文件即可。

1.插件的注册

大致过程如下

cordova 插件初始化时序

这里需要先了解一下cordova的h5资源被打包之后的目录结构,如下

cordova 目录结构

在本地会加载cordova.js文件,cordova.js会加载cordova_plugins.js文件,cordova_plugins.js同时 里面包含插件信息的集合数据,每条数据都包含插件对象的js的路径,然后就根据这些路径将plugin文件夹下的插件 一并加入,这里我们以其中一个Device插件为例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//cordova_plugins.js的内容
...
{
    "id": "cordova-plugin-device.device",
    //插件路径,相对地址
    "file": "plugins/cordova-plugin-device/www/device.js",
    "pluginId": "cordova-plugin-device",
    "clobbers": [
        "device"
    ]
},
...

插件路径

而不管是cordova_plugins.js还是插件js文件,在cordova.js中都是通过document操作添加到h5运行环境的

1
2
3
4
5
6
7
8
//corodva.js文件中 injectScript 方法是最终的插件添加操作。
exports.injectScript = function(url, onload, onerror) {
    var script = document.createElement("script");
    script.onload = onload;
    script.onerror = onerror;
    script.src = url;
    document.head.appendChild(script);
};

2.插件的调用

经过如上的插件注册过程之后,h5运行环境中,应该都存在了关于所有插件的调用句柄了,这里还是以Device插件的调用为 例子。

cordova 调用流程

关键方法androidExec,注意注释的地方。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function androidExec(success, fail, service, action, args) {
    if (bridgeSecret < 0) {
        throw new Error('exec() called without bridgeSecret');
    }
    if (jsToNativeBridgeMode === undefined) {
        androidExec.setJsToNativeBridgeMode(jsToNativeModes.JS_OBJECT);
    }
    args = args || [];
    for (var i = 0; i < args.length; i++) {
        if (utils.typeName(args[i]) == 'ArrayBuffer') {
            args[i] = base64.fromArrayBuffer(args[i]);
        }
    }
    //回调方法数组建立
    var callbackId = service + cordova.callbackId++,
        argsJson = JSON.stringify(args);
    if (success || fail) {
        cordova.callbacks[callbackId] = {success:success, fail:fail};
    }
    //启动调用插件方法,与webview交互
    var msgs = nativeApiProvider.get().exec(bridgeSecret, service, action, callbackId, argsJson);
    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) {
        messagesFromNative.push(msgs);
        nextTick(processMessages);
    }
}

上图中的cordova 和 nativeapiprovider都是cordova.js文件中提供的调用对象。那么根据流程图介绍。 我们已经知道了cordova采用了两种方式来跟webviw交互,至于到底用哪种,是根据配置去决定的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
define("cordova/android/nativeapiprovider", function(require, exports, module) {
//从代码来看,应该默认使用_cordovaNative,如果没有,再使用prompt的方式
var nativeApi = this._cordovaNative || require('cordova/android/promptbasednativeapi');
var currentApi = nativeApi;
module.exports = {
    get: function() { return currentApi; },
    setPreferPrompt: function(value) {
        currentApi = value ? require('cordova/android/promptbasednativeapi') : nativeApi;
    },
    set: function(value) {
        currentApi = value;
    }
};
});
define("cordova/android/promptbasednativeapi", function(require, exports, module) {
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);
    }
};

而代码中的_cordovaNative是什么呢,如果猜的没错的话,应该是webview在原生代码的注册实体。下文会继续揭晓

3.回调方法数组

在上述分析中,作者特意将回调方法数组作为一个步骤列出,这个地方在后续webview回调js时会起到非常重要的作用。 我们先大致看看整个cordova插件调用系统的架构图

cordova 插件调用流程

如图,后缀js和java只是为了表示是属于js层还是android原生的代码,不是具体插件文件标准。可以看到其中的 中转组件plugin_manager承担了所有插件所有的向下分发和向上回传的任务,所以,在回传时一定需要准确的回传给对应的 插件调用,这里使用了一个集合来保存所有的调用记录的回调方法,在回传时只需要根据相应的id就能取出相应的回调方法 完成js接收webview的调用插件情况的反馈,具体的id是根据插件名字和一个不断递增的数字拼接而成,在androidExec代码 注释中就能看到。

2.3 cordova 原生层分析

1.插件注册

还是少说废话,直接上时序图

cordova-android 插件初始化

cordova 的android 原生工程中,有个config.xml文件,这个文件就是插件的注册表。

1
2
3
4
<feature name="Device">
    <param name="android-package" value="com.cordova.utils.Device" />
    <param name="onload" value="false" />
</feature>

上述配置中,feature-name就是js在调用exec方法时传递的ServiceName;com.cordova.utils.Device一目了然,表示 java文件Device的DeviceclassName,用作反射使用;onload表示是否懒加载。

2.插件调用

在前面的js层分析中,我们得出了结论,cordova 的js和webview交互使用了实体注册和prompt两种方式,其中实体注册在webview 的配置中一定存在相应的代码。就是如下代码

1
2
exposedJsApi = new X5ExposedJsApi(bridge);
webView.addJavascriptInterface(exposedJsApi, "_cordovaNative");

所以,当通过实体注册的方式来调用插件时一定会调用到X5ExposedJsApi的exec方法。

1
2
3
4
@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);
}

接着就会调用CordovaBrige的jsExec方法,在jsExec中会直接调用到pluginManager的exec方法,pluginManager根据具体的servciename 找到具体的插件,然后执行插件的逻辑

cordova-android 插件调用流程

这个就是实体注册的插件调用流程。那prompt的流程如何呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
 public boolean onJsPrompt(WebView view, String origin, String message, String defaultValue, final JsPromptResult result) {
        String handledRet = parentEngine.bridge.promptOnJsPrompt(origin, message, defaultValue);
    ...
 }

//CordovaBrige中的promptOnJsPrompt方法
  public String promptOnJsPrompt(String origin, String message, String defaultValue) {
        if (defaultValue != null && defaultValue.length() > 3 && defaultValue.startsWith("gap:")) {
            JSONArray array;
            try {
                array = new JSONArray(defaultValue.substring(4));
                int bridgeSecret = array.getInt(0);
                String service = array.getString(1);
                String action = array.getString(2);
                String callbackId = array.getString(3);
                //实际还是调用的CordovaBrige的jsExec方法
                String r = jsExec(bridgeSecret, service, action, callbackId, message);
         ...
}

上图的代码我们看到了实际prompt方式最后还是调用了CordovaBrige中的jsExec方法,跟实体注册最后逻辑一样的

而且根据cordova.js中有一段注释,可以得知,实体注册是主要方式,当实体注册无法把参数传下去时,就会使用prompt作为备用方法

3.插件回调

前面的分析,我们可以看到,cordova使用的两种方式都是可以直接获得返回值的,那么为什么cordova还是使用注册回调的方式来获得返回值呢?

首先我们看看还是Cordova是怎么将返回值回传给js的。

cordova-android 插件回调流程

从图中我们可以看到最终的发送者是BridgeMode,BridgeMode是个抽象类,具体的实现者有效的有三个个

BrigeMode继承关系

四个继承者在处理消息时会调用onNativeToJsMessageAvailable方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
//LoadUrlBridgeMode
@Override
public void onNativeToJsMessageAvailable(final NativeToJsMessageQueue queue) {
    ...
    engine.loadUrl("javascript:" + js, false);
    ...
}
//OnlineEventsBridgeMode
@Override
public void onNativeToJsMessageAvailable(final NativeToJsMessageQueue queue) {
    ...
    delegate.setNetworkAvailable(online);
    ...
}
//EvalBridgeMode
@Override
public void onNativeToJsMessageAvailable(final NativeToJsMessageQueue queue) {
    ...
    engine.evaluateJavascript(js, null);
    ...
}

可以看到loadUrl 和 evaluateJavascript都存在了,并且程序默认使用EvalBridgeMode的方式。 在调用到这两个地方时,js的回传数据如下

1
cordova.callbackFromNative('AndUtilPlugin1390149997',true,1,["OK"],false);

在cordova.js里面一定有一个callbackFromNative的方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
callbackFromNative: function(callbackId, isSuccess, status, args, keepCallback) {
    ...
    //从之前的回调数组取出相应的回调对象
    var callback = cordova.callbacks[callbackId];
    if (callback) {
        if (isSuccess && status == cordova.callbackStatus.OK) {
            callback.success && callback.success.apply(null, args);
        } else if (!isSuccess) {
            callback.fail && callback.fail.apply(null, args);
        }
        //表示这个回调保留与否,有些场景下,需要js一直保留,有些场景只需要js调用一次就行。删除数据,减少内存消耗
        if (!keepCallback) {
            delete cordova.callbacks[callbackId];
        }
    }
    ...
}

4.OnlineEventsBridgeMode的逻辑分析

上一节中我们算是分析清楚了一次js调用的闭环流程。但是作者也注意到了一个细节,那就是关于OnlineEventsBridgeMode的作用疑惑。

在cordova.js找到了一处注释。

1
2
3
4
5
6
7
8
9
//CB-11828
//This failsafe checks the version of Android and if it's Jellybean, it switches it to
//using the Online Event bridge for communicating from Native to JS
//It's ugly, but it's necessary.
var check = navigator.userAgent.toLowerCase().match(/android\s[0-9].[0-9]/);
var version_code = check && check[0].match(/4.[0-3].*/);
if (version_code != null && nativeToJsBridgeMode == nativeToJsModes.EVAL_BRIDGE) {
    nativeToJsBridgeMode = nativeToJsModes.ONLINE_EVENT;
}

从注释里面来看,应该是用来适配android 4.系列的,如果是这个系列的sdk,就默认使用ONLINE_EVENT模式来完成回调

OnlineEventsBridgeMode的流程分析

上图就是具体的分析流程。可以看到绕了一大圈,实际上还是调用了js的callbackFromNative来处理。只不过按照注释来看,这样实现处理逻辑 确实不太优雅,但是似乎是为了适配android 4.x的某些手机。

总结

1.cordova 使用了实体注册和prompt两种方式来进行js插件调用。

2.返回值没有直接通过这两种方式返回,而是采用了异步回调,也是为了避免插件调用的并发问题。比如有些情况下,原生需要等待才能将数据返回,而不应该让插件管理器 在此处阻塞。

3.cordova 考虑的多种使用场景,封装结构化,这次分析只是分析了核心机制实现原理,后续会继续深入了解其架构代码。