对 WKWebView js交互的简单封装

994 阅读7分钟

WKWebView使用流程

1、初始化
2、监听js消息
3、加载URL
4、接收js消息
5、移除js消息监听

文末附上原代码,抛砖引玉,望指教。

原项目在一个类中处理js交互的所有内容,代码量较多,所以想分离原来的类,将js内容分离出来,原来的类只处理业务逻辑。

核心方法

我新建了一个 NSObject 的类,来封装 WKWebView,目的是为了实现一个方法实现js消息的监听和回调。

    [webView subscripTo:@"setBarTitle" messageHandler:^(WKScriptMessage * _Nonnull message) {
        self.titleLabel.text = message.body;
    }];

.m文件中声明一个字典对象来管理消息的回调,消息名为key,handle为value。

/// 订阅列表,key为消息名,value 为handle
@property (nonatomic, strong) NSMutableDictionary *subscriptions;

/// 订阅消息
/// @param destination 消息名
/// @param handler 收到 js 消息回调
- (void)subscripTo:(NSString *)destination messageHandler:(YLWebViewMessageHeadler)handler {
    WKUserContentController * userCC = self.wkWebView.configuration.userContentController;
    [userCC addScriptMessageHandler:self name:destination];
    self.subscriptions[destination] = handler;
}

封装流程

1、初始化

我传了三个参数,frame,view 和 configuration 。

- (instancetype)initWithFrame:(CGRect)frame inView:(UIView *)view configuration:(WKWebViewConfiguration *)configuration

对于外界没有暴露 webView,所以传入frame和view,在初始化方法中将webview加载到view上。

configuration 为配置对象,可为空,默认配置支持js

    WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
    config.preferences.javaScriptEnabled = YES;

初始化webview

        // 没有设置 config 则使用默认设置
        if (configuration == nil) {
            configuration = [self defaultConfiguration];
        }
        
        // 初始化 webview
        self.wkWebView = [[WKWebView alloc] initWithFrame:frame configuration:configuration];
        [view addSubview:self.wkWebView];
        self.wkWebView.navigationDelegate = self;
        
        // 消息管理初始化
        self.subscriptions = [NSMutableDictionary dictionary];

2、加载URL

加载URL之前,我写了一个拦截方法,在加载URL之前添加js消息监听。

- (void)loadUrlString:(NSString *)urlStr withHeader:(NSDictionary *)headers
{
    // 加载url之前先添加监听js消息
    [self beforPerformLoadRequest];
    
    NSMutableURLRequest * urlRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlStr] cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:15.0];
    [headers enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        [urlRequest setValue:obj forHTTPHeaderField:key];
    }];
    [self.wkWebView loadRequest:urlRequest];
}

这里通过代理,在调用的类里面实现js消息的监听。

/// 请求前预处理,订阅js消息
- (void)beforPerformLoadRequest
{
    if (self.delegate && [self.delegate respondsToSelector:@selector(webViewSubscripMessage:)]) {
        [self.delegate webViewSubscripMessage:self];
    }
}

代理方法

/// 请求前订阅消息
- (void)webViewSubscripMessage:(YLWebViewClient *)webView;

3、OC与js交互

1、监听js消息

以消息名作为key值,将回调handle加入到消息管理对象中。

/// 订阅消息
/// @param destination 消息名
/// @param handler 收到 js 消息回调
- (void)subscripTo:(NSString *)destination messageHandler:(YLWebViewMessageHeadler)handler {
    WKUserContentController * userCC = self.wkWebView.configuration.userContentController;
    [userCC addScriptMessageHandler:self name:destination];
    self.subscriptions[destination] = handler;
}

收到js消息后,通过消息名取出回调handle并执行。

/// 收到 js 消息
- (void)userContentController:(nonnull WKUserContentController *)userContentController didReceiveScriptMessage:(nonnull WKScriptMessage *)message {
    YLWebViewMessageHeadler handler = self.subscriptions[message.name];
    handler(message);
}
2、注入js消息

直接把 WKWebView 的注入方法拿来中转了一下。

/// 向 js 发送消息
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id object, NSError * _Nullable error))completionHandler
{
    [self.wkWebView evaluateJavaScript:javaScriptString completionHandler:^(id _Nullable object, NSError * _Nullable error) {
        if (completionHandler != nil) {
            completionHandler(object, error);
        }
    }];
}
3、移除js消息监听

本来我是想把移除的方法直接写在dealloc方法中,这样就能自动调用了。但是这里有个问题,没有移除WKWebView的js监听的话,就不会触发dealloc方法,而且上层调用的类也不会调用dealloc方法。所以在退出页面的时候调用移除监听的方法。

/// 移除js监听
- (void)removeSubscrips
{
    WKUserContentController * userCC = self.wkWebView.configuration.userContentController;
    [self.subscriptions enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        [userCC removeScriptMessageHandlerForName:key];
    }];
    [self.subscriptions removeAllObjects];
}

注意事项

1、为什么在调用webview加载URL之前调用监听方法?

业务需求,有些方法在加载后会马上调用方法获取用户信息等,避免服务端已经调起方法了,本地还在加载消息监听的过程中而遗漏了消息。

2、有时候服务器并没有返回h5文件,而是返回了一个json字符串

我采用方式是在加载URL之后再请求一条数据获取获得的data,如果data为字典(和服务器约定的请求回调格式),就通过代理告诉业务层,显示提示信息,避免webview上显示了一串json字符串。

// 没有获取到 h5 页面,返回的错误信息会显示在 webview 上,回调给上层处理逻辑
    NSURLSessionDataTask *dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:urlRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (data == nil) {
            return;
        }
        NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingFragmentsAllowed error:nil];
        if (dic == nil) {
            return;
        }
        dispatch_async(dispatch_get_main_queue(), ^{
            [self loadRequestFailedWithJsonData:dic];
        });
    }];

    [dataTask resume];

不知道有没有其他方式能处理这个问题,因为webview收到了数据只是不是h5页面,所以fail的接口也不会回调错误,如果有更好的方法,感谢告诉我。

3、webview的拦截方法

这个也是把原方法照抄来的,通过代理回调给业务层。项目中有个拨打电话的需求,点击电话,调起url格式为 tel:xxxxxxxx 通过这种方式来调起手机端拨打电话,所以拦截URL提取电话号码。

- (void)webView:(YLWebViewClient *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler
{
    // 治疗拨打电话没反应
    NSURL *URL = navigationAction.request.URL;
    NSString *scheme = [URL scheme];
    if ([scheme isEqualToString:@"tel"]) {
        // 这里获取到的就是电话号码了
        NSString *resourceSpecifier = [URL resourceSpecifier];
        ...
        ...
        ...
        /// 拨打电话
        dispatch_main_async_safe(^{
            [[UIApplication sharedApplication] openURL:[NSURL URLWithString:callPhone]];
        });
        decisionHandler(WKNavigationActionPolicyCancel);
        return;
    }
    ...
    ...
    ...
    decisionHandler(WKNavigationActionPolicyAllow);
}

最后

封装完毕,代码量并没有减少多少,只是将监听和回调写在一起,便于处理。下面怀着忐忑的心情放出自己的原码。

.h

#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>

NS_ASSUME_NONNULL_BEGIN

@class YLWebViewClient;
@protocol YLWebViewDelegate <NSObject>

/// 请求前订阅消息
- (void)webViewSubscripMessage:(YLWebViewClient *)webView;
/// 请求失败,收到json数据
- (void)webView:(YLWebViewClient *)webView loadFailedWithJsonData:(NSDictionary *)jsonData;

@optional

- (void)webViewDidStart:(YLWebViewClient *)webView;
- (void)webViewDidFinish:(YLWebViewClient *)webView;
- (void)webViewDidFailed:(YLWebViewClient *)webView;
- (void)webView:(YLWebViewClient *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

@end

typedef void (^YLWebViewMessageHeadler)(WKScriptMessage *message);

@interface YLWebViewClient : NSObject

@property (nonatomic, weak) id <YLWebViewDelegate>delegate;

// 初始化
- (instancetype)initWithFrame:(CGRect)frame inView:(UIView *)view configuration:(WKWebViewConfiguration * _Nullable)configuration;
// 加载url
- (void)loadUrlString:(NSString *)urlStr withHeader:(NSDictionary *)headers;
// 订阅消息
- (void)subscripTo:(NSString *)destination messageHandler:(YLWebViewMessageHeadler)handler;
// 注入 js 消息
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id object, NSError * _Nullable error))completionHandler;
// 返回
- (BOOL)goBack;
// 移除监听
- (void)removeSubscrips;
@end

NS_ASSUME_NONNULL_END

.m

#import "YLWebViewClient.h"

@interface YLWebViewClient ()<WKNavigationDelegate, WKScriptMessageHandler>

@property (strong, nonatomic) WKWebView * wkWebView;
@property (nonatomic, strong) UIProgressView *myProgressView;
/// 订阅列表,key为消息名,value 为handle
@property (nonatomic, strong) NSMutableDictionary *subscriptions;

@end

@implementation YLWebViewClient

#pragma mark - life

- (void)dealloc
{
    if (self.wkWebView) {
        @try { // 防止崩溃
            [self.wkWebView removeObserver:self forKeyPath:@"estimatedProgress"];
        } @catch (NSException *exception) {} @finally {}
    }
}
/// 初始化
/**
 * 不传 config 则使用默认配置,支持js
 */
- (instancetype)initWithFrame:(CGRect)frame inView:(UIView *)view configuration:(WKWebViewConfiguration *)configuration {
    self = [super init];
    if (self) {
        // 清理缓存
        [self deleteWebCache];
        
        // 没有设置 config 则使用默认设置
        if (configuration == nil) {
            configuration = [self defaultConfiguration];
        }
        
        // 初始化 webview
        self.wkWebView = [[WKWebView alloc] initWithFrame:frame configuration:configuration];
        [view addSubview:self.wkWebView];
        self.wkWebView.navigationDelegate = self;
        
        // 监听进度
        [view addSubview:self.myProgressView];
        [self.wkWebView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:nil];
        
        self.subscriptions = [NSMutableDictionary dictionary];
    }
    return self;
}
// 默认配置
- (WKWebViewConfiguration *)defaultConfiguration
{
    //设置偏好设置
    WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
    //是否支持JavaScript
    config.preferences.javaScriptEnabled = YES;
    return config;
}

// 清理缓存
- (void)deleteWebCache
{
    NSSet *websiteDataTypes = [WKWebsiteDataStore allWebsiteDataTypes];
    NSDate *date = [NSDate dateWithTimeIntervalSince1970:0];
    [[WKWebsiteDataStore defaultDataStore] removeDataOfTypes:websiteDataTypes modifiedSince:date completionHandler:^{}];
}

#pragma mark - 加载URL

/// 加载URL
- (void)loadUrlString:(NSString *)urlStr withHeader:(NSDictionary *)headers
{
    // 加载url之前先添加监听js消息
    [self beforPerformLoadRequest];
    
    NSMutableURLRequest * urlRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlStr] cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:15.0];
    [headers enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        [urlRequest setValue:obj forHTTPHeaderField:key];
    }];
    [self.wkWebView loadRequest:urlRequest];
    
    // 没有获取到 h5 页面,返回的错误信息会显示在 webview 上,回调给上层处理逻辑
    NSURLSessionDataTask *dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:urlRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (data == nil) {
            return;
        }
        NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingFragmentsAllowed error:nil];
        if (dic == nil) {
            return;
        }
        dispatch_async(dispatch_get_main_queue(), ^{
            [self loadRequestFailedWithJsonData:dic];
        });
    }];

    [dataTask resume];
}

/// 没有获取到 h5 页面,收到json 错误信息
- (void)loadRequestFailedWithJsonData:(NSDictionary *)jsonData
{
    if (![jsonData isKindOfClass:[NSDictionary class]]) {
        jsonData = @{
            @"data": jsonData
        };
    }
    if (self.delegate && [self.delegate respondsToSelector:@selector(webView:loadFailedWithJsonData:)]) {
        [self.delegate webView:self loadFailedWithJsonData:jsonData];
    }
}
/// 请求前预处理,订阅js消息
- (void)beforPerformLoadRequest
{
    if (self.delegate && [self.delegate respondsToSelector:@selector(webViewSubscripMessage:)]) {
        [self.delegate webViewSubscripMessage:self];
    }
}

#pragma mark - response
/// 返回
/**
 * webview 中有多层跳转时,调用webview的返回上一页
 */
- (BOOL)goBack
{
    if (self.wkWebView.canGoBack) {
        [self.wkWebView goBack];
        return YES;
    }
    return NO;
}

#pragma mark - WKNavigationDelegate

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler
{
    if (self.delegate && [self.delegate respondsToSelector:@selector(webView:decidePolicyForNavigationAction:decisionHandler:)]) {
        [self.delegate webView:self decidePolicyForNavigationAction:navigationAction decisionHandler:decisionHandler];
    }
}

- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation {
    if (self.delegate && [self.delegate respondsToSelector:@selector(webViewDidStart:)]) {
        [self.delegate webViewDidStart:self];
    }
}
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    if (self.delegate && [self.delegate respondsToSelector:@selector(webViewDidFinish:)]) {
        [self.delegate webViewDidFinish:self];
    }
}
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation {
    if (self.delegate && [self.delegate respondsToSelector:@selector(webViewDidFailed:)]) {
        [self.delegate webViewDidFailed:self];
    }
}

#pragma mark - WKScriptMessageHandler

/// 收到 js 消息
- (void)userContentController:(nonnull WKUserContentController *)userContentController didReceiveScriptMessage:(nonnull WKScriptMessage *)message {
    YLWebViewMessageHeadler handler = self.subscriptions[message.name];
    handler(message);
}

#pragma mark - 订阅js消息

/// 订阅消息
/// @param destination 消息名
/// @param handler 收到 js 消息回调
- (void)subscripTo:(NSString *)destination messageHandler:(YLWebViewMessageHeadler)handler {
    WKUserContentController * userCC = self.wkWebView.configuration.userContentController;
    [userCC addScriptMessageHandler:self name:destination];
    self.subscriptions[destination] = handler;
}

/// 向 js 发送消息
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id object, NSError * _Nullable error))completionHandler
{
    [self.wkWebView evaluateJavaScript:javaScriptString completionHandler:^(id _Nullable object, NSError * _Nullable error) {
        if (completionHandler != nil) {
            completionHandler(object, error);
        }
    }];
}
/// 移除js监听
- (void)removeSubscrips
{
    WKUserContentController * userCC = self.wkWebView.configuration.userContentController;
    [self.subscriptions enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        [userCC removeScriptMessageHandlerForName:key];
    }];
    [self.subscriptions removeAllObjects];
}

#pragma mark - progress
// 计算wkWebView进度条
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if (object == self.wkWebView && [keyPath isEqualToString:@"estimatedProgress"]) {
        CGFloat newprogress = [[change objectForKey:NSKeyValueChangeNewKey] doubleValue];
        self.myProgressView.alpha = 1.0f;
        [self.myProgressView setProgress:newprogress animated:YES];
        if (newprogress >= 1.0f) {
            [UIView animateWithDuration:0.3f
                                  delay:0.3f
                                options:UIViewAnimationOptionCurveEaseOut
                             animations:^{
                                 self.myProgressView.alpha = 0.0f;
                             }
                             completion:^(BOOL finished) {
                                 [self.myProgressView setProgress:0 animated:NO];
                             }];
        }
        
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (UIProgressView *)myProgressView
{
    if (_myProgressView == nil) {
        _myProgressView = [[UIProgressView alloc] initWithFrame:CGRectMake(0, 1, [UIScreen mainScreen].bounds.size.width, 0)];
        _myProgressView.tintColor = [UIColor blueColor];
        _myProgressView.trackTintColor = [UIColor whiteColor];
    }
    
    return _myProgressView;
}
@end