iOS-WKWebView适配实战篇

3次阅读

共计 8947 个字符,预计需要花费 23 分钟才能阅读完成。

一、Cookie 适配

1. 现状

WKWebView 适配中最麻烦的就是 cookie 同步问题

WKWebView 采用了独立存储控件,因此和以往的 UIWebView 并不互通

虽然 iOS11 以后,iOS 开放了 WKHTTPCookieStore 让开发者去同步,但是还是需要考虑低版本的 同步问题,本章节从各个角度切入考虑 cookie 同步问题

2. 同步 cookie(NSHTTPCookieStorage->WKHTTPCookieStore)

iOS11+

可以直接使用 WKHTTPCookieStore 遍历方式设值,可以在创建 wkwebview 时候就同步也可以是请求时候

// iOS11 同步 HTTPCookieStorag 到 WKHTTPCookieStore
WKHTTPCookieStore *cookieStore = self.wkWebView.configuration.websiteDataStore.httpCookieStore;

- (void)syncCookiesToWKCookieStore:(WKHTTPCookieStore *)cookieStore  API_AVAILABLE(ios(11.0)){NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
    if (cookies.count == 0) return;
    for (NSHTTPCookie *cookie in cookies) {
        [cookieStore setCookie:cookie completionHandler:^{if ([cookies.lastObject isEqual:cookie]) {[self wkwebviewSetCookieSuccess];
            }
        }];
    }
}

同步 cookie 可以在初始化 wkwebview 的时候,也可以在请求的时候。初始化时候同步可以确保发起 html 页面请求的时候带上 cookie

例如:请求在线页面时候要通过 cookie 来认证身份,如果不是初始化时同步,可能请求页面时就是 401 了

iOS11-

通过前端执行 js 注入 cookie, 在请求时候执行

//wkwebview 执行 JS
- (void)injectCookiesLT11 {WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource:[self cookieString] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
    [self.wkWebView.configuration.userContentController addUserScript:cookieScript];
}
// 遍历 NSHTTPCookieStorage, 拼装 JS 并执行
- (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;}
        [script appendFormat:@"if (cookieNames.indexOf('%@') == -1) {document.cookie='%@';};\n", cookie.name, [self formatCookie:cookie]];
    }
    return script;
}
//Format cookie 的 js 方法
- (NSString *)formatCookie:(NSHTTPCookie *)cookie {
    NSString *string = [NSString stringWithFormat:@"%@=%@;domain=%@;path=%@",
                        cookie.name,
                        cookie.value,
                        cookie.domain,
                        cookie.path ?: @"/"];
    if (cookie.secure) {string = [string stringByAppendingString:@";secure=true"];
    }
    return string;
}

但是上面方法执行 js,也无法保证第一个页面请求带有 cookie

所以请求时候创建 request 需要设置 cookie,并且 loadRequest

-(void)injectRequestCookieLT11:(NSMutableURLRequest*)mutableRequest {
    // iOS11 以下,手动同步所有 cookie
    NSArray *cookies = NSHTTPCookieStorage.sharedHTTPCookieStorage.cookies;
    NSMutableArray *mutableCookies = @[].mutableCopy;
    for (NSHTTPCookie *cookie in cookies) {[mutableCookies addObject:cookie];
    }
    // Cookies 数组转换为 requestHeaderFields
    NSDictionary *requestHeaderFields = [NSHTTPCookie requestHeaderFieldsWithCookies:(NSArray *)mutableCookies];
    // 设置请求头
    mutableRequest.allHTTPHeaderFields = requestHeaderFields;
}

3. 反向同步 cookie(WKHTTPCookieStore->NSHTTPCookieStorage)

wkwebview 产生的 cookie 也可能在某些场景需要同步给 NSHTTPCookieStorage

iOS11+ 可以直接用 WKHTTPCookieStore 去同步,

iOS11- 可以采用 js 端获取,触发 bridge 同步给 NSHTTPCookieStorage

但是 js 同步方式无法同步 httpOnly,所以真的遇到了,还是要结合服务器等方式去做这个同步。

二、JS 和 Native 通信

1.Native 调用 JS

将代码准备完毕后调用 API 即可,回调函数可以接收 js 执行结果或者错误信息,So Easy。

   [self.wkWebView evaluateJavaScript:jsCode completionHandler:^(id object, NSError *error){}];

2. 注入 JS

其实就是提前注入一些 JS 方法,可以提供给 JS 端调用。

比如有的框架会将 bridge 直接通过这种方式注入到 WK 的执行环境中,而不是从前端引入 JS, 这种好处就是假设前端的 JS 是在线加载,JS 服务器挂了或者网络问题,这样前端页面就失去了 Naitve 的 Bridge 通信能力了。

-(instancetype)initWithSource:(NSString *)source injectionTime:(WKUserScriptInjectionTime)injectionTime forMainFrameOnly:(BOOL)forMainFrameOnly;

//WKUserScriptInjectionTime 说明
typedef NS_ENUM(NSInteger, WKUserScriptInjectionTime) {
    WKUserScriptInjectionTimeAtDocumentStart, /** 文档开始时候就注入 **/
    WKUserScriptInjectionTimeAtDocumentEnd /** 文档加载完成时注入 **/
} API_AVAILABLE(macos(10.10), ios(8.0));

3.JS 调用 Native

3-1. 准备代理类

代理类要实现 WKScriptMessageHandler

@interface WeakScriptMessageDelegate : NSObject<WKScriptMessageHandler>
  @property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate;
  - (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate;
@end

WKScriptMessageHandler 就一个方法

@implementation WeakScriptMessageDelegate
- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate {self = [super init];
    if (self) {_scriptDelegate = scriptDelegate;}
    return self;
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {[self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}

3-2. 设置代理类

合适时机(一般初始化)设置代理类,并且指定 name

NSString* MessageHandlerName = @"bridge";
[config.userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:MessageHandlerName];

3-3.bridge 的使用 (JS 端)

执行完上面语句后就会在 JS 端注入了一个对象 ”window.webkit.messageHandlers.bridge

//JS 端发送消息,参数最好选用 String,比较通用
window.webkit.messageHandlers.bridge.postMessage("type");

3-4.Native 端消息的接收

然后 native 端可以通过 WKScriptMessage 的 body 属性中获得传入的值

- (void)userContentController:(WKUserContentController*)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{if ([message.name isEqualToString:HistoryBridageName]) {} else if ([message.name isEqualToString:MessageHandlerName]) {[self jsToNativeImpl:message.body];
    }
}

3-5. 思考题

这里我们为什么要使用 WeakScriptMessageDelegate,并且再设置个 delegate 指向 self(controller), 为什么不直接指向?

提示:可以参考 NSTimer 的循环引用问题

3-6. 完整的示例

-(void)_defaultConfig{WKWebViewConfiguration* config = [WKWebViewConfiguration new];
   …… ……
   …… ……
   WKUserContentController* userController = [[WKUserContentController alloc] init];
   config.userContentController = userController;
   [self injectHistoryBridge:config];
   …… ……
   …… ……     
}

-(void)injectHistoryBridge:(WKWebViewConfiguration*)config{[config.userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:HistoryBridageName];
    NSString *_jsSource = [NSString stringWithFormat:
                           @"(function(history) {\n"
                           "function notify(type) {\n"
                           "setTimeout(function() {\n"
                           "window.webkit.messageHandlers.%@.postMessage(type)\n"
                           "}, 0)\n"
                           "}\n"
                           "function shim(f) {\n"
                           "return function pushState() {\n"
                           "notify('other')\n"
                           "return f.apply(history, arguments)\n"
                           "}\n"
                           "}\n"
                           "history.pushState = shim(history.pushState)\n"
                           "history.replaceState = shim(history.replaceState)\n"
                           "window.addEventListener('popstate', function() {\n"
                           "notify('backforward')\n"
                           "})\n"
                           "})(window.history)\n", HistoryBridageName
                           ];
    WKUserScript *script = [[WKUserScript alloc] initWithSource:_jsSource injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];
    [config.userContentController addUserScript:script];
}

3-7. 其它问题

在 iOS8 beta5 前,JS 和 Native 这样通信设置是不行的,所以可以采用生命周期中做 URL 的拦截去解析数据来达到效果,这里不做赘述,可以自行参考网上类似 UIWebview 的桥接原理文章

三、实战技巧

1.UserAgent 的设置

添加 UA

实际过程中最好只是原有 UA 上做添加操作,全部替换可能导致服务器的拒绝(安全策略)

日志中红线部分是整个模拟器的 UA,绿色部门是 UA 中的 ApplicationName 部分

iOS9 上,WKWebview 提供了 API 可以设置 ua 中的 ApplicationName

config.applicationNameForUserAgent = [NSString stringWithFormat:@"%@ %@", config.applicationNameForUserAgent, @"arleneConfig"];

全部替换 UA

iOS9 以上直接可以指定 wkwebview 的 customUserAgent,iOS9 以下的话,设置 NSUserDefaults

if (@available(iOS 9.0, *)) {self.wkWebView.customUserAgent = @"Hello My UserAgent";}else{[[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent":@"Hello My UserAgent"}];
   [[NSUserDefaults standardUserDefaults] synchronize];
}

2. 监听进度和页面的 title 变化

wkwebview 可以监控页面加载进度,类似浏览器中打开页面中的进度条的显示

页面切换的时候也会自动更新页面中设置的 title, 可以在实际项目中动态切换容器的 title,比如根据切换的 title 设置 navigationItem.title

原理直接通过 KVO 方式监听值的变化,然后在回调中处理相关逻辑

//kvo 加载进度
[self.webView addObserver:self
              forKeyPath:@"estimatedProgress"
              options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
              context:nil];
//kvo title
[self.webView addObserver:self
              forKeyPath:@"title"
              options:NSKeyValueObservingOptionNew
              context:nil];

/** KVO 监听具体回调 **/
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{if ([keyPath isEqual:@"estimatedProgress"] && object == self.webView) {ALLOGF(@"Progress--->%@",[NSNumber numberWithDouble:self.webView.estimatedProgress]);
    }else if([keyPath isEqualToString:@"title"]
             && object == self.webview){self.navigationItem.title = self.webView.title;}else{[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

/** 销毁时候记得移除 **/
[self.webView removeObserver:self
           forKeyPath:NSStringFromSelector(@selector(estimatedProgress))];
[self.webView removeObserver:self
                     forKeyPath:NSStringFromSelector(@selector(title))];

3.Bridge 通信实战

下面介绍自己实现的 bridge 通信框架,前端无需关心所在容器,框架层做适配。

import {WebBridge} from 'XXX'
/**
* 方法: WebBridge.call(taskName,options,callback)
* 参数说明: 
*    taskName String task 的名字,用于 Native 处理分发任务的标识
* options  Object 传递的其它参数
* callback function 回调函数
*.         回调参数
*                     json object native 返回的内容
**/
WebBridge.call("Alert",{"content":"弹框内容","btn":"btn 内容"},function(json){console.log("call back is here",JSON.stringify(json));
});

上面调用了 Native 的 Alert 控件,然后返回调用结果。

调用到的 Native 代码如下:

//AlertTask.m
#import "AlertTask.h"
#import <lib-base/ALBaseConstants.h>
@interface AlertTask (){}
@property (nonatomic,weak) ArleneWebViewController* mCtrl;
@end

@implementation AlertTask
-(instancetype)initWithContext:(ArleneWebViewController*)controller{self = [super init];
    self.mCtrl = controller;
    return self;
}
-(NSString*)taskName{return @"Alert";}
-(void)doTask:(NSDictionary*)params{ALShowAlert(@"Title",@"message");// 弹出 Alert
    NSMutableDictionary* callback = [ArleneTaskUtils basicCallback:params];// 获取 callback
    [callback addEntriesFromDictionary:params];
    [self.mCtrl callJS:callback];// 执行回调
}
@end

具体实现原理可以点击下方视频链接:

点击获取框架原理视频

关于我

期待与要求上进的您进一步沟通

微信号:maako127

扫描下方二维码加入我的公众号(二码前端说),定期更新前端相关技术干货

正文完
 0