iOS客户端与Web端交互

806 阅读7分钟

前言

客户端开发和前端开发,不可避免的会经常遇到需要相互通信的场景。正巧前段时间工作上遇到了一个交互的场景,趁机研究整理一下,以下是自己的理解。

一、客户端如何调用Web端?

UIWebView时代,有两种:

  • 方法一:UIWebView实例直接执行- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script方法
[webView stringByEvaluatingJavaScriptFromString:@"js command"];
  • 方法二:获取到UIWebView的JSContext,利用JSContext实例去执行- (JSValue *)evaluateScript:(NSString *)script方法
- (void)webViewDidFinishLoad:(UIWebView *)webView {
    JSContext *jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
    [jsContext evaluateScript:@"js command"];
}

最开始怀疑UIWebView最终是通过JSContext来执行的Script,但是通过验证发现并不是。

验证方式:

1.通过Aspect框架,hook到JSContext的- (JSValue *)evaluateScript:(NSString *)script方法;

2.然后利用UIWebView实例去调用- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script

3.发现hook的方法并不会调用

WKWebView时代,因为获取不到JSContext,只有一种方式:

由WKWebView的实例执行- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)( _Nullable id, NSError * _Nullable error))completionHandler

[webView evaluateJavaScript:@"js command" completionHandler:^(NSString* result, NSError* error) {}];

二、Web端如何调用客户端?

UIWebView时代,有两种:

  • 方法一:Web端通过创建iframe等方式,通过加载新的“文档”,这样会触发原生UIWebView的相关代理,利用传递过来的信息进行原生方法调用

    JS端创建iframe进行调用

      var iframe = document.createElement('iframe');
      iframe.style.display = 'none';
      iframe.src = 'http://loadurl';
      document.documentElement.appendChild(iframe);
      window.MSF_Iframe = iframe;
    

    原生在webView代理里拦截进行处理

    - (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
          NSURL *url = request.URL;
          // 发现是自定义的url,就拦截掉开始处理
      }
    
  • 方法二:获取到UIWebView的JSContext后,给JSContext添加方法/对象等,Web端即可以调用原生。

      - (void)webViewDidFinishLoad:(UIWebView *)webView {
          JSContext *jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
          // 给JS端添加了一个JSInVokeOC方法,JS端调用该方法,就会触发下面的block
          context[@"JSInVokeOC"] = ^id(JSValue *obj) {
             return @{};
          };
      }
    

    这种方式是利用JavaScriptCore框架的能力,在Web端给global对象创建了对应的方法,对象。

WKWebView时代,有两种:

  • 方法一:和上面的UIWebView一样,Web端可以通过加载新的“文档”,触发原生相关代理方法,在代理方法里可以根据信息来进行方法调用

  • 方法二:利用系统已经提供好的bridge

    原生通过相关api注册好方法

    [webView.configuration.userContentController addScriptMessageHandler:self name:@"jsInvokeNative"];
    

    Web端通过以下方式调用原生

    window.webkit.messageHandlers.jsInvokeNative.postMessage()
    

通信问题是解决了,但是在实际使用中就会发现,缺少一个非常关键的事情:调用对方后,一方执行完成了,如何把对应的信息回复给对方?

三、如何处理回调?

原理是:各端在调用的时候记录好调用方法的回调和回调标识,另一端处理完消息后,根据回调标识,再次调用对方。

实际案例: 之前对接广告,广告侧要求业务方给Webview注入一个JSBridge,JSBridge需要提供invoke方法,以供Web端调用原生的方法,来获取是否登陆等信息。

下面我们一步一步来实现:

  1. 原生利用Webview,调用- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)( _Nullable id, NSError * _Nullable error))completionHandler方法,给Web环境中的window声明一个JSBridge对象

    JSBridge对象包含以下三个内容:

    • invoke方法,用来调用原生
    • 有个Native回调JS的方法
    • 存在一个集合,用来存放等待原生回调的函数
  2. invoke方法里,是创建一个iframe,将Web端需要传递的参数信息拼接到iframe的url上去调用原生

  3. 原生在WebView相关代理方法里拦截到这个url的信息后,开始调用原生的方法进行处理 (信息来到原生,原生侧开始处理消息)

  4. 原生处理完成后,要根据之前url里给到的回调函数信息,再次调用Web端的方法,将相关信息给到web端

JS代码示例如下:

NSString * MSF_inject_jsBridge_js() {
#define msf_js_func__(x) #x
    // BEGIN preprocessorJSCode
    static NSString * preprocessorJSCode = @msf_js_func__(
     ;
     function setupWebViewJsBridge() {
         if (window.MSF_JsBridge) { return; }
         // 创建JSBridge对象
         window.MSF_JsBridge = {};
         window.MSF_JsBridge.callBacks = {};// 用来存放回调函数
         window.MSF_JsBridge.invoke = invokeNativeMethod; // 调用原生的方法
         window.MSF_JsBridge.nativeCallBackToJs = nativeCallBackToJs; // 留给原生回调的方法
     };

    // 原生回调的方法
     function nativeCallBackToJs(callBackId, params) {
         if (callBackId) {
             var callBack = window.MSF_JsBridge.callBacks[callBackId];
             if (callBack) {
                 callBack(params);
             }
             // 处理完后,要移除回调,否则会越来越多
             delete window.MSF_JsBridge.callBacks.callBackId;
             console.log(params);
         }
     };

    function invokeNativeMethod(methodName, params) {
        // 如果有回调,处理一下params:将回调函数转移到集合中,把回调id传递给原生
        if (params.callBack != undefined) {
            var callBackId = 'callBackId'+new Date().getTime();
            window.MSF_JsBridge.callBacks[callBackId] = params.callBack;
            params.callBackId = callBackId;
            delete params.callBack;
        }
        // 如果已经创建过iframe,直接利用
        if (window.MSF_Iframe) {
            window.MSF_Iframe.src = 'mushao://' + methodName + '://' + JSON.stringify(params);
            return;
        }
        // 创建iframe
        var iframe = document.createElement('iframe');
        iframe.style.display = 'none';
        iframe.src = 'mushao://' + methodName + '://' + JSON.stringify(params);
        document.documentElement.appendChild(iframe);
        window.MSF_Iframe = iframe;
    };                                                     
    setupWebViewJsBridge();
);
    // END preprocessorJSCode
#undef msf_js_func__
    return preprocessorJSCode;
};

WebView加载完成后开始注入

- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    NSString *injectJs = MSF_inject_jsBridge_js();
    [webView evaluateJavaScript:injectJs completionHandler:^(id _Nullable data, NSError * _Nullable error) {
        if (error) {
            NSLog(@"注入失败:%@",error);
            return;
        }
        
        // 模拟代码:模拟JS调用原生
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [self.wkWebView evaluateJavaScript:@"window.MSF_JsBridge.invoke('receiveNativeInfo',{'device':'iphone X','callBack':(function(params) { console.log('call back success')})})" completionHandler:^(id _Nullable data, NSError * _Nullable error) {     }];
        });
    }];
}

Safari的调试器里可以看到注入JSBridge成功:

image.png

在webView相关代理方法里拦截Web端的调用消息

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(**void** (^)(WKNavigationActionPolicy))decisionHandler {
    NSString *urlStr = [navigationAction.request.URL.absoluteString stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
    if ([urlStr hasPrefix:@"mushao://"]) {
        NSArray *infoArray = [urlStr componentsSeparatedByString:@"//"];
        if (infoArray.count >= 3) {
            NSString *methodName = [infoArray objectAtIndex:1];
            if (![methodName hasSuffix:@":"]) {
                methodName = [methodName stringByAppendingString:@":"];
            }
            NSString *jsonParamsStr = [infoArray objectAtIndex:2];
            NSLog(@"收到了js调用的原生方法\n方法名:%@\n参数:%@",methodName,jsonParamsStr);
            [self performSelector:NSSelectorFromString(methodName) withObject:jsonParamsStr];
        }
    }
    decisionHandler(WKNavigationActionPolicyAllow);
}

拦截到后开始处理消息,处理完成后开始回调:

- (void)receiveNativeInfo:(NSString *)jsonParams {
    //解析数据
    NSData *jsonData = [jsonParams dataUsingEncoding:NSUTF8StringEncoding];
    NSError *error = nil;
    NSDictionary *params = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];
    if (error) {
        return;
    }
    // 开始做原生的相关处理
    // ……
    // 原生处理结束
    
    // 如果有回调,拿到信息后,再次发消息给JS
    id callBackId = params[@"callBackId"];
    if (callBackId) {
        [self.wkWebView evaluateJavaScript:[NSString stringWithFormat:@"window.MSF_JsBridge.nativeCallBackToJs('%@',{'msg':'oc to js info'})",callBackId] completionHandler:^(id _Nullable data, NSError * _Nullable error) {
        }];
    }
}

从调试面板上可以看到JS收到了原生的回调:

image.png

到现在,除了Web端回调给原生的实现没有处理外,其他的基本上通了。而Web端回调给原生,和上面的原理是一样的,就是记录回调,然后web端处理消息后,再次调用原生。

四、WebViewJavascriptBridge

上面是自己的简单实现,在github上已经有一个非常好的框架WebViewJavascriptBridge

1.框架如何使用?

  • 客户端提供给Web端的方法
[_bridge registerHandler:@"testObjcCallback" handler:^(**id** data, WVJBResponseCallback responseCallback) {
        NSLog(@"testObjcCallback called: %@", data);
        responseCallback(@"Response from testObjcCallback");
    }];
  • web端提供给原生的调用方法
window.WebViewJavascriptBridge.registerHandler('testJavascriptHandler', function(data, responseCallback) {
    var responseData = { 'Javascript Says':'Right back atcha!' }
    log('JS responding with', responseData)
    responseCallback(responseData)
})
  • 客户端调用Web端的方法
[_bridge callHandler:@"testJavascriptHandler" data:@{ @"foo":@"before ready" }];

2.原理简介

  • web端响应原生的调用
// 如果有回调id,记录对应的回调id,封装到responseCallback中
if (message.callbackId) {
    var callbackResponseId = message.callbackId;
    responseCallback = function(responseData) {
    _doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
    };
}
// 取出对应的函数,开始调用
var handler = messageHandlers[message.handlerName];
if (!handler) {
    console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
} else {
    handler(message.data, responseCallback);
}

// 将回调message存到队列中,开始去触发原生
function _doSend(message, responseCallback) {
    if (responseCallback) {
        var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
        responseCallbacks[callbackId] = responseCallback;
        message['callbackId'] = callbackId;
    }
    sendMessageQueue.push(message);
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}
  • 原生调用Web端
- (**void**)_dispatchMessage:(WVJBMessage*)message {
    NSString *messageJSON = [self _serializeMessage:message pretty:NO];
    [**self** _log:@"SEND" json:messageJSON];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];
    
    NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
    if ([[NSThread currentThread] isMainThread]) {
        [self _evaluateJavascript:javascriptCommand];
    } else {
        dispatch_sync(dispatch_get_main_queue(), ^{
            [self _evaluateJavascript:javascriptCommand];
        });
    }
}
  • Web端调用原生的简化过程:

image.png

原生调用Web的简化过程就不画了,和上面的差不多。

五、JavaScriptCore

最后这里想再聊一下JavaScriptCore这个框架

JavaScriptCore框架有以下主要类:

  • JSVirtualMachine JS虚拟机,一个虚拟机实例代表单独一个Javascript的“对象空间”或者执行资源。

    看了内部实现,就是对象持有三个NSMapTable,用来存放一些对象(个人觉得是为了处理相关对象的生命周期,比如JSContext对象就是绑定到JSVirtualMachine对象的一个NSMapTable中的)

  • JSContext 代表一个JavaScript的执行环境。所有的Javascript values都会绑定到对应的执行环境中。

  • JSValue 是对JSContext中的Javascript值的引用(即Js值在OC中的映射)

    通过JSValue的API,可以给JSContext里创建对应的Javascript对象。

    几乎支持所有的类型:对象,布尔,整型,id类型,NSRange,CGRect,函数等等,甚至可以支持创建promise

  • JSExport 提供了一种声明方式来导出OC对象和类——包括属性、实例方法、类方法和初始化方法等给到 JavaScript。(即可以把原生的对象通过这个提供给JS端)

这个框架异常的强大。但是WKWebView没办法使用让人很头疼。目前看用的比较多的就是热修复。

著名的JSPatch就用了对应的能力。

JSPatch引擎在初始化的时候,利用JSContext给JS端声明了很多的原生方法以供调用。

image.png

想实现动态化,利用JavaScriptCore这个框架是一个不错的选择。未来有时间可以再深入研究一下。