混合开发的App(Hybrid App)就是在一个App中内嵌一个轻量级的浏览器,一部分原生的功能改为Html 5来开发,这部分功能不仅能够在不升级App的情况下动态更新,而且可以在Android或iOS的App上同时运行,让用户的体验更好又可以节省开发的资源。
下面来谈谈Hybrid App开发中的技术问题。iOS方面的我不太了解,我就主要谈谈Android开发中的,其中可能会有很多说错的,请大家轻喷
想要在一个App中显示一个Html 5网页的功能,其实很简单,只要一个WebView就可以了。你可以点击链接来跳转网页。像这样的功能就能叫做Hybrid 开发了嘛?显然不是的。
我觉得一个Hybrid开发的App中必须要要有的功能就是Html 5页面和Native App怎么进行交互。比如,我点了一个Html 5页面上的一个按钮或链接,我能不能够跳转到Native App的某个页面;比如我点了Html 5页面上的分享按钮,我能不能调用Native App的分享功能;比如Html加载的时候能不能获取Native App的用户信息等等。
看下图,在网易云音乐中进入这个Html 5页面时,你点击作者:空虚小编你会进入他的主页,这个主页是Native页面,而你点击上面那个播放按钮时,云音乐会启动Native的播放界面播放音乐,你点击评论时,你会进入Native的评论页
WebView本来就支持js和Java相互调用,你只需要开启WebView的JavaScript脚本执行,然后通过代码mWebView.addJavascriptInterface(new JsBridge(), "bxbxbai");向Html 5页面时注入一个Java对象,然后就可以在Html 5页面中调用Native的功能了
微信应该是Hybrid 开发做的最好的App之一,它是怎么做交互的呢?
答案就是微信JS-SDK,去微信开发者文档中可以看到,微信JS-SDK封装了各种微信的功能,比如分享到朋友圈,图像接口,音频接口,支付接口地理位置接口等等。开发者只需要调用微信JS-SDK中的函数,然后统一由JS-SDK来调用微信中的功能,这样好处就是我写了一个Html 5的应用或网页,在Android和iOS的微信中都可以正常运行了
下面会详细讲到
那么网易云音乐是怎么做的呢?我用黑科技知道了上图云音乐的界面Activity是CommonSubjectActivity(名字好奇怪,如果要我从代码里找,我肯定找不到,因为还有一个类叫做EmbedBrowserActivity),我就在反编译后的云音乐代码中找相应的功能实现代码,实在没找到。不过我拿到了那个Html 5页面的地址:http://music.163.com/m/topic/194001
用Chrome打开后发现和App中显示的不一样,然后我用Charles截了进入那个Html 5的请求,发现云音乐加载的地址是http://music.163.com/m/topic/194001?type=android ,就是加了手机系统类型
然后在我自己的App中加载这个Html 5页面就可以看到下图,@小比比说这样的文字是可以点击跳转到个人,点击播放按钮是可以播放音乐的
从Html源代码中可以看到如下信息:
也就是说,当我点击一个用户名的时候就请求跳转到orpheus://user/30868859,因为WebView可以拦截跳转的url,所以App在拦截每一个url,如果host是orpheus的话就启动用户首页
反编译代码后,在云音乐的代码中找到了this.mWebView.setWebViewClient(new cf(this));这么一句代码,进入cf类,发现下面代码:
public boolean shouldOverrideUrlLoading(WebView webView, String url) { if (url.startsWith("orpheus://")) { RedirectActivity.a(this.activity, url); return true; } if ((url.toLowerCase().startsWith("http://")) || (url.toLowerCase().startsWith("https://"))) { return false; } try { this.activity.startActivity(new Intent("android.intent.action.VIEW", Uri.parse(url))); return true; } catch (ActivityNotFoundException localActivityNotFoundException) { localActivityNotFoundException.printStackTrace(); } return true; }
果然如此,再进入RedirectActivity,这是一个没有任何界面的Activity,专门用于处理页面跳转信息,它会调用一个方法NeteaseMusicUtils.redirect(this, getIntent().getData().toString(), false)来处理url,redirect方法的名字是我自己写的,部分代码如下:
可以看到orpheus://user/30868859中的用户id被传入了ProfileAcvitiy,因此启动了用户首页显示了用户信息
然后我自己写了代码拦截Html 5的跳转,打印出的Log如下:
可以看到Html 5页面可以跳转到各种页面,比如用户首页、播放音乐、MV界面、评论页、电台节目等等
一般来讲,也是我目前知道的两种主流的方式就是
第2种方式实现起来很简单,但是一个致命的问题就是这种交互方式是单向的,Html 5无法实现回调。像云音乐App中这种点击跳转到具体页面的功能,Schema的方式确实可以简单实现,而且也非常适合。如果需求变得复杂,假如Html 5需要获取Native App中的用户信息,那么最好使用js调用的方式。
上面讲到WebViewbe本身就是支持js调用Native代码的,不过WebView的这个功能在Android 4.2(API 17)一下存在高危的漏洞。这个漏洞的原理就是Android系统通过WebView.addJavascriptInterface(Object o, String interface)方法注册可供js调用的Java对象,但是系统并没有对注册的Java对象方法调用做限制。导致攻击者可以利用反射调用未注册的其他任何Java对象,攻击者可以根据客户端的能力做任何事情。这篇文章详细的介绍了这个漏洞
出于安全考虑,Android 4.2以后的系统规定允许被js调用的Java方法必须以@JavascriptInterface进行注解
Cordova是一个广泛使用的Hybrid开发框架,它提供了一套js和Native交互规范
在Cordova的SystemWebViewEngine类中可以看到
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; } webView.addJavascriptInterface(new SystemExposedJsApi(bridge), "_cordovaNative"); }
因此当Android系统高于4.2时,Cordova还是使用addJavascriptInterface这种方式,因为这个方法在高版本上安全而且简单,低于4.2的时候,用什么方法呢?
答案是WebChromeClient.onJsPrompt方法
WebView可以设置一个WebChromeClient对象,它可以处理js的3个方法
这3个方法分别对应js的alert、confirm、prompt方法,因为只有prompt接收返回值,所以js调用一个Native方法后可以等待Native返回一个参数。下面是cordova.js中的一段代码:
/** * Implements the API of ExposedJsApi.java, but uses prompt() to communicate. * This is used pre-JellyBean, where addJavascriptInterface() is disabled. */ 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); } };
然后只要在onJsPrompt方法中使用CordovaBridge来处理js的prompt调用
/** * Tell the client to display a prompt dialog to the user. If the client returns true, WebView will assume that the client will handle the prompt dialog and call the appropriate JsPromptResult method. * <p/> * Since we are hacking prompts for our own purposes, we should not be using them for this purpose, perhaps we should hack console.log to do this instead! */ @Override public boolean onJsPrompt(WebView view, String origin, String message, String defaultValue, final JsPromptResult result) { // Unlike the @JavascriptInterface bridge, this method is always called on the UI thread. String handledRet = parentEngine.bridge.promptOnJsPrompt(origin, message, defaultValue); if (handledRet != null) { result.confirm(handledRet); } else { dialogsHelper.showPrompt(message, defaultValue, new CordovaDialogsHelper.Result() { @Override public void gotResult(boolean success, String value) { if (success) { result.confirm(value); } else { result.cancel(); } } }); } return true; }
Cordova是Apache的一个开源解决方案,不过它需要xml配置CordovaPlugin信息,使用会比较麻烦,而且这个框架很重,具体请自行搜索Cordova使用教程
下面这个开源项目是我个人觉得比较合理的解决方案,也比较轻量级,下图就是一个Demo
https://github.com/pedant/safe-java-js-webview-bridge
这个项目的原理就是使用WebChromeClient.onJsPrompt方法来进行交互,本质上都是js调用prompt函数,传输一些参数,onJsPrompt方法拦截到prompt动作,然后解析数据,最后调用相应的Native方法
HostJsScope类中定义了所有可以被js调用的方法,这些方法都必须是静态方法,并且所有的方法第一个参数必须是WebView
/** * HostJsScope中需要被JS调用的函数,必须定义成public static,且必须包含WebView这个参数 */ public class HostJsScope { /** * 短暂气泡提醒 * @param webView 浏览器 * @param message 提示信息 * */ public static void toast(WebView webView, String message) { Toast.makeText(webView.getContext(), message, Toast.LENGTH_SHORT).show(); } /** * 系统弹出提示框 * @param webView 浏览器 * @param message 提示信息 * */ public static void alert(WebView webView, String message) { // 构建一个Builder来显示网页中的alert对话框 AlertDialog.Builder builder = new AlertDialog.Builder(webView.getContext()); builder.setPositiveButton(android.R.string.ok, new AlertDialog.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }); builder.setTitle("Hello world") .setMessage(message) .setCancelable(false) .create() .show(); } // 其他代码 }
上面代码列举了最基本的点击Html 5按钮弹出对话框的功能
这个库中一个最关键的叫做JsCallJava,这个实现的就是js来调用Java方法的功能,这个类只用于InjectedWebChromeClient类
public class InjectedChromeClient extends WebChromeClient { private JsCallJava mJsCallJava; private boolean mIsInjectedJS; public InjectedChromeClient(String injectedName, Class injectedCls) { this(new JsCallJava(injectedName, injectedCls)); } public InjectedChromeClient(JsCallJava jsCallJava) { mJsCallJava = jsCallJava; } // 处理Alert事件 @Override public boolean onJsAlert(WebView view, String url, String message, final JsResult result) { result.confirm(); return true; } @Override public void onProgressChanged(WebView view, int newProgress) { //为什么要在这里注入JS //1 OnPageStarted中注入有可能全局注入不成功,导致页面脚本上所有接口任何时候都不可用 //2 OnPageFinished中注入,虽然最后都会全局注入成功,但是完成时间有可能太晚,当页面在初始化调用接口函数时会等待时间过长 //3 在进度变化时注入,刚好可以在上面两个问题中得到一个折中处理 //为什么是进度大于25%才进行注入,因为从测试看来只有进度大于这个数字页面才真正得到框架刷新加载,保证100%注入成功 if (newProgress <= 25) { mIsInjectedJS = false; } else if (!mIsInjectedJS) { view.loadUrl(mJsCallJava.getPreloadInterfaceJS()); mIsInjectedJS = true; StopWatch.log(" inject js interface completely on progress " + newProgress); } super.onProgressChanged(view, newProgress); } @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { result.confirm(mJsCallJava.call(view, message)); StopWatch.log("onJsPrompt: " + view.toString() +", " + url +", " + message +", " + defaultValue + ", " + result) ; return true; } }
这个InjectedWebChromeClient是设给WebView的,这里一个非常重要的细节需要注意一下,在onProgressChange方法中,向WebView注入了一段js代码,这段js代码如下:
javascript: (function(b) { console.log("HostApp initialization begin"); var a = { queue: [], callback: function() { var d = Array.prototype.slice.call(arguments, 0); var c = d.shift(); var e = d.shift(); this.queue[c].apply(this, d); if (!e) { delete this.queue[c] } } }; a.alert = a.alert = a.alert = a.delayJsCallBack = a.getIMSI = a.getOsSdk = a.goBack = a.overloadMethod = a.overloadMethod = a.passJson2Java = a.passLongType = a.retBackPassJson = a.retJavaObject = a.testLossTime = a.toast = a.toast = function() { var f = Array.prototype.slice.call(arguments, 0); if (f.length < 1) { throw "HostApp call error, message:miss method name" } var e = []; for (var h = 1; h < f.length; h++) { var c = f[h]; var j = typeof c; e[e.length] = j; if (j == "function") { var d = a.queue.length; a.queue[d] = c; f[h] = d } } var g = JSON.parse(prompt(JSON.stringify({ method: f.shift(), types: e, args: f }))); if (g.code != 200) { throw "HostApp call error, code:" + g.code + ", message:" + g.result } return g.result }; //有时候,我们希望在该方法执行前插入一些其他的行为用来检查当前状态或是监测 //代码行为,这就要用到拦截(Interception)或者叫注入(Injection)技术了 /** * Object.getOwnPropertyName 返回一个数组,内容是指定对象的所有属性 * * 其后遍历这个数组,分别做以下处理: * 1. 备份原始属性; * 2. 检查属性是否为 function(即方法); * 3. 若是重新定义该方法,做你需要做的事情,之后 apply 原来的方法体。 */ Object.getOwnPropertyNames(a).forEach(function(d) { var c = a[d]; if (typeof c === "function" && d !== "callback") { a[d] = function() { return c.apply(a, [d].concat(Array.prototype.slice.call(arguments, 0))) } } }); b.HostApp = a; console.log("HostApp initialization end") })(window);
那么这段js代码是如何生成的呢?答案就在JsCallJava类的构造函数方法中,这个构造方法做的事情就是解析HostJsScope类中的方法,把每一个方法的签名都保持到private Map<String, Method> mMethodsMap中,再看上面那段js代码中
a.alert = a.alert = a.alert = a.delayJsCallBack = a.getIMSI = a.getOsSdk = a.goBack = a.overloadMethod = a.overloadMethod
= a.passJson2Java = a.passLongType = a.retBackPassJson = a.retJavaObject = a.testLossTime = a.toast = a.toast = function()
这些都是HostJsScope类中定义的方法名
那么这个库的整个执行流程是这样的:
以上就是这个开源库的整个原理,我个人觉得非常适合用于Hybrid开发,这个解决方案中js可以收到Native的返回值,而且没有使用addJavascriptInterface方法,在低版本手机上也不会有安全问题,这个方法比Cordova的实现和配置简单
那么当我点击Html 5页面上的一个按钮,比如弹出对话框,这个过程的整体流程是怎么样的呢
什么?你问我微信是怎么解决的?我也反编译了微信的代码,想研究一下他们是解决的,其实我非常好奇微信的这种js 调用Native,并且又返回的调用方法
首先,我去微信的js sdk官网看了一下js sdk提供的功能,提供了各种强大的功能,各位可以自己去看一下。那么问题来了,微信是怎么做到js 调用Native并且能够成功返回的呢?
带着疑问我反编译了微信Android客户端,在assers/jsapi中看到了wxjs.js文件,我想这个就是微信js sdk的源码了吧。。。
我首先说一下,我不太懂js的代码, 我只能连蒙带猜的看微信的js代码,如果有js大神对这方面也感兴趣,希望可以一起(jian)探(fei)讨(zao)
在wxjs.js中看到了一下代码,我猜微信就是用这个__WeixinJSBridge当时js和Native进行通信的数据结构吧?
var __WeixinJSBridge = { // public invoke:_call, call:_call, on:_onfor3rd, env:_env, log:_log, // private // _test_start:_test_start, _fetchQueue: _fetchQueue, _handleMessageFromWeixin: _handleMessageFromWeixin, _hasInit: false, _continueSetResult: _continueSetResult };
然后我又看到了下面的代码,我想应该是提供分享内容到朋友圈功能的吧
// share timeline _on('menu:share:timeline',function(argv){ _log('share timeline'); var data; if (typeof argv.title === 'string') { data = argv; _call('shareTimeline',data); }else{ data = { // "img_url": "", // "img_width": "", // "img_height": "", "link": document.documentURI || _session_data.init_url, "desc": document.documentURI || _session_data.init_url, "title": document.title }; var shareFunc = function(_img){ if (_img) { data['img_url'] = _img.src; data['img_width'] = _img.width; data['img_height'] = _img.height; } _call('shareTimeline',data); }; getSharePreviewImage(shareFunc); } });
请注意最后这句:_call('shareTimeline',data);,在看看__WeixinJSBridge中的call属性,接着我找到了_call方法。
function _call(func,params,callback) { var curFuncIdentifier = __WeixinJSBridge.call; if (curFuncIdentifier !== _callIdentifier) { return; } if (!func || typeof func !== 'string') { return; }; if (typeof params !== 'object') { params = {}; }; var callbackID = (_callback_count++).toString(); if (typeof callback === 'function') { _callback_map[callbackID] = callback; }; var msgObj = {'func':func,'params':params}; msgObj[_MESSAGE_TYPE] = 'call'; msgObj[_CALLBACK_ID] = callbackID; _sendMessage(JSON.stringify(msgObj)); }
大致意思应该就是:就是将这个东西_call('shareTimeline',data);转换成一个json字符串吧,从这里看到微信的做法和上面那个开源库非常类似,简单并且安全。_call方法最后调用_sendMessage方法发送消息
//将消息添加到发送队列,iframe的准备队列为weixin://dispatch_message/ function _sendMessage(message) { _sendMessageQueue.push(message); _readyMessageIframe.src = _CUSTOM_PROTOCOL_SCHEME + '://' + _QUEUE_HAS_MESSAGE; // var ifm = _WXJS('iframe#__WeixinJSBridgeIframe')[0]; // if (!ifm) { // ifm = _createQueueReadyIframe(document); // } // ifm.src = _CUSTOM_PROTOCOL_SCHEME + '://' + _QUEUE_HAS_MESSAGE; };
从上面代码可以看到微信的js sdk也是将js的方法调用换成一个类似weixin://dispatch_message/这样的url,上面说的json封装的数据。那么我猜测微信的做法是类似网易云音乐的拦截url吗?如果真的是这样的话,就非常不安全了,随便一个Html 5页面可以伪造一个类似:weixin://dispatch_message/这样的url来调用微信的功能了,不过好在微信对每个js调用都必须带上appid。
在反编译后的微信代码,我看到了下面代码:
我想这写就是微信想Html 5开放的接口吧?不过对比了一下微信js sdk的官网,我看到好多App提供的功能在js sdk中并没有找到,这样也没有太大关系,以为微信只要升级js sdk就可以使用其他功能了,因为Native已经开放了嘛~
从上面__WeixinJSBridge可以看到有一个熟悉_handleMessageFromWeixin,这个就是js来处理Native的回调接口,我用这个字符串在微信代码中搜索,结果如下:
因此,我大致猜测,微信中的js调Native功能是用拦截url的方式,而Native回调的话是使用evaluateJavascript方法
我也在js sdk中找到了相应的函数:
function _handleMessageFromWeixin(message) { var curFuncIdentifier = __WeixinJSBridge._handleMessageFromWeixin; if (curFuncIdentifier !== _handleMessageIdentifier) { return '{}'; } var ret; var msgWrap if (_isUseMd5 === 'yes') { var realMessage = message[_JSON_MESSAGE]; var shaStr = message[_SHA_KEY]; var arr = new Array; arr[0] = JSON.stringify(realMessage); arr[1] = _xxyy; var str = arr.join(""); var msgSha = ''; var shaObj = CryptoJS.SHA1(str); msgSha = shaObj.toString(); if (msgSha !== shaStr) { _log('_handleMessageFromWeixin , shaStr : ' + shaStr + ' , str : ' + str + ' , msgSha : ' + msgSha); return '{}'; } msgWrap = realMessage; } //省略很多代码
微信的做法应该说非常基础,使用了原生的功能,但是安全,由于微信客户端对每一个js调用都有验证(appid),因此这也增加了一定的安全性
以上说的都是建立在我的分析正确的情况下。
现在各种新的技术也在想办法解决Native开发的效率问题,想用技术来解决一套代码运行在Android和iOS客户端,我相信随着技术的发展这些问题都会解决。我也好期待Facebook即将推出的React Native Android
本文讲的Hybrid开发就是Native客户端中嵌入了Html App的功能,这方面微信应该是做的最好的,由于Html 5的效率以及耗电问题,我个人觉得用户是不能满足Web App的体验的,Hybrid App也只适用于某些场景。一些基础的功能,比如调用手机的摄像头,获取地理位置,登录注册功能等等,做成Native的功能,让Html 5来调用更好,这样的体验也更好。
如果你把一个登录和注册功能也做成Html 5,在弱网络环境下,这个体验应该会非常的差,或许你等半天还没加载出页面。你可能会说,我可以预先加载Html 5的代码,打开App时直接加载,那么我说你在给自己找麻烦,如果要这样的话,Native开发或许更快一点。
那么什么情况适合Html 5开发呢?像一些活动页面,比如秒杀、团购等适合做Html 5,因为这些页面可能涉及的非常炫而且复杂,Html 5开发或许会简单点,关键是这些页面时效性短,更新更快,因为一个活动说不定就一周时间,下周换活动,如果这样的话,你还做Native是肯定不行的
有那么一句古老的箴言:
如果你手里有一把锤子,所有东西看上去都想钉子
千万不要以为Hybrid开发能够夸平台运行,就使用Hybrid开发任何功能。其实Facebook早期也是这么想的,后来就是因为WebView渲染效率底下,把整个应用改为Native开发,请看这里
引用Facebook的一段话:
Today, we’re releasing a new version of Facebook for Android that’s been rebuilt in native code to improve speed and performance. To support the unique complexity of Facebook stories across devices, we’re moving from a hybrid native/webview to pure native code, allowing us to optimize the Facebook experience for faster loading, new user interfaces, disk cache, and so on.
本文主要还是从技术上谈谈Hybrid开发中js和Native交互的技术实现原理。抛砖引玉,写的估计也有很多错的,希望技术大牛指出。
最后,我觉得那个开源的库是一个非常不错的解决方案,解决办法巧妙、简单而且安全。当时我debug了半天弄明白其中的原理后,我一拍大腿,这办法真好啊!!网易云音乐的解决办法适用于它的场景,不需要回调,Native只需要处理相应的信息,然后来实现页面跳转、播放音乐、播放MV等功能,这个方法也简单好用。