Fork me on GitHub

h5与原生交互及原理

为什么要在App中嵌入H5页面?

由于app原生页面每次有更新,都需要重新打包一次发布到应用平台上,且每次要向各个应用商店进行提交审核。之后用户需要手动进行点击更新安装,安装成本较高。

h5开发速度快,一端开发多端运行,如果APP用户常见页面频换,那么用H5,维护起来更容易。迭代版本时,不需要打包便可以发布(实时更新、快速迭代),与云端实现实时数据交互。

h5与原生app交互

移动端 web 应用,很多时候都需要与原生 app 进行交互、沟通(运行在 webview 中),比如微信的 jssdk,通过 window.wx 对象调用一些原生 app 的功能。
h5 与原生 app 的交互,本质上说,就是两种调用:app 调用 h5 的代码/h5 调用 app 的代码

app调用h5的代码

因为 app 是宿主,可以直接访问 h5,所以这种调用比较简单,就是在 h5 中曝露一些全局对象(包括方法),然后在原生 app 中调用这些对象。

h5调用app的代码

因为 h5 不能直接访问宿主 app,所以这种调用就相对复杂一点。
JavaScript调用Native的方式,主要有两种:注入api和拦截url scheme。

  • 注入api:由app向h5注入一个全局 js 对象,然后在h5直接访问这个对象
  • 拦截url scheme:由h5发起一个自定义协议请求,app 拦截这个请求后,再由 app 调用 h5 中的回调函数。这种方式要稍复杂一点,因为需要自定义协议,可以作为第一种方式的补充。
    • 由 app 自定义协议,比如 sdk://action?params
    • 在 h5 定义好回调函数,比如 window.bridge = {getDouble: value => {}, getTriple: value => {}}
    • 由 h5 发起一个自定义协议请求,比如 location.href = ‘sdk://double?value=10’
    • app 拦截这个请求后,进行相应的操作,获取返回值
    • 由 app 调用 h5 中的回调函数,比如 window.bridge.getDouble(20);

JSBridge

JSBridge的用途

JSBridge 就像其名称中的『Bridge』的意义一样,是 Native和非Native之间的桥梁,它的核心是构建 Native 和非Native间消息通信的通道,而且是双向通信的通道。

  • JS向Native发送消息:调用相关功能、通知Native当前JS的相关状态等。
  • Native向JS发送消息:回溯调用结果、消息推送、通知JS当前Native的状态等。

疑问:消息都是单向的,那么调用 Native 功能时 Callback 如何实现?

JSBridge的实现原理

JavaScript 是运行在一个单独的 JS Context 中(例如,WebView的Webkit引擎、JSCore)。由于这些 Context 与原生运行环境的天然隔离,我们可以将这种情况与 RPC(Remote Procedure Call,远程过程调用)通信进行类比,将 Native 与 JavaScript 的每次互相调用看做一次 RPC 调用。

在 JSBridge 的设计中,可以把前端看做 RPC 的客户端,把 Native 端看做 RPC 的服务器端,从而 JSBridge 要实现的主要逻辑就出现了:通信调用(Native 与 JS 通信)和句柄解析调用。这个流程类似JSONP的流程。

JSBridge通信原理

1. JS调用Native

通过上面的介绍,我们知道JavaScript 调用 Native 的方式主要有两种:注入api和拦截url scheme。

1.1 注入api

注入 API 方式的主要原理是,通过 WebView 提供的接口,向 JavaScript 的 Context(window)中注入对象或者方法,让 JavaScript 调用时,直接执行相应的 Native 代码逻辑,达到 JavaScript 调用 Native 的目的。

对于 iOS 的 UIWebView,实例如下:

1
2
3
4
5
JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

context[@"RenrencheJSBridge"] = ^(NSArray<NSArray *> *calls) {
// Native 逻辑
};

前端调用方式:

1
window.RenrencheJSBridge(message);

对于 iOS 的 WKWebView,实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@interface WKWebVIewVC ()<WKScriptMessageHandler>

@implementation WKWebVIewVC

- (void)viewDidLoad {
[super viewDidLoad];

WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
configuration.userContentController = [[WKUserContentController alloc] init];
WKUserContentController *userCC = configuration.userContentController;
// 注入对象,前端调用其方法时,Native 可以捕获到
[userCC addScriptMessageHandler:self name:@"nativeBridge"];

WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];

// TODO 显示 WebView
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"GoBack"]) {
NSLog(@"前端传递的数据 %@: ",message.body);
// Native 逻辑
}
}

前端的调用方式:

1
window.webkit.messageHandlers.GoBack.postMessage(params);

对于 Android 可以采用下面的方式:

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
public class JavaScriptInterfaceDemoActivity extends Activity{
private WebView Wv;

@Override
public voidon Create(Bundle savedInstanceState){
super.onCreate(savedInstanceState);

Wv = (WebView)findViewById(R.id.webView);
final JavaScriptInterface myJavaScriptInterface = new JavaScriptInterface(this);

Wv.getSettings().setJavaScriptEnabled(true);
Wv.addJavascriptInterface(myJavaScriptInterface, "nativeBridge");

// TODO 显示 WebView

}

public class JavaScriptInterface{
Context mContext;

JavaScriptInterface(Context c) {
mContext = c;
}

publicvoidpostMessage(String webMessage){
// Native 逻辑
}
}
}

前端调用方式:

1
window.nativeBridge.postMessage(message);

⚠️ 在 4.2 之前,Android 注入 JavaScript 对象的接口是 addJavascriptInterface,但是这个接口有漏洞,可以被不法分子利用,危害用户的安全,因此在 4.2 中引入新的接口 @JavascriptInterface(上面代码中使用的)来替代这个接口,解决安全问题。所以 Android 注入对对象的方式是 有兼容性问题的。

1.2 拦截url scheme

url scheme是一种类似于url的链接,是为了方便app直接互相调用设计的,形式和普通的 url 近似,主要区别是 protocol 和 host 一般是自定义的,例如: qunarhy://hy/url?url=ymfe.tech,protocol 是 qunarhy,host 则是 hy。

缺陷:

  • 使用 iframe.src 发送 URL SCHEME 会有 url 长度的隐患。
  • 创建请求,需要一定的耗时,比注入 API 的方式调用同样的功能,耗时会较长。

为什么之前要采用这种不优雅的方式?因为它支持iOS6,但是现在基本可以忽略

⚠️ 通过 location.href 连续调用 Native,很容易丢失一些调用。

2. Native调用JS

相比于 JavaScript 调用 Native, Native 调用 JavaScript 较为简单,毕竟不管是 iOS 的 UIWebView 还是 WKWebView,还是 Android 的 WebView 组件,都以子组件的形式存在于 View/Activity 中,直接调用相应的 API 即可。

Native 调用 JavaScript,其实就是执行拼接 JavaScript 字符串,从外部调用 JavaScript 中的方法,因此 JavaScript 的方法必须在全局的 window 上。

对于 iOS 的 UIWebView,示例如下:

1
result = [uiWebview stringByEvaluatingJavaScriptFromString:javaScriptString];

对于 iOS 的 WKWebView,示例如下:

1
[wkWebView evaluateJavaScript:javaScriptString completionHandler:completionHandler];

对于 Android,在 Kitkat(4.4)之前并没有提供 iOS 类似的调用方式,只能用 loadUrl 一段 JavaScript 代码,来实现:

1
webView.loadUrl("javascript:" + javaScriptString);

而 Kitkat 之后的版本,也可以用 evaluateJavascript 方法实现:

1
2
3
4
5
6
webView.evaluateJavascript(javaScriptString, new ValueCallback<String>() {
@Override
publicvoidonReceiveValue(String value){

}
});

⚠️ 使用 loadUrl 的方式,并不能获取 JavaScript 执行后的结果。

通信原理总结

  • JavaScript 调用 Native 推荐使用 注入 API 的方式(iOS6 忽略,Android 4.2以下使用 WebViewClient 的 onJsPrompt 方式)。
  • Native 调用 JavaScript 则直接执行拼接好的 JavaScript 代码即可。

对于其他方式,诸如 React Native、微信小程序 的通信方式都与上描述的近似,并根据实际情况进行优化。

以 React Native 的 iOS 端举例:JavaScript 运行在 JSCore 中,实际上可以与上面的方式一样,利用注入 API 来实现 JavaScript 调用 Native 功能。

不过 React Native 并没有设计成 JavaScript 直接调用 Object-C,而是 为了与 Native 开发里事件响应机制一致,设计成 需要在 Object-C 去调 JavaScript 时才通过返回值触发调用。原理基本一样,只是实现方式不同。

当然不仅仅 iOS 和 Android,其他手机操作系统也用相应的 API,例如 WMP(Win 10)下可以用 window.external.notify 和 WebView.InvokeScript/InvokeScriptAsync 进行双向通信。其他系统也类似。

JSBridge封装

JSBridge 的接口主要功能有两个:

  • 调用 Native(给 Native 发消息)
  • 被 Native 调用(接收 Native 消息)。

因此JSBridge可以设计成如下:

1
2
3
4
5
6
7
8
9
10
class Bridge {
constructor(options = {}) {
this.options = options
}
init () {}
// 提供给 APP 调用
_dispatchMessageFromNative () {}
// 向 APP 发起消息
send () {}
}

上面的疑问:消息都是单向的,那么调用 Native 功能时 Callback 怎么实现的?
对于 JSBridge 的 Callback ,其实就是 RPC 框架的回调机制。也可以用更简单的 JSONP 机制解释:

当发送JSONP请求时,url参数里会有callback参数,其值是当前页面唯一的,而同时以此参数值为key将回调函数存到window上,随后,服务器返回 script中,也会以此参数值作为句柄,调用相应的回调函数。

由此可见,callback参数这个唯一标识是这个回调逻辑的关键。这样,我们可以参照这个逻辑来实现JSBridge:用一个自增的唯一id,来标识并存储回调函数,并把此id以参数形式传递给 Native,而Native也以此id作为回溯的标识。这样,即可实现Callback回调逻辑。

实战应用:

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
class Bridge {
constructor(options = {}) {
this.options = options
this.uniqueId = 1
this.responseCallbacks = {}
}
init () {
const bridgeObj = {
_dispatchMessageFromNative: this._dispatchMessageFromNative.bind(this)
}
window.WebViewJavascriptBridge = bridgeObj
}
// 提供给 APP 调用
_dispatchMessageFromNative (messageJSON) {
const { responseCallbacks } = this
setTimeout(() => {
let message = ''
let error = null

try {
message = JSON.parse(messageJSON)
} catch (e) {
error = e
throw new Error(error)
}

if (message.responseId) {
const responseCallback = responseCallbacks[message.responseId]
if (!responseCallback) {
return
}

const $data = isAnroid ? JSON.parse(message) : message.responseData
responseCallback($data, error)
delete responseCallbacks[message.responseId]
}
}, 0)
}
// 向 APP 发起消息
send (action, data = {}, responseCallback) {
let { uniqueId } = this
const { responseCallbacks } = this
const message = { action, data }

// TODO: 兼容老版本 Android Ios
if (window.RenrencheJSBridge) {
const params = isAnroid ? JSON.stringify(message) : message
try {
let nativeData = window.RenrencheJSBridge.nativeBridge(params)
if (responseCallback && typeof responseCallback === 'function') {
nativeData = isAnroid ? JSON.parse(nativeData) : nativeData
responseCallback(nativeData)
}
} catch (error) {
throw new Error('RenrencheJSBridge nativeBridge Error')
}
} else {
if (responseCallback) {
const callbackId = `cb_${uniqueId += 1}_${new Date().getTime()}`
responseCallbacks[callbackId] = responseCallback
message.callback = callbackId
}
const params = isAnroid ? JSON.stringify(message) : message
try {
window.webkit.messageHandlers[injectedName].postMessage(params)
} catch (e) {
throw new Error('wkwebview postMessage Error')
}
}
}
}

⚠️ 注:

  • 在 Native 端配合实现 JSBridge 的 JavaScript 调用 Native 逻辑:
    接收到 JavaScript 消息 => 解析参数,拿到 bridgeName、data 和 callbackId => 根据 bridgeName 找到功能方法,以 data 为参数执行 => 执行返回值和 callbackId 一起回传前端。
  • Native 调用 JavaScript:
    直接自动生成一个唯一的 ResponseId,并存储句柄,然后和 data 一起发送给前端

参考文章:
https://juejin.im/post/5abca877f265da238155b6bc
https://mp.weixin.qq.com/s/vwIRE3nYzoG-PfvfT45Wsg

本文标题:h5与原生交互及原理

文章作者:tongtong

发布时间:2019年04月11日 - 16:04

最后更新:2019年04月16日 - 20:04

原始链接:https://ilove-coding.github.io/2019/04/11/h5与原生交互及原理/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!
-------------本文结束-------------