Dec
12
在做Web容器方式时,业界目前一般采用的方案都是WebViewJavaScriptBridge拓展于Android/iOS上的实现。原理是基于iframe url request或者webkit.messageHandlers/Android JavascriptInterface进行js->native通信,evalJavascript的native->js通信,并相互维护call queue来保持对外一致。显然这个实现方案暴露的Bridge API必须是异步的,可以抽象为
但是实际开发过程中异步bridge还是有不便之处,如storage / environmentinfo这类的桥,同步桥还是更方便易用。
虽然同步桥的风险是显而易见的——native的线程卡顿如io卡顿/死锁等会直接暴露到web端导致页面卡死,但是对于web开发的角度来说这完全是可以接受的——比较原本大量dom api如localstorage就是同步api也同样是native实现,对于web来说风险是一致的——storage桥如果系统io卡顿,我们的birdge卡的话localstorage也卡。结论上,Web容器提供同步bridge更合理。因此本文讨论同步桥实现方案。
编写同步桥时参考了业界容器方案相关有无同步桥实现——Web容器的开源方案里并没有,不过React-Native倒是提供同步桥。观察RN的同步桥实现方案——通过c++桥接直接在js上下文中注入了同步方法nativeCallSyncHook:
在js中global.nativeCallSyncHook(moduleID, methodID, params)统一桥接暴露的方法。
但是对于Webview来说问题来了,使用系统提供的webview我们根本没有jscore的全权控制权,更别说通过c++注入自定义函数了。因此完成这件事的核心,是在webview的js中寻找一个实际行为为异步但是js中表现为同步的API,并且这个API可以被native拦截。目前可用的API有以下两个方案:
1. Prompt拦截
标准Dom API中prompt函数类似于alert,let result = prompt('title', 'defaultinfo')默认行为即是弹出一个给定title与defaultinfo的输入框的dialog等待用户输入,阻塞js并返回输入结果字符串。因此这是一个典型的实际行为为异步但是js中表现为同步的API。因此我们可以将同步桥的实现封装为序列化数据通过Prompt通信来实现。
native如何拦截呢?在Android中,自定义WebChromeClient可以重写onJsPrompt方法来处理,如下面这段代码:
iOS中,目前只考虑WKWebview的话,在WKUIDelegate中也有相应方法runJavaScriptTextInputPanelWithPrompt去承接:
2. Synchronous XMLHttpRequest request拦截
浏览器中XMLHttpRequest也是支持同步请求的,通过类似于:
虽然使用得很少,但是目前两端的Webview在主线程中执行同步XMLHttpRequest是可行的。并且这个方式相对于Prompt方案有一个优势,即xhr在Web Worker也可以直接使用因此worker中也可以提供同步桥,而Prompt方案和传统的WebViewJavaScriptBridge不少实现方案由于重依赖Dom API,在worker中反而用不了。在Worker使用越来越广泛的今天,这也是一个考虑因素。
而native的角度,Android和iOS的WKWebview做网络请求拦截也确实都有各自的特点与困难,只是正好这个方案基本规避到。Android的webview请求拦截依然是WebViewClient中的接口:
要注意几点:
a. WebResourceRequest是缺少request的body的,因此从js中带上来的数据只能塞到url中或者header中。
b. shouldInterceptRequest是在子线程中的同步方法,因此callNativeMethodSync的实际实现也是同步方法,需要跨线程加锁来完成java中的同步转异步的工作。
对于iOS来说,UIWebview的请求拦截其实很简单,来一个NSUrlProtocol直接对特定请求进行拦截就好。但是目前应该都转换为WKWebview了,这玩意儿相对麻烦了,需要实现WKURLSchemeHandler并塞到WKWebViewConfiguration中来进行拦截,代码看起来是:
这边要注意几点是:
a. 相对于Android,iOS这边的startURLSchemeTask倒是是异步方法因此写起来更简单一些,整体要做的工作和Android是一致的。
b. 同时提供了stopURLSchemeTask来处理request cancel的情况,不过对于桥调用,就算有异常情况下系统主动调用了cancel(如页面离开时请求未完成),异步调用大概率也无法处理cancel,所以可以考虑不处理。如果考虑处理cancel的话,需要维护urlSchemeTask队列逻辑相对更复杂了。
——————
目前可用的同步桥直接实现方案即如上两种,还在探索还有没有可用的口子。另外基于经验上可用性可以考虑实际工程中互做降级组合使用(如一般web环境中prompt和xhr一起ab,worker则只使用xhr)。
callAPI(apiName: string, param: array, callback: function(result: any): void): void;
但是实际开发过程中异步bridge还是有不便之处,如storage / environmentinfo这类的桥,同步桥还是更方便易用。
虽然同步桥的风险是显而易见的——native的线程卡顿如io卡顿/死锁等会直接暴露到web端导致页面卡死,但是对于web开发的角度来说这完全是可以接受的——比较原本大量dom api如localstorage就是同步api也同样是native实现,对于web来说风险是一致的——storage桥如果系统io卡顿,我们的birdge卡的话localstorage也卡。结论上,Web容器提供同步bridge更合理。因此本文讨论同步桥实现方案。
编写同步桥时参考了业界容器方案相关有无同步桥实现——Web容器的开源方案里并没有,不过React-Native倒是提供同步桥。观察RN的同步桥实现方案——通过c++桥接直接在js上下文中注入了同步方法nativeCallSyncHook:
//JSIExecutor.cpp
void JSIExecutor::initializeRuntime() {
//...
runtime_->global().setProperty(
*runtime_,
"nativeCallSyncHook",
Function::createFromHostFunction(
*runtime_,
PropNameID::forAscii(*runtime_, "nativeCallSyncHook"),
1,
[this](
jsi::Runtime &,
const jsi::Value &,
const jsi::Value *args,
size_t count) { return nativeCallSyncHook(args, count); }));
//...
}
Value JSIExecutor::nativeCallSyncHook(const Value *args, size_t count) {
//...为了方便理解简化了代码
unsigned int moduleId = static_cast<unsigned int>(args[0].getNumber());
unsigned int methodId = static_cast<unsigned int>(args[1].getNumber());
std::string moduleName = moduleRegistry_->getModuleName(moduleId);
std::string methodName = moduleRegistry_->getModuleSyncMethodName(moduleId, methodId);
MethodCallResult result = delegate_->callSerializableNativeHook(
*this, moduleId, methodId, dynamicFromValue(*runtime_, args[2]));
Value returnValue = valueFromDynamic(*runtime_, result.value());
return returnValue;
}
在js中global.nativeCallSyncHook(moduleID, methodID, params)统一桥接暴露的方法。
但是对于Webview来说问题来了,使用系统提供的webview我们根本没有jscore的全权控制权,更别说通过c++注入自定义函数了。因此完成这件事的核心,是在webview的js中寻找一个实际行为为异步但是js中表现为同步的API,并且这个API可以被native拦截。目前可用的API有以下两个方案:
1. Prompt拦截
标准Dom API中prompt函数类似于alert,let result = prompt('title', 'defaultinfo')默认行为即是弹出一个给定title与defaultinfo的输入框的dialog等待用户输入,阻塞js并返回输入结果字符串。因此这是一个典型的实际行为为异步但是js中表现为同步的API。因此我们可以将同步桥的实现封装为序列化数据通过Prompt通信来实现。
native如何拦截呢?在Android中,自定义WebChromeClient可以重写onJsPrompt方法来处理,如下面这段代码:
//class XXX extends WebChromeClient
@Override
boolean onJsPrompt (WebView view, String url, String message, String defaultValue, JsPromptResult result) {
//
JsonObject obj = JsonObject.反序列化(message);
String className = obj.get('className');
String methodName = obj.get('methodName');
List params = obj.get('params');
this.callNativeMethod(className, methodName, params, new Callback() {
public void onSuccess(Object res) {
JsonObject obj = new JsonObject();
obj.put('status', 1);
obj.put('data', res);
result.confirm(obj.序列化());
}
public void onError(Exception exp) {
JsonObject obj = new JsonObject();
obj.put('status', 0);
obj.put('error', exp);
result.confirm(obj.序列化());
}
});
return true;
}
iOS中,目前只考虑WKWebview的话,在WKUIDelegate中也有相应方法runJavaScriptTextInputPanelWithPrompt去承接:
//WKUIDelegate
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler {
NSDictionary *obj = JsonUtils.反序列化(prompt);
NSString *className = obj[@"className"];
NSString *methodName = obj[@"methodName"];
NSArray *params = obj[@"params"];
[self callNativeMethod:className method:methodName params:params callback:^(id res, NSError *err){
if (err) {
completionHandler(JsonUtils.序列化(@{@"status": @0, @"error": err}));
} else {
completionHandler(JsonUtils.序列化(@{@"status": @1, @"data": res}));
}
}];
}
2. Synchronous XMLHttpRequest request拦截
浏览器中XMLHttpRequest也是支持同步请求的,通过类似于:
var request = new XMLHttpRequest();
request.open('GET', 'mybridge://method?class=A&method=B¶ms=[...]', false); //这个false指定的同步请求
request.send();
if (request.status !== 200) {
return null;
}
const result = JSON.parse(request.responseText);
虽然使用得很少,但是目前两端的Webview在主线程中执行同步XMLHttpRequest是可行的。并且这个方式相对于Prompt方案有一个优势,即xhr在Web Worker也可以直接使用因此worker中也可以提供同步桥,而Prompt方案和传统的WebViewJavaScriptBridge不少实现方案由于重依赖Dom API,在worker中反而用不了。在Worker使用越来越广泛的今天,这也是一个考虑因素。
而native的角度,Android和iOS的WKWebview做网络请求拦截也确实都有各自的特点与困难,只是正好这个方案基本规避到。Android的webview请求拦截依然是WebViewClient中的接口:
public WebResourceResponse shouldInterceptRequest(WebView view, final WebResourceRequest request) {
if (request != null && request.getUrl() != null) {
String scheme = request.getUrl().getScheme().trim();
if (scheme.equalsIgnoreCase("mybridge")) {
JsonObject obj = RequestUtils.解析(request.getUrl().getRequest());
String className = obj.get('className');
String methodName = obj.get('methodName');
List params = obj.get('params');
String data = null;
try {
Object res = this.callNativeMethodSync(className, methodName, params);
JsonObject obj = new JsonObject();
obj.put('status', 1);
obj.put('data', res);
data = obj.序列化();
} catch (Exception exp) {
JsonObject obj = new JsonObject();
obj.put('status', 0);
obj.put('error', exp);
data = obj.序列化();
}
return new WebResourceResponse("application/json", "UTF-8", 200, "", null, new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)));
}
}
return super.shouldInterceptRequest(view, request);
}
要注意几点:
a. WebResourceRequest是缺少request的body的,因此从js中带上来的数据只能塞到url中或者header中。
b. shouldInterceptRequest是在子线程中的同步方法,因此callNativeMethodSync的实际实现也是同步方法,需要跨线程加锁来完成java中的同步转异步的工作。
对于iOS来说,UIWebview的请求拦截其实很简单,来一个NSUrlProtocol直接对特定请求进行拦截就好。但是目前应该都转换为WKWebview了,这玩意儿相对麻烦了,需要实现WKURLSchemeHandler并塞到WKWebViewConfiguration中来进行拦截,代码看起来是:
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask {
NSURLRequest *taskRequest = urlSchemeTask.request;
NSDictionary *obj = UrlUtils.反序列化(taskRequest.URL);
NSString *className = obj[@"className"];
NSString *methodName = obj[@"methodName"];
NSArray *params = obj[@"params"];
[self callNativeMethod:className method:methodName params:params callback:^(id res, NSError *err){
NSString *data = nil;
if (err) {
data = JsonUtils.序列化(@{@"status": @0, @"error": err});
} else {
data = JsonUtils.序列化(@{@"status": @1, @"data": res});
}
[urlSchemeTask didReceiveResponse:[[NSURLResponse alloc] initWithURL:taskRequest.URL MIMEType:@"application/json" expectedContentLength:0 textEncodingName:@"UTF-8"]];
[urlSchemeTask didReceiveData:[data dataUsingEncoding:NSUTF8StringEncoding]];
[urlSchemeTask didFinish];
}];
}
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask {
//好像无法做到
}
这边要注意几点是:
a. 相对于Android,iOS这边的startURLSchemeTask倒是是异步方法因此写起来更简单一些,整体要做的工作和Android是一致的。
b. 同时提供了stopURLSchemeTask来处理request cancel的情况,不过对于桥调用,就算有异常情况下系统主动调用了cancel(如页面离开时请求未完成),异步调用大概率也无法处理cancel,所以可以考虑不处理。如果考虑处理cancel的话,需要维护urlSchemeTask队列逻辑相对更复杂了。
——————
目前可用的同步桥直接实现方案即如上两种,还在探索还有没有可用的口子。另外基于经验上可用性可以考虑实际工程中互做降级组合使用(如一般web环境中prompt和xhr一起ab,worker则只使用xhr)。