Dec 12

目前Webview同步桥(Synchronous Bridge)落地方案

Lrdcq , 2021/12/12 11:03 , 程序 , 閱讀(3543) , Via 本站原創
在做Web容器方式时,业界目前一般采用的方案都是WebViewJavaScriptBridge拓展于Android/iOS上的实现。原理是基于iframe url request或者webkit.messageHandlers/Android JavascriptInterface进行js->native通信,evalJavascript的native->js通信,并相互维护call queue来保持对外一致。显然这个实现方案暴露的Bridge API必须是异步的,可以抽象为
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)。
logo