WKWebView 与 JavaScript 进行通讯

2020年4月起App Store将不再接受使用UIWebView的新App上架、2020年12月起将不再接受使用UIWebView的App更新。苹果已经彻底抛弃了UIWebView,所以我们不再讨论关于UIWebView的问题。

苹果从 iOS8.0 为我们带来了全新的 WKWebView,WKWebView 为我们和 JavaScript 的交互带来了不一样的体验。

WKNavigationDelegate 协议拦截

  • 首先遵守 WKNavigationDelegate 协议
  • 实现 webView:decidePolicyForNavigationAction:decisionHandler: 方法, 匹配对应的URL

示例代码:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>JavaScript & Objective-C</title>
    <style>
        body { padding-top: 100px; }
    </style>
</head>
<body>
    <button onclick="onLogin()">Login</button>
    <div>OC Response:<span id="resp"><span></div>
</body>
    <script>
        function onLogin() {
            window.location.href = 'login://';
        }
        
        function onLoginResponse(data) {
            document.getElementById('resp').innerHTML = JSON.stringify(data);
            return 'Login Success!!!';
        }
    </script>
</html>
@interface ViewController ()<WKNavigationDelegate>

@property (nonatomic, strong) WKWebView *webView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
    self.webView.navigationDelegate = self; // 设置代理
    [self.view addSubview:self.webView];
    
    NSString *path = [[NSBundle mainBundle] pathForResource:@"index.html" ofType:nil];
    NSURL *URL = [NSURL fileURLWithPath:path];
    NSURLRequest *request = [NSURLRequest requestWithURL:URL];
    [self.webView loadRequest:request];
}

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    NSURL *URL = navigationAction.request.URL;
    // 匹配到相应的协议, 则做相应的事情, 并且取消此次请求即可
    if ([URL.scheme isEqualToString:@"login"]) {
        NSString *user = @"{\"name\":\"xiaoming\",\"age\":18}";
        NSString *jsCode = [NSString stringWithFormat:@"onLoginResponse(%@)", user];
        [self.webView evaluateJavaScript:jsCode completionHandler:^(id _Nullable data, NSError * _Nullable error) {
            NSLog(@"%@", data);
        }];
        // 取消此次请求
        decisionHandler(WKNavigationActionPolicyCancel);
        return;
    }
    decisionHandler(WKNavigationActionPolicyAllow);
}

@end

WKScriptMessageHandler

  • 首先通过 addScriptMessageHandler:name: 注册多个消息处理程序(可以理解为注册多个方法供 JavaScript 进行调用)
  • 在 JavaScript 端则通过 window.webkit.messageHandlers.<name>.postMessage(<messageBody>) 来调用 Objective-C 方法并传递参数
  • 在 Objective-C 通过 evaluateJavaScript:completionHandler: 来调用 JavaScript 代码并接收返回值

示例代码:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>JavaScript & Objective-C</title>
    <style>
        body { padding-top: 100px; }
    </style>
</head>
<body>
    <button onclick="onLogin()">Call OC</button>
    <div>OC Response:<span id="resp"><span></div>
</body>
    <script>
        // 调用OC方法
        function onLogin() {
            // postMessage 参数不能省略, 如果不想传递任何数据则使用 null
            // 如果省略了参数, 则 iOS 将接收不到消息的调用
            window.webkit.messageHandlers.Login.postMessage(null);
        }

        // 接收OC返回的数据
        function onLoginResponse(data) {
            document.getElementById('resp').innerHTML = JSON.stringify(data);
            // 这里可以通过 return 返回数据给 iOS
            return 'Login Success!!!';
        }
    </script>
</html>

Objective-C 代码

@interface ViewController ()<WKScriptMessageHandler>

@property (nonatomic, strong) WKWebView *webView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
    [self.view addSubview:self.webView];
    
    [self.webView.configuration.userContentController addScriptMessageHandler:self name:@"Login"];
    
    NSString *path = [[NSBundle mainBundle] pathForResource:@"index.html" ofType:nil];
    NSURL *URL = [NSURL fileURLWithPath:path];
    NSURLRequest *request = [NSURLRequest requestWithURL:URL];
    [self.webView loadRequest:request];
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name isEqualToString:@"Login"]) {
        NSString *user = @"{\"name\":\"xiaoming\",\"age\":18}";
        NSString *jsCode = [NSString stringWithFormat:@"onLoginResponse(%@)", user];
        [self.webView evaluateJavaScript:jsCode completionHandler:^(id _Nullable data, NSError * _Nullable error) {
            NSLog(@"%@", data);
        }];
    }
}

- (void)dealloc {
    [self.webView.configuration.userContentController removeAllScriptMessageHandlers];
}

@end
  • WKScriptMessageHandler循环引用

当我们调用了 addScriptMessageHandler:name: 接口后,发现控制器无法销毁了,由于循环引用造成了内存泄漏。这里采用 NSProxy 方案进行解决。

/// WKScriptMessageHandler代理类
/// 解决由于 `addScriptMessageHandler:name:` 接口导致的循环引用问题
///
/// 用法:
/// id<WKScriptMessageHandler> proxy = (id<WKScriptMessageHandler>)[WKScriptMessageHandlerProxy proxyWithTarget:self];
/// [self.webView.configuration.userContentController addScriptMessageHandler:proxy name:@"fn"];
@interface WKScriptMessageHandlerProxy : NSProxy

+ (instancetype)proxyWithTarget:(id)target;

@end

@implementation WKScriptMessageHandlerProxy
{
    __weak id _target;
}

+ (instancetype)proxyWithTarget:(id)target {
    WKScriptMessageHandlerProxy *proxy = [WKScriptMessageHandlerProxy alloc];
    proxy->_target = target;
    return proxy;
}

- (BOOL)respondsToSelector:(SEL)aSelector {
    return [_target respondsToSelector:aSelector];
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [_target methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    if ([_target respondsToSelector:invocation.selector]) {
        [invocation invokeWithTarget:_target];
    }
}

@end

WebViewJavascriptBridge

WebViewJavascriptBridge 算是最受欢迎的OC与JS桥接的框架了,不过该框架已经几年不维护了。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>JavaScript & Objective-C</title>
    <style>
        body { padding-top: 100px; }
    </style>
</head>
<body>
    <button onclick="onLogin()">Call OC</button>
    <div>OC Response:<span id="resp"><span></div>
</body>
    <script>
        function setupWebViewJavascriptBridge(callback) {
            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)
        }
        
        setupWebViewJavascriptBridge(function(bridge) {
            // 初始化完毕, 在这里注册方法供 OC 调用
            bridge.registerHandler('onResponse', function(data, responseCallback) {});
        });
        
        function onLogin() {
            // 调用 OC 方法, 并直接在回调函数中接收 OC 返回的数据
            WebViewJavascriptBridge.callHandler('Login', {}, function(data) {
                document.getElementById('resp').innerHTML = JSON.stringify(data);
            });
        }
    </script>
</html>
@interface ViewController ()<WKScriptMessageHandler>

@property (nonatomic, strong) WKWebView *webView;
@property (nonatomic, strong) WKWebViewJavascriptBridge *bridge;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
    [self.view addSubview:self.webView];
    
    NSString *path = [[NSBundle mainBundle] pathForResource:@"index.html" ofType:nil];
    NSURL *URL = [NSURL fileURLWithPath:path];
    NSURLRequest *request = [NSURLRequest requestWithURL:URL];
    [self.webView loadRequest:request];
    
    self.bridge = [WKWebViewJavascriptBridge bridgeForWebView:self.webView];
    [self.bridge registerHandler:@"Login" handler:^(id data, WVJBResponseCallback responseCallback) {
        NSDictionary *user = @{@"name": @"xiaoming", @"age": @18};
        responseCallback(user);
    }];
}

@end