前言
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与原生交互变得优雅起来。
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的h5资源被打包之后的目录结构,如下
在本地会加载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插件的调用为
例子。
关键方法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插件调用系统的架构图
如图,后缀js和java只是为了表示是属于js层还是android原生的代码,不是具体插件文件标准。可以看到其中的
中转组件plugin_manager承担了所有插件所有的向下分发和向上回传的任务,所以,在回传时一定需要准确的回传给对应的
插件调用,这里使用了一个集合来保存所有的调用记录的回调方法,在回传时只需要根据相应的id就能取出相应的回调方法
完成js接收webview的调用插件情况的反馈,具体的id是根据插件名字和一个不断递增的数字拼接而成,在androidExec代码
注释中就能看到。
2.3 cordova 原生层分析
1.插件注册
还是少说废话,直接上时序图
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
找到具体的插件,然后执行插件的逻辑
这个就是实体注册的插件调用流程。那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的。
从图中我们可以看到最终的发送者是BridgeMode,BridgeMode是个抽象类,具体的实现者有效的有三个个
四个继承者在处理消息时会调用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 考虑的多种使用场景,封装结构化,这次分析只是分析了核心机制实现原理,后续会继续深入了解其架构代码。