导语: 很少会有一个 App 完全使用原生代码去编写,更多的情况是原生和 H5 结合的方式,本文主要介绍一下在 iOS 中如何使用 WKWebView 及和 JS 的交互。

在 iOS8.0 之前我们使用的是 UIWebView,但是由于其性能问题在 iOS8.0 之后苹果官方推出了 WKWebView,具有高性能占用更少的内存支持更多 H5 的特性等优点。

要对 WKWebView 有更多的了解,首先必须清楚其有14个类和3个协议;只有掌握了14个类和3个协议才能更好的应用 WebView。

# 一、WKWebView 的 14 个类和 3 个协议

# 1. WKWebView 的 14 个类

我们不一定会完全使用到如下的 14 个类,也不一定现在完全了解他们具体的使用方法;但是我们需要首先了解每个类的具体作用,用以开发过程中能够快速的找到。

  • WKScriptMessageHandler: 提供从网页中收消息的回调方法
  • WKWebViewConfiguration: 初始化 webview 的设置。
  • WKBackForwardList: 之前访问过的 web 页面的列表,可以通过后退和前进动作来访问到。
  • WKBackForwardListItem: webview 中后退列表里的某一个网页。
  • WKFrameInfo: 包含一个网页的布局信息。
  • WKNavigation: 包含一个网页的加载进度信息。
  • WKNavigationAction: 包含可能让网页导航变化的信息,用于判断是否做出导航变化。
  • WKNavigationResponse: 包含可能让网页导航变化的返回内容信息,用于判断是否做出导航变化。
  • WKPreferences: 概括一个 webview 的偏好设置。
  • WKProcessPool: 表示一个 web 内容加载池。
  • WKUserContentController: 提供使用 JavaScript post 信息和注册 script 的方法。
  • WKScriptMessage: 包含网页发出的信息。
  • WKUserScript: 表示可以被网页接受的用户脚本。
  • WKWindowFeatures: 指定加载新网页时的窗口属性。
  • WKWebsiteDataStore: 包含网页数据存储和查找。
  • WKNavigationDelegate: 提供了追踪主窗口网页加载过程和判断主窗口和子窗口是否进行页面加载新页面的相关方法。
  • WKUIDelegate: 提供用原生控件显示网页的方法回调。

# 3. WKWebView 的三个协议【重点】

相比上面的 14 个类,如下的三个协议更为重要,且必须知道如何使用;只有这样你才能实现 iOS 和 JS 的交互。

# 1). WKNavigationDelegate

该代理提供的方法,可以用来追踪加载过程(页面开始加载、加载完成、加载失败)、以及决定是否执行跳转。

页面加载相关方法

// 页面开始加载时调用
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation;
// 当内容开始返回时调用
- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation;
// 页面加载完成之后调用
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation;
// 页面加载失败时调用
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation;

页面跳转相关方法

页面跳转的代理方法有三种,分为(收到跳转与决定是否跳转两种)

// 接收到服务器跳转请求之后调用
- (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(WKNavigation *)navigation;
// 在收到响应后,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler;
// 在发送请求之前,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

# 2). WKScriptMessageHandler

WKScriptMessageHandler应该说是最重要的一个协议了,因为 js 调用 OC 代码就是通过此协议中的方法传递过来的。

// 从web界面中接收到一个脚本时调用
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;

我们可以通过message.name获取到 js 中挂在到 window 下的方法。

一个简单例子:

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;
    if ([message.name isEqualToString:@"windowMethod"]) {
        // 实现你的需求
    }
}

# 3). WKUIDelegate

WKUIDelegate所实现的代理方法都是与界面弹出提示框相关的,针对于 web 界面的三种提示框(警告框、确认框、输入框)分别对应三种代理方法。

// 新建WKWebView
- (nullable WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures;

// 关闭WKWebView
- (void)webViewDidClose:(WKWebView *)webView NS_AVAILABLE(10_11, 9_0);

// 对应js的Alert方法
/**
 *  web界面中有弹出警告框时调用
 *
 *  @param webView           实现该代理的webview
 *  @param message           警告框中的内容
 *  @param frame             主窗口
 *  @param completionHandler 警告框消失调用
 */
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler;

// 对应js的confirm方法
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL result))completionHandler;

// 对应js的prompt方法
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * __nullable result))completionHandler;


# 二、WKWebView 的 API

# 1. WKWebView 的初始化

WKWebView 的初始化方法一共有两种:


// 默认初始化
- (instancetype)initWithFrame:(CGRect)frame;

// 根据对webview的相关配置,进行初始化,configuration就是上面说的偏好设置
- (instancetype)initWithFrame:(CGRect)frame configuration:(WKWebViewConfiguration *)configuration NS_DESIGNATED_INITIALIZER;


# 2. WKWebView 加载 HTML 的方式

如果你看过前面的一篇文章,如何加载 pdf/excel/word 文档,你会发现 webview 有一个loadRequest方法可以直接请求对应的 url 地址:

基本方法

NSURL *url = [NSURL URLWithString:@"mrgaogang.github.com"];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
[webView loadRequest:request];

其他方法

//加载本地URL文件
- (nullable WKNavigation *)loadFileURL:(NSURL *)URL
allowingReadAccessToURL:(NSURL *)readAccessURL

//加载本地HTML字符串
- (nullable WKNavigation *)loadHTMLString:(NSString *)string  baseURL:(nullable NSURL *)baseURL;
//加载二进制数据
- (nullable WKNavigation *)loadData:(NSData *)data MIMEType:(NSString *)MIMEType characterEncodingName:(NSString *)characterEncodingName baseURL:(NSURL *)baseURL

# 3. 其他主要 API


//上文介绍过的偏好配置
@property (nonatomic, readonly, copy) WKWebViewConfiguration *configuration;
// 导航代理
@property (nullable, nonatomic, weak) id <WKNavigationDelegate> navigationDelegate;
// 用户交互代理
@property (nullable, nonatomic, weak) id <WKUIDelegate> UIDelegate;

// 页面前进、后退列表
@property (nonatomic, readonly, strong) WKBackForwardList *backForwardList;

// 默认构造器
- (instancetype)initWithFrame:(CGRect)frame configuration:(WKWebViewConfiguration *)configuration NS_DESIGNATED_INITIALIZER;


//加载请求API
- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;

// 加载URL
- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL NS_AVAILABLE(10_11, 9_0);

// 直接加载HTML
- (nullable WKNavigation *)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;

// 直接加载二进制data
- (nullable WKNavigation *)loadData:(NSData *)data MIMEType:(NSString *)MIMEType characterEncodingName:(NSString *)characterEncodingName baseURL:(NSURL *)baseURL NS_AVAILABLE(10_11, 9_0);

// 前进或者后退到某一页面
- (nullable WKNavigation *)goToBackForwardListItem:(WKBackForwardListItem *)item;

// 页面的标题,支持KVO的
@property (nullable, nonatomic, readonly, copy) NSString *title;

// 当前请求的URL,支持KVO的
@property (nullable, nonatomic, readonly, copy) NSURL *URL;

// 标识当前是否正在加载内容中,支持KVO的
@property (nonatomic, readonly, getter=isLoading) BOOL loading;

// 当前加载的进度,范围为[0, 1]
@property (nonatomic, readonly) double estimatedProgress;

// 标识页面中的所有资源是否通过安全加密连接来加载,支持KVO的
@property (nonatomic, readonly) BOOL hasOnlySecureContent;

// 当前导航的证书链,支持KVO
@property (nonatomic, readonly, copy) NSArray *certificateChain NS_AVAILABLE(10_11, 9_0);

// 是否可以招待goback操作,它是支持KVO的
@property (nonatomic, readonly) BOOL canGoBack;

// 是否可以执行gofarward操作,支持KVO
@property (nonatomic, readonly) BOOL canGoForward;

// 返回上一页面,如果不能返回,则什么也不干
- (nullable WKNavigation *)goBack;

// 进入下一页面,如果不能前进,则什么也不干
- (nullable WKNavigation *)goForward;

// 重新载入页面
- (nullable WKNavigation *)reload;

// 重新从原始URL载入
- (nullable WKNavigation *)reloadFromOrigin;

// 停止加载数据
- (void)stopLoading;

// 执行JS代码
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ __nullable)(__nullable id, NSError * __nullable error))completionHandler;

// 标识是否支持左、右swipe手势是否可以前进、后退
@property (nonatomic) BOOL allowsBackForwardNavigationGestures;

// 自定义user agent,如果没有则为nil
@property (nullable, nonatomic, copy) NSString *customUserAgent NS_AVAILABLE(10_11, 9_0);

// 在iOS上默认为NO,标识不允许链接预览
@property (nonatomic) BOOL allowsLinkPreview NS_AVAILABLE(10_11, 9_0);

#if TARGET_OS_IPHONE
/*! @abstract The scroll view associated with the web view.
 */
@property (nonatomic, readonly, strong) UIScrollView *scrollView;
#endif

#if !TARGET_OS_IPHONE
// 标识是否支持放大手势,默认为NO
@property (nonatomic) BOOL allowsMagnification;

// 放大因子,默认为1
@property (nonatomic) CGFloat magnification;

// 根据设置的缩放因子来缩放页面,并居中显示结果在指定的点
- (void)setMagnification:(CGFloat)magnification centeredAtPoint:(CGPoint)point;


# 三、WKWebView 和 JS 交互

现在我们正式的简单介绍 WKWebView 是如何和 JS 进行交互的;使用过 jsbridge 或者写过 Android 的 webview 和 js 交互的同学都清楚,所谓的 oc 能调用 js 的代码 需要 js 的函数挂在到 window 下面才行。

先看效果图:

# 1. JS 调用 OC 程序

js 调用 OC 程序一共需要实现如下几个步骤:

  • 编写 html 并使用window.webkit.messageHandlers.方法名称.postMessage();调用 oc
  • 初始化 WKWebView 配置 WKWebViewConfiguration,并设置可执行 js 代码;

  • 初始化 WKWebView

  • 注册所有需要调用的方法

  • WKWebView 加载 html

第一步: 编写 html 并使用window.webkit.messageHandlers.方法名称.postMessage();调用 oc

注意点:

  • postMessage 的参数不能为空什么都不写(至少也要写一个 null),不然不会走代理方法
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script>
      // oc调用js代码
      function ocToH51(params, params2) {
        document.getElementsByClassName("data")[0].innerHTML =
          params + "\n" + params2;
      }
      function h5ToOC1() {
        // 在OC中可以通过message.name isEqualsString h5ToOC1来获取到此处调用的参数
        window.webkit.messageHandlers.h5ToOC1.postMessage(
          "我是h5传递过来的一个参数"
        );
      }
      function h5ToOC2() {
        // 在OC中可以通过message.name isEqualsString h5ToOC2来获取到此处调用的参数
        window.webkit.messageHandlers.h5ToOC2.postMessage([
          "我是h5传递过来的一个参数",
          "我是第二个参数",
        ]);
      }
    </script>

    <style>
      .container {
        display: flex;
        flex-direction: column;
      }

      .container button {
        margin-top: 20px;
        background-color: dodgerblue;
        color: white;
        border-radius: 5px;
        height: 50px;
        border: 1px solid white;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h2>我是H5页面</h2>
      <button onclick="h5ToOC1()">传递单个参数给iOS</button>
      <button onclick="h5ToOC2()">传递多个参数给iOS</button>

      <h3>如下是oc传递给H5的数据</h3>
      <div class="data"></div>
    </div>
  </body>
</html>

第二步:初始化 WKWebViewConfiguration 并设置可执行 JS;



- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    // 初始化WKWebViewConfiguration并设置可执行JS;
    WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
    // 默认为0
    config.preferences.minimumFontSize = 10;
    //是否支持JavaScript
    config.preferences.javaScriptEnabled = YES;
    //不通过用户交互,是否可以打开窗口
    config.preferences.javaScriptCanOpenWindowsAutomatically = NO;

    CGSize size=  UIScreen.mainScreen.bounds.size;
    // 初始化webview
    self.webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, size.width, size.height/2) configuration:config];
    // JS调用OC代码,需要在OC端先注册有哪些方法,这样才能在WKScriptMessageHandler的协议方法实现
    [self registerJSCallOCMethod:config];
    // 使用webview加载url
    [self loadHTML];

    [self.view addSubview:self.webView];

}

/**
 使用webview加载url
 */
- (void) loadHTML{

//    加载本地的地址
//    NSString *html = [NSBundle.mainBundle pathForResource:@"index" ofType:@"html"];
//    NSURL *url = [[NSBundle mainBundle] bundleURL];
//    [self.webView loadHTMLString:[NSString stringWithContentsOfFile: html encoding:NSUTF8StringEncoding error:nil]  baseURL: url ];

    // 加载远程的URL,10.66.147.65请换成自己本地的ip地址
    NSURL *url = [[NSURL alloc] initWithString:@"http://10.66.147.65:3000"];
    NSURLRequest *request = [[NSURLRequest alloc] initWithURL:url];
    [self.webView loadRequest:request];
}

// JS调用OC代码,需要在OC端先注册有哪些方法,这样才能在WKScriptMessageHandler的协议方法实现
- (void) registerJSCallOCMethod: (WKWebViewConfiguration *) config{
    if(config!=nil){
        // 获取到WKUserContentController,通过WKUserContentController注册方法
        WKUserContentController *controller = config.userContentController;
        [controller addScriptMessageHandler:self name:@"h5ToOC1"];
        [controller addScriptMessageHandler:self name:@"h5ToOC2"];
    }
}

注意点: 使用完成之后需要释放 WKUserContentController,不然会造成内存泄漏

泄漏的原因: 我们最开始 使用[controller addScriptMessageHandler:self name:@"h5ToOC1"]注册了方法中controller持有了self,然后 controller 又被config持有,最终被webview持有,然后 webviewself 的一个私有变量,所以 self 也持有 self,所以,这个时候有循环引用的问题存在,导致界面被 pop 或者 dismiss 之后依然会存在内存中。不会被释放;

// 注意,必须在dealloc方法调用的时候处理,释放资源
- (void)dealloc{
    WKUserContentController *container = self.webView.configuration.userContentController;
    [container removeScriptMessageHandlerForName:@"h5ToOC1"];
    [container removeScriptMessageHandlerForName:@"h5ToOC2"];
}

# 2. OC 调用 JS 方法

OC 必须调用挂在到 window 下的 JS 方法,且主要使用evaluateJavaScript去执行 js:


- (IBAction)ocToH5:(id)sender {
    [self.webView evaluateJavaScript:@"ocToH51('你好,mrgaogang','mrgaogang.github.io')" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
        //JS 返回结果
        NSLog(@"%@ %@",response,error);
    }];
}

# 3. OC 和 JS 数据类型映射

不管是 js 传递给 OC 还是 OC 调用 JS,我们需要清除 OC 和 JS 数据类型,以便能够使用适合的数据结构获取数据。

# 四、WKWebView 的问题

加载 Cookie 的时候的几个注意事项

  • WKWebView 加载网页得到的 Cookie 会同步到 NSHTTPCookieStorage 中。
  • WKWebView 加载请求时,不会同步 NSHTTPCookieStorage 中已有的 Cookie。
  • 通过共用一个 WKProcessPool 并不能解决 2 中 Cookie 同步问题,且可能会造成 Cookie 丢失。

在请求头中添加 cookie,这样的话只要保证[NSHTTPCookieStorage sharedHTTPCookieStorage]中存在你的 cookie,第一次请求就不会有问题了

NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://www.LynkCo.com"]];
NSArray *cookies = [NSHTTPCookieStorage sharedHTTPCookieStorage].cookies;
//Cookies数组转换为requestHeaderFields
NSDictionary *requestHeaderFields = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
//设置请求头
request.allHTTPHeaderFields = requestHeaderFields;
[self.webView loadRequest:request];

只需要通过添加 WKUserScript 就可以了,只要保证 sharedHTTPCookieStorage 中你的 Cookie 存在,后续 Ajax 请求就不会有问题。


/*!
 *  更新webView的cookie
 */
- (void)updateWebViewCookie
{
    WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource:[self cookieString] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
    //添加Cookie
    [self.configuration.userContentController addUserScript:cookieScript];
}

- (NSString *)cookieString
{
    NSMutableString *script = [NSMutableString string];
    [script appendString:@"var cookieNames = document.cookie.split('; ').map(function(cookie) { return cookie.split('=')[0] } );\n"];
    for (NSHTTPCookie *cookie in [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]) {
        // Skip cookies that will break our script
        if ([cookie.value rangeOfString:@"'"].location != NSNotFound) {
            continue;
        }
        // Create a line that appends this cookie to the web view's document's cookies
        [script appendFormat:@"if (cookieNames.indexOf('%@') == -1) { document.cookie='%@'; };\n", cookie.name, cookie.da_javascriptString];
    }
    return script;
}


//核心方法:
/**
 修复打开链接Cookie丢失问题

 @param request 请求
 @return 一个fixedRequest
 */
- (NSURLRequest *)fixRequest:(NSURLRequest *)request
{
    NSMutableURLRequest *fixedRequest;
    if ([request isKindOfClass:[NSMutableURLRequest class]]) {
        fixedRequest = (NSMutableURLRequest *)request;
    } else {
        fixedRequest = request.mutableCopy;
    }
    //防止Cookie丢失
    NSDictionary *dict = [NSHTTPCookie requestHeaderFieldsWithCookies:[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies];
    if (dict.count) {
        NSMutableDictionary *mDict = request.allHTTPHeaderFields.mutableCopy;
        [mDict setValuesForKeysWithDictionary:dict];
        fixedRequest.allHTTPHeaderFields = mDict;
    }
    return fixedRequest;
}

#pragma mark - WKNavigationDelegate

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {

#warning important 这里很重要
    //解决Cookie丢失问题
    NSURLRequest *originalRequest = navigationAction.request;
    [self fixRequest:originalRequest];
    //如果originalRequest就是NSMutableURLRequest, originalRequest中已添加必要的Cookie,可以跳转
    //允许跳转
    decisionHandler(WKNavigationActionPolicyAllow);
    //可能有小伙伴,会说如果originalRequest是NSURLRequest,不可变,那不就添加不了Cookie了,是的,我们不能因为这个问题,不允许跳转,也不能在不允许跳转之后用loadRequest加载fixedRequest,否则会出现死循环,具体的,小伙伴们可以用本地的html测试下。

    NSLog(@"%@", NSStringFromSelector(_cmd));
}

#pragma mark - WKUIDelegate

- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures {

#warning important 这里也很重要
    //这里不打开新窗口
    [self.webView loadRequest:[self fixRequest:navigationAction.request]];
    return nil;
}

# 4. 其他问题

详情请见: WKWebView 那些坑 (opens new window)

参考

【未经作者允许禁止转载】 Last Updated: 10/14/2021, 11:20:21 AM