App与Js交互(一)iOS

0.312字数 2425阅读 5322

目录

示例代码

Demo: https://github.com/gwpp/jsinterface

前言

不论是在创业团队中快速试错,还是在成熟团队中快速迭代复杂需求,还或者是其他原因,WebView在APP中的大量使用已经成为了一个明显的趋势,这也应该算是大前端融合的一个表象吧。笔者在工作中也遇到过很多App&Js交互的问题,粗浅的研究了一下,这里也分享给大家,如果有错误的地方还请下方留言指出,共同进步。

iOS系统中的交互

众所周知,iOS有UIWebViewWKWebView两个组件可以用来渲染嵌入页面。前者使用甚广,出生的也早,后者是iOS8推出的,优化了加载速度和内存,安全性上也有所提升。具体的两者比较百度、简书上都很多,这里不做赘述。

方案一,拦截跳转

  • WebView:UIWebView
  • 原生调用JS:
    UIWebView直接调用Js方法,示例代码如下:
    [self.webView stringByEvaluatingJavaScriptFromString:@"showResponse('123')"];
    
  • JS调用原生:
    拦截跳转是我们最常见的一种方式,也是最简单,最容易理解的一种。我们可以在UIWebView的代理方法中拦截每一个请求,如果是特殊的链接就可以做一些事情,比如跳转、执行某些方法等。示例如下:
    // JS端
    window.location = 'app://login?account=13011112222&password=123456';
    
    // iOS端
    - (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {  
      NSString *scheme = request.URL.scheme;
      NSString *host = request.URL.host;
      
      // 一般用作交互的链接都会有一个固定的协议头,这里我们一“app”作为协议头为了,实际项目中可以修改
      if ([scheme isEqualToString:@"app"]) { // scheme为“app”说明是做交互的链接
          if ([host isEqualToString:@"login"]) { // host为“login”对应的就是登录操作
              NSDictionary *paramsDict = [request.URL getURLParams];
              NSString *account = paramsDict[@"account"];
              NSString *password = paramsDict[@"password"];
              NSString *msg = [NSString stringWithFormat:@"执行登录操作,账号为:%@,密码为:%@", account, password];
              UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"原生弹窗" message:msg delegate:nil cancelButtonTitle:@"好" otherButtonTitles:nil, nil];
              [alert show];
          }
      return YES;
    }
    

方案二,拦截跳转

  • WebView:WKWebView

  • 原生调用JS:
    WKWebView直接调用Js方法,示例代码如下:

    [self.webView evaluateJavaScript:@"showResponse('点击了原生的按钮22222222222')" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
          if (error) {
              NSLog(@"%@", error);
          } else {
              NSLog(@"%@", response);
          }
     }];
    

    它相对于UIWebView而言最大的优点就是支持callback,不想UIWebView那样只能一去不复返。

  • JS调用原生:
    类似UIWebView,在WK中我们同样可以拦截跳转,原理相同,代码不同。示例如下:

    // JS端
    window.location = 'app://login?account=13011112222&password=123456';
    
    // iOS端
    - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
      NSURLRequest *request = navigationAction.request;
      NSString *scheme = request.URL.scheme;
      NSString *host = request.URL.host;
      
      // 一般用作交互的链接都会有一个固定的协议头,这里我们一“app”作为协议头为了,实际项目中可以修改
      if ([scheme isEqualToString:@"app"]) { // scheme为“app”说明是做交互的链接
          if ([host isEqualToString:@"login"]) { // host为“login”对应的就是登录操作
              NSDictionary *paramsDict = [request.URL getURLParams];
              NSString *account = paramsDict[@"account"];
              NSString *password = paramsDict[@"password"];
              NSString *msg = [NSString stringWithFormat:@"执行登录操作,账号为:%@,密码为:%@", account, password];
              UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"原生弹窗" message:msg delegate:nil cancelButtonTitle:@"好" otherButtonTitles:nil, nil];
              [alert show];
          }
          
          // ... 这里可以继续加 else if
          
          decisionHandler(WKNavigationActionPolicyCancel);
          return;
      }
      decisionHandler(WKNavigationActionPolicyAllow);
    }
    

阶段小结

前两种方法到此就介绍完了,很简单,但是在项目大了之后拦截跳转的代理方法中会有非常多的判断。冗余、可维护性差,硬编码重。所以我们会有下面的其他方法。

方案三,JSContext

JSContext即JavaScriptContext,这个东西在UIWebView中可以拿到,但是在WKWebView中却是取不到了,所以只能用在UIWebView中。除此以外Android里也有类似的一个东西,所以使用JSContext就有了在JS端多平台统一的可能,这里不多说,在《App与Js交互(三)》中会有详细说明。
JSContext的原理就是iOS暴露出去一个遵守<JSExport>协议的对象给JS,JS可以直接调用该对象的public方法。

  • WebView:UIWebView
  • 原生调用JS:
    // 有两种方式。jsContext 是一个【JSContext *】变量,需要在【webViewDidFinishLoad: 】方法中每次赋值
    // 方式1:
    [self.jsContext evaluateScript:@"showResponse('点击了按钮1111111111111111')"];
    
    // 方式2:
    JSValue *value = self.jsContext[@"showResponse"];
    [value callWithArguments:@[@"点击了按钮222222222"]];
    
  • JS调用原生:
    // JS端,app是iOS中注册的一个对象
    app.login("13011112222", "123456");
    
    // iOS端
    // 每次嵌入页面加载完毕都要给jsContext赋值,否则在js端调用可能会失效。
    - (void)webViewDidFinishLoad:(UIWebView *)webView {
      self.jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
      self.jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
          context.exception = exception;
          NSLog(@"异常信息:%@", exception);
      };
      // app是随便取的名字,可以改,改了之后JS要同步修改。如果Android端使用@JavaScriptInterface的形式,那么还要保证Android、iOS两端同步,建议都用app
      self.jsContext[@"app"] = [[JSContextModel alloc] init];
    }
    
    // JSContextModel,
    @protocol JsContextExport<JSExport>
    /**
     * 登出方法,js调用的方法名也是logout
     */
    - (void)logout;
    /**
     * 登录方法,JSExportAs的作用就是给OC方法导出一个js方法名,例如下面的方法js调用就是 login("your account", "your password")。在多参数的方法声明时必须使用这种方式
     */
    JSExportAs(login, - (void)loginWithAccount:(NSString *)account   password:(NSString *)password);
    /**
     * 获取登录信息
     * @return 当前登录用户的身份信息。JSContext方式调用OC时,方法的返回值只能是NSString、NSArray、NSDictionary、NSNumber、BooL,其他类型不能解析
     */
    - (NSDictionary *)getLoginUser;
    @end
    
    @interface JSContextModel : NSObject<JsContextExport>
    @end
    

方案四,WebKit

window.webkit.messagehandlers.<name>.postMessage是apple推荐使用的WKWebView的JS交互方式,使用起来比较简单,不支持callback回调。

  • WebView:WKWebView
  • 原生调用JS:
    参考【方案二】的原生调用JS
  • JS调用原生:
    // js
    window.webkit.messageHandlers.login.postMessage({
      'account': '13000000000',
      'password': '123456'
    });
    
    // iOS - 初始化WKWebView时设置 configuration
    self.webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:[[WKWebViewConfiguration alloc] init]];
    WKUserContentController *confVc = self.webView.configuration.userContentController;
      [confVc addScriptMessageHandler:self name:@"login"];
      
    // iOS - 在ScriptMessageHandler 的代理方法中处理
    - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
      if ([message.name isEqualToString:@"login"]) {
          if (![message.body isKindOfClass:[NSDictionary class]]) {
              return;
          }
          NSDictionary *data = message.body;
          NSString *account = data[@"account"];
          NSString *password = data[@"password"];
          
          NSString *msg = [NSString stringWithFormat:@"执行登录操作,账号为:%@,密码为:%@", account, password];
          UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"原生弹窗" message:msg delegate:nil cancelButtonTitle:@"好" otherButtonTitles:nil, nil];
          [alert show];
          return;
      }
    }
    

方案五,JsBridge

  • WebView:UIWebView、WKWebView同时支持,且方法名完全没有差异,但是特别要注意的一点就是:iOS原生是不支持这种方式的,我们需要依赖于一个三方库 —— WebViewJavascriptBridge,这是一个很有名的库,具体有多牛逼这里也不做过多需求,百度一下你就知道。
  • 初始化代码:
    // JS初始化代码
    /**
     * 初始化jsbridge
     * @param readyCallback 初始化完成后的回调
     */
     function initJsBridge(readyCallback) {
         var u = navigator.userAgent;
         var isAndroid = u.indexOf('Android') > -1 || u.indexOf('Adr') > -1; //android终端
         var isiOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); //ios终端
         // 注册jsbridge
         function connectWebViewJavascriptBridge(callback) {
             if (isAndroid) {
                 if (window.WebViewJavascriptBridge) {
                     callback(WebViewJavascriptBridge)
                 } else {
                     document.addEventListener(
                         'WebViewJavascriptBridgeReady'
                         , function () {
                             callback(WebViewJavascriptBridge)
                         },
                         false
                     );
                 }
                 return;
             }
             if (isiOS) {
                 if (window.WebViewJavascriptBridge) {
                     return callback(WebViewJavascriptBridge);
                 }
                 if (window.WVJBCallbacks) {
                     return window.WVJBCallbacks.push(callback);
                 }
                 window.WVJBCallbacks = [callback];
                 var WVJBIframe = document.createElement('iframe');
                 WVJBIframe.style.display = 'none';
                 WVJBIframe.src = 'https://__bridge_loaded__';
                 document.documentElement.appendChild(WVJBIframe);
                 setTimeout(function () {
                     document.documentElement.removeChild(WVJBIframe)
                 }, 0)
             }
         }
         // 调用注册方法
         connectWebViewJavascriptBridge(function (bridge) {
             if (isAndroid) {
                 bridge.init(function (message, responseCallback) {
                     console.log('JS got a message', message);
                     responseCallback(data);
                 });
             }
              
             // 只有在这里注册过的方法,在原声代码里才能用callHandler的方式调用
             bridge.registerHandler('jsbridge_showMessage', function (data, responseCallback) {
                 showResponse(data);
             });
             bridge.registerHandler('jsbridge_getJsMessage', function (data, responseCallback) {
                  showResponse(data);
                  responseCallback('native 传过来的是:' + data);
              });
    
              readyCallback();
          });
      }
    
    // iOS初始化代码
     - (void)setupJsBridge {
      if (self.bridge) return;
      // self.webview既可以是UIWebView,又可以是WKWebView
      self.bridge = [WebViewJavascriptBridge bridgeForWebView:self.webView];
      
      [self.bridge registerHandler:@"getOS" handler:^(id data, WVJBResponseCallback responseCallback) {
          // 这里Response的回调可以传id类型数据,但是为了保持Android、iOS的统一,全部使用json字符串作为返回数据
          NSDictionary *response = @{@"error": @(0), @"message": @"", @"data": @{@"os": @"ios"}};
          responseCallback([response jsonString]);
      }];
      
      [self.bridge registerHandler:@"login" handler:^(id data, WVJBResponseCallback responseCallback) {
          if (data == nil || ![data isKindOfClass:[NSDictionary class]]) {
              NSDictionary *response = @{@"error": @(-1), @"message": @"调用参数有误"};
              responseCallback([response jsonString]);
              return;
          }
          
          NSString *account = data[@"account"];
          NSString *passwd = data[@"password"];
          NSDictionary *response = @{@"error": @(0), @"message": @"登录成功", @"data" : [NSString stringWithFormat:@"执行登录操作,账号为:%@、密码为:@%@", account, passwd]};
          responseCallback([response jsonString]);
      }];
    }
    
  • 原生调用JS
    [self.bridge callHandler:@"jsbridge_getJsMessage" data:@"点击了原生的按钮222222222" responseCallback:^(id responseData) {
      UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"显示jsbridge返回值" message:responseData delegate:nil cancelButtonTitle:@"好" otherButtonTitles:nil, nil];
       [alert show];
    }];
    
  • JS调用原生
    // 首先调用JSBridge初始化代码,完成后再设置其他
    initJsBridge(function () {
      $("#getOS").click(function () {
            // 通过JsBridge调用原生方法,写法固定,第一个参数时方法名,第二个参数时传入参数,第三个参数时响应回调
            window.WebViewJavascriptBridge.callHandler('getOS', null, function (response) {
            showResponse(response);
          });
      });
     });