JSBridge
JSONP
在讲JSBridge前,先回顾一下JSONP的知识,因为它们的思想有共通之处:
简介
JSONP是JSON with Padding
的略称,前端定义好回调函数,函数名放在url中传给后端,后端拿到函数名,构造出执行函数的字符串返回给前端,前端收到后便会执行该回调,回调函数中可以拿到后端传过来的数据。
原理:src属性不受同源策略的限制,img
、script
等标签都不受同源策略的影响。
缺点:由于JSONP只支持get请求,且具有一定安全漏洞,一般不在实际场景中使用。
实现
以最简单的场景为例:
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <body> <span class="amount">100</span> <button class="btn">Btn</button> <script> const button = document.querySelector('.btn') const amount = document.querySelector('.amount')
button.addEventListener('click', (e) => { let script = document.createElement('script') let functionName = 'func' + parseInt(Math.random() * 10000, 10) window[functionName] = function (result) { console.log(result) if (result.name === 'success') { amount.innerText = amount.innerText - 1 } } script.src = `http://127.0.0.1:3000?callback=${functionName}` document.body.appendChild(script) script.onload = function (e) { e.currentTarget.remove() delete window[functionName] } script.onerror = function () { alert('Fail') e.currentTarget.remove() delete window[functionName] } }) </script> </body> </html>
import * as http from 'http' import * as url from 'url' import * as qs from 'qs'
const server = http.createServer((req, res) => { const { query } = url.parse(req.url) const params = qs.parse(query) const data = { name: 'success' } let str = ''
if (params.callback) { str = `${params.callback}(${JSON.stringify(data)})` res.end(str) } else { res.end() } })
server.listen(3000, () => { console.log('Server is running on port 3000...') })
|
前端发送请求的url可以是:http://127.0.0.1:3000?callback=func1234
后端获取回调函数名func1234
,构造好字符串func1234({ name: 'success' })
前端拿到该字符串后,就会调用回调函数,拿到后端传的数据。
可见,JSONP的模式大概是:前端随机生成了回调函数名,把函数名告诉后端,后端从url中解析出函数名,完成自身的逻辑后,将构造好的字符串返回给前端,前端执行回调。
JSB
H5 -> 前端,Native/原生 -> 客户端
安卓 -> Java/Kotlin,IOS -> OC/Swift
webview 是一个基于 webkit 的引擎,可以解析 DOM 元素,展示 html 页面的控件。
- 显示和渲染Web页面
- 直接使用html文件(网络上或本地assets中)作布局
- 可和JavaScript交互调用
—— Carson带你学Android:最全面、易懂的Webview使用教程
现在市面上的 App,基本上不是纯 Native 实现,客户端内置 webview,不少页面都嵌入了 H5,这种称作Hybrid App
,那么 H5 和 Native 必然要进行通信。
JSBridge,是沟通 JS 和 Native 的桥梁,为双向信道,使 JS 可以调 Native 的 Api,从而拥有部分原生的能力。这样,页面中的 H5 部分就可以使用地址位置、摄像头等原生才有的功能。同时,Native 也可以调用JS。
我们要实现的 JSB,功能大致为:前端向客户端发送请求,意在调用客户端的某方法,达到前端使用客户端能力的效果,前端也定义了回调函数,并且把回调函数名告知客户端。客户端拦截请求,执行对应的方法,然后通过特定 API(如下),调用前端的回调函数。
Native 调用 JS
安卓和IOS都提供了调用JS的方法,被调用的方法需要在 JS 全局上下文上。
其中安卓有两种方法可供选择:
1 2 3 4 5 6 7 8 9
| webview.loadUrl("javascript: func()");
webView.evaluateJavascript( "javascript:func()", ValueCallback { return@ValueCallback })
|
可见,客户端想调用前端只需要直接调用这些方法,把JS代码字符串传入即可。
方式 |
优点 |
缺点 |
loadUrl |
兼容性好 |
1. 会刷新页面 2. 无法获取 js 方法执行结果 |
evaluateJavascript |
1. 性能好 2. 可获取 js 执行后的返回值 |
仅在安卓 4.4 以上可用 |
由于现在98%以上的手机安卓版本>=5,所以采用 evaluateJavascript。
JS 调用 Native
JS 调用 Native 的实现方式一般至少有两种,分别是:拦截 URL Schema、注入 JS 上下文,本文介绍拦截 URL Schema的安卓实现。
拦截 URL SCHEME 的主要流程是:前端通过某种方式(例如 iframe.src)发送 URL Scheme 请求,之后 Native 拦截到请求并根据 URL SCHEME(包括所带的参数)进行相关操作。
URL SCHEME
URL SCHEME 是一种类似于url的链接,是为了方便app直接互相调用设计的,形式和普通的 url 近似,主要区别是 protocol 和 host 一般是自定义的,例如: jsbridge://showToast?msg=hello
,protocol 是 jsbridge,host 是 showToast。
jsbridge:// 只是一种规则,可以根据业务进行制定,使其具有含义。
在下面实现的时候,参数使用了JSON的形式,如jsbridge://showToast?{"data": {"msg": "hello"}, "callbackName": "callback1234"}
前端实现
接下来分别看看前端和客户端应该如何配合,实现相互通信:
规定 protocol 为 jsbridge,即 URL SCHEME 以 jsbridge:// 开头,客户端拿到 url 后进行解析,如果以 jsbridge 开头,则执行对应逻辑。
前端和 JSONP 一样,随机生成一个回调函数名,把回调函数绑定在 window 上,URL SCHEME 作为请求的 url,URL SCHEME 中包含了数据、以及回调函数名,然后使用 iframe 发送请求。
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 31 32 33 34 35
| class JSBridge { static call(methodName, arg, callback) { const args = { data: arg === undefined ? null : JSON.stringify(arg), } if (typeof callback === 'function') { const callbackName = 'CALLBACK' + parseInt(Math.random() * 10000) window[callbackName] = callback args['callbackName'] = callbackName } const url = 'jsbridge://' + methodName + '?' + JSON.stringify(args) const iframe = document.createElement('iframe') iframe.src = url iframe.style.display = 'none' document.body.appendChild(iframe) window.setTimeout(() => { document.body.removeChild(iframe) }, 1000); } }
const arg = { msg: "The message is from JS!", };
JSBridge.call('showToast', arg, (res) => { alert(res.msg); });
|
安卓实现
NativeMethods
既然前端请求了客户端的 showToast 方法,先来实现该方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class NativeMethods { fun showToast(view: WebView, arg: JSONObject, callBack: CallBack) { val message = arg.optString("msg") Toast.makeText(view.context, message, Toast.LENGTH_SHORT).show() try { val result = JSONObject() result.put("msg", "js 调用 native 成功!") callBack.apply(result) } catch (e: Exception) { e.printStackTrace() } } }
|
原理很简单,拿到前端传过来的 msg,展示在 Toast 里,然后调用 JS 的回调函数,此处使用了 callBack.apply 方法,来看看该方法:
1 2 3 4 5 6 7 8 9
| class CallBack(private val mWebView: WebView?, private val callbackName: String) { fun apply(jsonObject: JSONObject) { mWebView?.evaluateJavascript( "javascript:$callbackName($jsonObject)", ValueCallback { return@ValueCallback }) } }
|
CallBack 类仅仅是作了一层封装,唯一做的事就是调了 evaluateJavascript 方法,即 Native 调 JS。
callbackName 是在别的地方传给 CallBack 类的,见下文。
JSBridge.register
然后看看客户端的 JSBridge 类,其主要有 register 和 call 两个方法。
register 用于将客户端暴露出来的方法塞到 hashmap 中,为一个双层的 map 结构,里层 map 的 key 为方法名,val 为 method 的反射。
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| { JSBridge: { showToast: (showToast方法的反射) } }
object JSBridge { private val exposeMethods: MutableMap<String, HashMap<String?, Method>> = HashMap() private val classAndMethods: MutableMap<String, HashMap<String?, Method>> = HashMap() fun register(exposeName: String, classz: Class<*>, className: String) { val allMethods = getAllMethod(classz) if (!exposeMethods.containsKey(exposeName)) { exposeMethods[exposeName] = allMethods } if (!classAndMethods.containsKey((className))) { classAndMethods[className] = allMethods } } private fun getAllMethod(injectedCls: Class<*>): HashMap<String?, Method> { val methodHashMap = HashMap<String?, Method>() val methods = injectedCls.declaredMethods for (method in methods) { val modifiers = method.modifiers if (!Modifier.isPublic(modifiers)) { continue } val parameters = method.parameterTypes if (parameters.size == 3) { if (parameters[0] == WebView::class.java && parameters[1] == JSONObject::class.java && parameters[2] == CallBack::class.java) { methodHashMap[method.name] = method } } } return methodHashMap } fun call () { ... } }
|
JSBridge.call
call 用来响应前端的请求,由上文可知,从 URL SCHEME 中可以拿到方法名,然后可以在 hashmap 中查找,拿到方法的反射,最后使用 method.invoke()
来调用该方法。
注意前面声明了变量 classAndMethods,这个数据结构和 exposeMethods 类似,只不过 key 就是类名,设立这个数据结构的原因是,method.invoke() 的第一个参数要求传所调用方法所属类的实例对象
,此处 showToast 属于 NativeMethods 类,所以要传入它的实例对象,而我实在没搞懂如何根据方法的反射,获取该类的实例对象,因此使用了不优雅的实现,即先创建了 hashmap,然后用两层 for 循环去查出类名,最后获取实例对象,当作 invoke 的第一个参数传入。
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| fun call(webView: WebView?, urlString: String?): String? { if (urlString != "" && urlString != null && urlString.startsWith("jsbridge")) { val uri = Uri.parse(urlString) val methodName = uri.host try { val args = JSONObject(uri.query) val arg = JSONObject(args.getString("data")) val callbackName = args.getString("callbackName") if (exposeMethods.containsKey("JSBridge")) { val methodHashMap = exposeMethods["JSBridge"] if (methodHashMap != null && methodHashMap.size != 0 && methodHashMap.containsKey( methodName ) ) { val method = methodHashMap[methodName] var className = "" for ((_className, methods) in classAndMethods){ for ((_, methodReflection) in methods) { if (methodReflection == method) { className = _className } } } val instance = Class.forName(className).newInstance() method?.invoke(instance, webView, arg, CallBack(webView, callbackName)) } } } catch (e: Exception) { e.printStackTrace() } } return null }
|
到这一步,还有一个问题,就是如何在哪里执行 call 方法。
webview 当收到跳转请求时,会经过 WebViewClient 类的 shouldOverrideUrlLoading 方法,因此我们需要覆写该方法。假设我们请求的 url 为https://www.baidu.com
如果 return false,webview 会继续加载百度的页面,如果 return true,则会拦截该请求,停止加载,这里应该将请求拦截,调用 JSBridge.call 方法。
1 2 3 4 5 6 7 8 9
| import com.example.urlschema.JSBridge.call
class JSBridgeViewClient : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { call(view, url) return true } }
|
这一切都完成后,JSBridge 就可以正常工作了,如图:
总结
JSONP 和 JSB 还是有一定相似之处的,都是由前端发起请求,构造url,告诉了对方回调函数的名称,经过 客户端/后端 的处理,再执行前端的回调函数。
由于笔者对安卓只是初步入门的水平,Java/Kotlin 也只是了解的程度,不准确的地方希望大家谅解。
参考资料
mcuking/JSBridge
SDBridge/SDBridgeKotlin
Hybrid App技术解析 – 原理篇
Demo
Demo,需要使用Android Studio运行