移动端Hybrid通信 —— jsBridge实现与原理分析
#文 一像素
前言
近期在做移动端的开发工作,之前大大小小的移动端项目也做了几个,遇到不少 需要交互的功能,这块东西端就像中国象棋中的“楚河汉界”一般的存在,本文我们将从移动端最常见的一种唤醒交互(如推广、引流等)讲起 ,一起探索Hybrid 开发模式下的 JS Bridge 通信技术。
顾名思义,JS Bridge 的意思就是桥,也就是连接 JS 和 Native 的桥梁,它也是 Hybrid App 里面的核心。一般分为 JS 调用 Native 和 Native 主动调用 JS 两种形式。
场景导入
下面的场景大家应该不陌生
这里还有个小例子,你可以在浏览器里面直接输入 weixin://,系统就会提示你是否要打开微信。输入 mqq:// 就会帮你唤起手机 QQ。
同样,你可以用同样的方式,来打开我们的吉客云。那这个功能是如何做到的呢?下面我们将从这一最基础的操作开始,逐步深入 jsBridge 交互的基本形式。
一、概念导入
URL Scheme
URL Scheme 是一种特殊的 URL,一般用于在 Web 端唤醒 App,甚至跳转到 App 的某个页面,比如在某个手机网站上付款的时候,可以直接拉起支付宝支付页面。
这里有个常用 App URL Scheme 汇总:URL Schemes 收集整理
在手机里面打开这个页面后点击这里,就会提示你是否要打开微信。
我们常说的 Deeplink 一般也是基于 URL Scheme 来实现的。一个 URI 的组成结构如下:
URI = scheme:[//authority]path[?query][#fragment] // scheme = http // authority = www.baidu.com // path = /link // query = url=xxxxx authority = [userinfo@]host[:port]
除了 http/https 这两个常见的协议,还可以自定义协议。借用维基百科的一张图:
通常情况下,App 安装后会在手机系统上注册一个 Scheme,比如 weixin:// 这种,所以我们在手机浏览器里面访问这个 scheme 地址,系统就会唤起我们的 App。
一般在 Android 里面需要到 AndroidManifest.xml 文件中去注册 Scheme:
<activity android:name=".login.dispatch.DispatchActivity" android:launchMode="singleTask" android:theme="@style/AppDispatchTheme"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="taobao" /> <data android:host="xxx" /> <data android:path="/goods" /> </intent-filter> </activity>
在 iOS 中需要在 Xcode 里面注册,有一些已经是系统使用的不应该使用,比如 Maps、YouTube、Music。
具体可以参考苹果开发者官网文档:Defining a Custom URL Scheme for Your App
另外,Apple 在WWDC 2015上为 iOS 9 引入的一个新功能Universal Link。这里不展开讲,相关详细说明请参考:Support Universal Links
二、JS 调用 Native
在 iOS 里面又需要区分 UIWebView 和 WKWebView 两种 WebView:
WKWebView 是 iOS8 之后出现的,目的是取代笨重的 UIWebView,它占用内存更少,大概是 UIWebView 的 1/3,支持更好的 HTML5 特性,性能更加强大。
但也有一些缺点,比如不支持缓存,需要自己注入 Cookie,发送 POST 请求的时候带不了参数,拦截 POST 请求的时候无法解析参数等等。
JS 调用 Native 通信大致有三种方法:
- 拦截 Scheme
- 弹窗拦截
- 注入 JS 上下文
这三种方式总体上各有利弊,下面会一一介绍。
1、拦截 Scheme
仔细思考一下,如果是 JS 和 Java 之间传递数据,我们该怎么做呢?
对于前端开发来说,调 Ajax 请求接口是最常见的需求了。不管对方是 Java 还是 Python,我们都可以通过 http/https 接口来获取数据。实际上这个流程和 JSONP 更加类似。
已知客户端是可以拦截请求的,那么可不可以在这个上面做文章呢?
如果我们请求一个不存在的地址,上面带了一些参数,通过参数告诉客户端我们需要调用的功能呢?
比如我要调用扫码功能:
axios.get('http://xxxx?func=scan&callback_id=yyyy')
客户端可以拦截这个请求,去解析参数上面的 func 来判断当前需要调起哪个功能。客户端调起扫码功能之后,会获取 WebView 上面的 callbacks 对象,根据 callback_id 回调它。
所以基于上面的例子,我们可以把域名和路径当做通信标识,参数里面的 func 当做指令,callback_id 当做回调函数,其他参数当做数据传递。对于不满足条件的 http 请求不应该拦截。
当然了,现在主流的方式是前面我们看到的自定义 Scheme 协议,以这个为通信标识,域名和路径当做指令。
这种方式的好处就是 iOS6 以前只支持这种方式,兼容性比较好。
JS 端
我们有很多种方法可以发起请求,目前使用最广泛的是 iframe 跳转:
- 使用 a 标签跳转
<a href="taobao://">点击我打开淘宝</a>
- 重定向
location.href = "taobao://"
- iframe 跳转
const iframe = document.createElement("iframe"); iframe.src = "taobao://" iframe.style.display = "none" document.body.appendChild(iframe)
Android 端
在 Android 侧可以用 shouldOverrideUrlLoading 来拦截 url 请求。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231150620472967681.png?Expires=4783564591&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=K6aECyl9vs88rvByX4L%2FdggYqOM%3D#1629964589984.png)
iOS 端
在 iOS 侧需要区分 UIWebView 和 WKWebView 两种方式。 在 UIWebView 中:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231150948096934529.png?Expires=4783564630&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=ABJ92%2BC0iZ4HXny8gS6Vv1am6Xc%3D#1629964629164.png)
在 WKWebView 中:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231151122789696128.png?Expires=4783564650&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=t9Y2ZBaKNZwy7PyrPNPXClYMml8%3D#1629964649962.png)
目前不建议只使用拦截 URL Scheme 解析参数的形式,主要存在几个问题。
- 连续续调用 location.href 会出现消息丢失,因为 WebView 限制了连续跳转,会过滤掉后续的请求。
- URL 会有长度限制,一旦过长就会出现信息丢失 因此,类似 WebViewJavaScriptBridge 这类库,就结合了注入 API 的形式一起使用,这也是我们这边目前使用的方式,后面会介绍一下。
2、弹窗拦截
Android 实现
这种方式是利用弹窗会触发 WebView 相应事件来拦截的。
一般是在 setWebChromeClient 里面的 onJsAlert、onJsConfirm、onJsPrompt 方法拦截并解析他们传来的消息。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231151367000359425.png?Expires=4783564680&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=DXcTRHb5U8I6QcRtJxSCraLzyr0%3D#1629964679096.png)
iOS 实现
我们以 WKWebView 为例:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231151534344700416.png?Expires=4783564700&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=HsdLhtpQCC5IKYXr%2FEKbpbtfj%2BQ%3D#1629964699049.png)
这种方式的缺点就是在 iOS 上面 UIWebView 不支持,但是 WKWebView 又有更好的 scriptMessageHandler,比较尴尬。
注入上下文
前面我们有讲过在 iOS 中内置了 JavaScriptCore 这个框架,可以实现执行 JS 以及注入 Native 对象等功能。
这种方式不依赖拦截,主要是通过 WebView 向 JS 的上下文注入对象和方法,可以让 JS 直接调用原生。
PS:iOS 中的 Block 是 OC 对于闭包的实现,它本质上是个对象,定义 JS 里面的函数。
iOS UIWebView
iOS 侧代码:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231151753738846849.png?Expires=4783564726&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=ayNspe8HvsHTjL5t91TGhPxooYA%3D#1629964725179.png)
JS 代码:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231151860116292096.png?Expires=4783564738&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=YLyk1z%2FmXQzkD83sH1uMQC7JxKk%3D#1629964737883.png)
这种方式厉害的地方在于,JS 调用是同步的,可以立马拿到返回值。
我们也不再需要像拦截方式一样,每次传值都要把对象做 JSON.stringify,可以直接传 JSON 过去,也支持直接传一个函数过去。
iOS WKWebView
WKWebView 里面通过 addScriptMessageHandler 来注入对象到 JS 上下文,可以在 WebView 销毁的时候调用 removeScriptMessageHandler 来销毁这个对象。
前端调用注入的原生方法之后,可以通过 didReceiveScriptMessage 来接收前端传过来的参数。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231152667050050049.png?Expires=4783564835&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=9S2yiULV7x%2Bjla7ftgF6bpcVpxg%3D#1629964834033.png)
使用 addScriptMessageHandler 注入的对象实际上只有一个 postMessage 方法,无法调用更多自定义方法。前端的调用方式如下:
window.webkit.messageHandlers.nativeObj.postMessage(data);
需要注意的是,这种方式要求 iOS8 及以上,而且返回不是同步的。和 UIWebView 一样的是,也支持直接传 JSON 对象,不需要 stringify。
Android addJavascriptInterface
安卓4.2之前注入 JS 一般是使用 addJavascriptInterface ,和前面的 addScriptMessageHandler 有一些类似,但又没有它的限制。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231153104490791424.png?Expires=4783564887&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=VwbPptb2Ch7jsSLiPb7v7vtGD7Q%3D#1629964886226.png)
在 JS 里面调用:
window.DatePickerBridge._pick(...)
但这种方案有一定风险,可以参考这篇文章:WebView中接口隐患与手机挂马利用
在 Android4.2 之后提供了 @JavascriptInterface 注解,暴露给 JS 的方法必须要带上这个。
所以前面的 _pick 方法需要带上这个注解。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231153278066360961.png?Expires=4783564907&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=ajmzXrBzw39sVQ%2BfhoUY4MKSLEU%3D#1629964906912.png)
Native 调用 JS
Native 调用 JS 一般就是直接 JS 代码字符串,有些类似我们调用 JS 中的 eval 去执行一串代码。一般有 loadUrl、evaluateJavascript 等几种方法,这里逐一介绍。
但是不管哪种方式,客户端都只能拿到挂载到 window 对象上面的属性和方法。
Android
在 Android 里面需要区分版本,在安卓4.4之前的版本支持 loadUrl,使用方式类似我们在 a 标签的 href 里面写 JS 脚本一样,都是javascript:xxx 的形式。
这种方式无法直接获取返回值。
webView.loadUrl("javascript:foo()")
在安卓4.4以上的版本一般使用 evaluateJavascript 这个 API 来调用。这里需要判断一下版本。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231153983088427521.png?Expires=4783564991&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=e69eJkaGN%2B7Muq97uBi0wVgYc1Q%3D#1629964990886.png)
UIWebView
在 iOS 的 UIWebView 里面使用 stringByEvaluatingJavaScriptFromString 来调用 JS 代码。这种方式是同步的,会阻塞线程。
results = [self.webView stringByEvaluatingJavaScriptFromString:"foo()"];
WKWebView
WKWebView 可以使用 evaluateJavaScript 方法来调用 JS 代码。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231154243495985665.png?Expires=4783565023&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=ltATxG%2FWXUFQOoRzBKvj1DsqmlY%3D#1629965022000.png)
JS Bridge 设计
前面讲完了 JS 和 Native 互调的所有方法,这里来介绍一下我们这边 JS Bridge 的设计吧。
我们以基于 WebViewJavascriptBridge 这个库来实现JS Bridge 通信 。
主要是结合 Scheme 协议+上下文注入来做。考虑到 Android 和 iOS 不一样的通信方式,这里进行了封装,保证提供给外部的 API 一致。
具体功能的调用我们封装成了 npm 包,下面的是几个基础 API:
- callHandler(name, params, callback):这个是调用 Native 功能的方法,传模块名、参数、回调函数给 Native。
- hasHandler(name):这个是检查客户端是否支持某个功能的调用。
- registerHandler(name):这个是提前注册一个函数,等待 Native 回调,比如 pageDidBack 这种场景。
那么这几个 API 又是如何实现的呢?这里 Android 和 iOS 封装不一致,应当分开来说。
Android Bridge
前面我们有说过安卓可以通过 @JavascriptInterface 注解来将对象和方法暴露给 JS。
所以这里的几个方法都是通过注解暴露给 JS 来调用的,在 JS 层面做了一些兼容处理。
hasHandler
首先最简单的是这个 hasHandler,就是在客户端里面维护一张表(其实我们是写死的),里面有支持的 Bridge 模块信息,只需要用 switch...case 判断一下就行了。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231154646325330432.png?Expires=4783565071&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=rBBCH5GhU7GblTeGsFAZ9YSH1Oc%3D#1629965070018.png)
callHandler
然后我们来看 callHandler 这个方法,它是提供 JS 调用 Native 功能的方法。在调用这个方法之前,我们一般需要先判断一下 Native 是否支持这个功能。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231154819491365376.png?Expires=4783565091&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=4WNVPdY2GiC%2BJEOCCyZLfGwO3c8%3D#1629965090662.png)
如果 Native 没有支持这个 Bridge,我们就需要对回调进行兼容性处理。这个兼容性处理包括两个方面,一个是功能方面,一个是 callback 的默认回参。
比如我们调用 Native 的弹窗功能,如果客户端没支持这个 Bridge,或者我们是在浏览器里面打开的这个页面,此时应该退出到使用 Web 的 alert 弹窗。
对于 callback,我们可以默认给传个 0,表示当前不支持这个功能。
假设这个 alert 的 bridge 接收两个参数,分别是 title 和 content,那么此时就应该使用浏览器自带的 alert 展示出来。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231155070611227264.png?Expires=4783565121&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=tqsooW%2BA2HTkgSHKqMx0HWNiXF8%3D#1629965120598.png)
这个 fallback 函数我们希望能够更加通用,每个调用方法都应该有自己的 fallback 函数,所以前面的 callHandler 应该设计成这样:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231155226018579073.png?Expires=4783565140&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=HAtJ4f9CV7EAHOvfrsnrKDR68N4%3D#1629965139119.png)
我们可以基于这个函数封装一些功能方法,比如前面的 alert:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231155368450261505.png?Expires=4783565157&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=KXsJRquOFkKu%2FcNs3pxFbeOtqbw%3D#1629965156100.png)
具体效果类似下面这种,这是从 Google 上随便找的一张图(侵删):
那么客户端又如何实现回调 callback 函数的呢?前面说过,客户端想调用 JS 方法,只能调用挂载到 window 对象上面的。
因此,这里使用了一种很巧妙的方法,实际上 callback 函数依然是 JS 执行的。
在调用 Native 之前,我们可以先将 callback 函数和一个 uniqueId 映射起来,然后存在 JS 本地。我们只需要将 callbackId 传给 Native 就行了。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231155577746031105.png?Expires=4783565182&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=qAL0%2BOaxxEBxu3D7ZF5CTxR3iJc%3D#1629965181035.png)
在客户端这里,当 send 方法接收到参数之后,会执行相应功能,然后使用 webView.loadUrl 主动调用前端的一个接收函数。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231155687460635137.png?Expires=4783565195&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=EqRZdw43%2B%2FaCRPGR8iaoqE6naRo%3D#1629965194130.png)
所以 JS 需要事前定义好这个 onReceive 方法,它接收一个 callbackId 和一个 result。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231155828473135617.png?Expires=4783565211&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=Wta%2BCCu5w1PtNq%2BsG1tbRwKlVHk%3D#1629965210940.png)
大致流程如下:
registerHandler
注册的流程比较简单,也是我们把 callback 函数事先存到一个 messageHandler 对象里面,不过这次的 key 不再是一个随机的 id,而是 name。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231156329229582976.png?Expires=4783565271&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=DWecwv%2Bkop2KnnH%2BtiKlRsmBPMc%3D#1629965270635.png)
这里不像 callHandler 需要主动调用 window.bridge.send 去通知客户端,只需要等客户端到了相应的时机来调用 window.bridge.onReceive 就行了。
所以这里还需要改造一下 onReceive 方法。由于不再会有 callbackId 了,所以客户端可以传个空值,然后将 handlerName 放到 result 里面。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231156502321627649.png?Expires=4783565292&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=7O%2F8d7uDvJuk97FYfSYC9I8Ka7s%3D#1629965291265.png)
这种情况下的流程如下,可以发现完全不需要 JS 调用 Native:
iOS Bridge
讲完了 Android,我们再来讲讲 iOS,原本 iOS 可以和 Android 设计一致,可是由于种种原因导致有不少差异。
iOS 和 Android 中最显著的差异就在于这个 window.bridge.send 方法的实现,Android 里面是直接调用 Native 的方法,iOS 中是通过 URL Scheme 的形式调用。
协议依然是 WebViewJavaScriptBridge 里面的协议,URL Scheme 本身不会传递数据,只是告诉 Native 有新的调用。
然后 Native 会去调用 JS 的方法,获取队列里面所有需要执行的方法。
所以我们需要事先创建好一个 iframe,插入到 DOM 里面,方便后续使用。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231156725936751105.png?Expires=4783565318&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=Lf8hqfChDoBCBm7e9Y3BRPTr2KU%3D#1629965317897.png)
callHandler
每次调用的时候只需要复用这个 iframe 就行了。这里是处理 callback 并通知 Native 的代码:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231156888935793152.png?Expires=4783565338&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=QWs2540tHl73AjbtLAeRysoP2mc%3D#1629965337314.png)
通知 Native 之后,它怎么拿到我们的 handlerName 和 data 呢?我们可以实现一个 fetchQueue 的方法。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231157455879864833.png?Expires=4783565406&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=X1tTj%2BE4OQxqmbSW2uWkp%2F3bnW4%3D#1629965404881.png)
然后将其挂载到 window.WebViewJavascriptBridge 对象上面。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231157744464757248.png?Expires=4783565440&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=%2F1MzhYB%2BxuywoJr7qeuk0wxwG6c%3D#1629965439337.png)
这样 iOS 就可以使用 evaluateJavaScript 轻松拿到这个 messageQueue。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231157860856693249.png?Expires=4783565454&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=EOhscxcSlyAYWpkJTUv0sgUuaWc%3D#1629965453198.png)
那么 iOS 又是如何回调 JS 的 callback 函数呢?这个其实和 Android 的 onReceive 是同样的原理。
这里可以实现一个 _handleMessageFromObjC 方法,同样挂载到 window.WebViewJavascriptBridge 对象上面,等待 iOS 回调。
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231157954446885505.png?Expires=4783565465&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=wv2FNrp3wtsp4jeybyv%2F6dHvIwE%3D#1629965464369.png)
流程如下图:
registerHandler
registerHandler 和 Android 原理是一模一样的,都是提前注册一个事件,等待 iOS 调用,具体就不多讲了,这里直接放代码:
![](http://jkyun.oss-cn-hangzhou.aliyuncs.com/longterm/53/system/wkdoc/533786189826392704/1231158097724310144.png?Expires=4783565482&OSSAccessKeyId=LTAIh08vjrfC7HV0&Signature=GxHjHBUUz5RRw73mUNkRh69kxks%3D#1629965481446.png)
总结
上述就是 Hybrid 开发中 JS 和 Native 交互的大致原理,经过上述介绍大家对这一技术应该有了一个系统的认识,但也忽略了不少细节(如初始化 WebViewJavascriptBridge 对象等等 ),谨此记录,感兴趣的也可以参考一下这个库:GitHub - lzyzsd/JsBridge: android java and javascript bridge, inspired by wechat webview jsbridge
期待三连