【瞎搞iOS开发08】WKWebView (含JS交互) 踩坑、
简书的Markdown内部跳转太别扭,建议看原始版本
最近在开发一款大部分都是Web网页的APP,项目采用WKWebView加载网页,我和Web端的兄弟对WebKit都不熟悉,踩了不少坑。在此对WKWebView的使用做些小结,另外填些踩过的坑。
- 配置WKWebView
- 利用KVO实现进度条
- WKNavigationDelegate协议
- WKUIDelegate协议
- JS交互实现流程
- JS交互 踩坑+填坑
- 参考文献
配置WKWebView
对WKWebView就不细说了,贴出主要的代码,有兴趣可以看看末尾的参考文献
/// 偏好设置,涉及JS交互
WKWebViewConfiguration * configuration = [[WKWebViewConfiguration alloc] init];
configuration.preferences = [[WKPreferences alloc]init];
configuration.preferences.javaScriptEnabled = YES;
configuration.preferences.javaScriptCanOpenWindowsAutomatically = NO;
configuration.processPool = [[WKProcessPool alloc]init];
configuration.allowsInlineMediaPlayback = YES;
// if (iOS9()) {
// /// 缓存机制(未研究)
// configuration.websiteDataStore = [WKWebsiteDataStore defaultDataStore];
// }
configuration.userContentController = [[WKUserContentController alloc] init];
WKWebView * webView = [[WKWebView alloc]initWithFrame:JKMainScreen configuration:configuration];
/// 侧滑返回上一页,侧滑返回不会加载新的数据,选择性开启
self.webView.allowsBackForwardNavigationGestures = YES;
/// 在这个代理相应的协议方法可以监听加载网页的周期和结果
self.webView.navigationDelegate = self;
/// 这个代理对应的协议方法常用来显示弹窗
self.webView.UIDelegate = self;
/// 如果涉及到JS交互,比如Web通过JS调iOS native,最好在[webView loadRequest:]前注入JS对象,详细代码见文章后半部分代码。
self.jsBridge = [[JSBridge alloc]initWithUserContentController:configuration.userContentController];
self.jsBridge.webView = webView;
self.jsBridge.webViewController = self;
利用KVO实现进度条
KVO能监听加载进度,也能监听当前Url的Title。
UIProgressView *progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
[webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:nil];
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"estimatedProgress"]) {
if (object == self.webView) {
if (self.webView.estimatedProgress == 1.0) {
self.progressView.progress = 1.0;
[UIView animateWithDuration:0.2 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
self.progressView.alpha = 0.0f;
} completion:nil];
} else {
self.progressView.progress = self.webView.estimatedProgress;
}
}
}
- (void)dealloc{
[self.webView removeObserver:self forKeyPath:@"estimatedProgress"];
}
}
WKNavigationDelegate协议,监听网页加载周期
/// 发送请求前决定是否跳转,并在此拦截拨打电话的URL
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler{
/// decisionHandler(WKNavigationActionPolicyCancel);不允许加载
/// decisionHandler(WKNavigationActionPolicyAllow);允许加载
decisionHandler(WKNavigationActionPolicyAllow);
}
/// 收到响应后决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler{
decisionHandler(WKNavigationResponsePolicyAllow);
}
/// 内容开始加载
- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation{
self.progressView.alpha = 1.0;
}
/// 加载完成
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation{
[self hideErrorView];
if (self.progressView.progress < 1.0) {
[UIView animateWithDuration:0.1 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
self.progressView.alpha = 0.0f;
} completion:nil];
}
/// 禁止长按弹窗,UIActionSheet样式弹窗
[webView evaluateJavaScript:@"document.documentElement.style.webkitTouchCallout='none';" completionHandler:nil];
/// 禁止长按弹窗,UIMenuController样式弹窗(效果不佳)
[webView evaluateJavaScript:@"document.documentElement.style.webkitUserSelect='none';" completionHandler:nil];
}
/// 加载失败
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error{
if (error.code == NSURLErrorNotConnectedToInternet) {
[self showErrorView];
/// 无网络(APP第一次启动并且没有得到网络授权时可能也会报错)
} else if (error.code == NSURLErrorCancelled){
/// -999 上一页面还没加载完,就加载当下一页面,就会报这个错。
return;
}
JKLog(@"webView加载失败:error %@",error);
}
WKUIDelegate协议,常用来显示UIAlertController弹窗
// 在JS端调用alert函数时(警告弹窗),会触发此代理方法。
// 通过completionHandler()回调JS
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler{
JKAlertManager * manager = [JKAlertManager alertWithPreferredStyle:UIAlertControllerStyleAlert title:@"提示" message:message];
[manager configueCancelTitle:nil destructiveIndex:JKAlertDestructiveIndexNone otherTitle:@"确定", nil];
[manager showAlertFromController:self actionBlock:^(JKAlertManager *tempAlertManager, NSInteger actionIndex, NSString *actionTitle) {
if (actionIndex != tempAlertManager.cancelIndex) {
completionHandler();
}
}];
}
// JS端调用confirm函数时(确认、取消式弹窗),会触发此方法
// completionHandler(true)返回结果
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler{
JKAlertManager * manager = [JKAlertManager alertWithPreferredStyle:UIAlertControllerStyleAlert title:@"提示" message:message];
[manager configueCancelTitle:@"取消" destructiveIndex:JKAlertDestructiveIndexNone otherTitle:@"确定", nil];
[manager showAlertFromController:self actionBlock:^(JKAlertManager *tempAlertManager, NSInteger actionIndex, NSString *actionTitle) {
if (actionIndex != tempAlertManager.cancelIndex) {
completionHandler(YES);
}else{
completionHandler(NO);
}
}];
}
/// JS调用prompt函数(输入框)时回调,completionHandler回调结果
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable))completionHandler{
JKAlertManager * manager = [JKAlertManager alertWithPreferredStyle:UIAlertControllerStyleAlert title:@"提示" message:prompt];
[manager configueCancelTitle:@"取消" destructiveIndex:JKAlertDestructiveIndexNone otherTitle:@"确定", nil];
[manager addTextFieldWithPlaceholder:defaultText secureTextEntry:NO ConfigurationHandler:^(UITextField * _Nonnull textField) {
} textFieldTextChanged:^(UITextField * _Nullable textField) {
}];
[manager showAlertFromController:self actionBlock:^(JKAlertManager * _Nullable tempAlertManager, NSInteger actionIndex, NSString * _Nullable actionTitle) {
completionHandler(tempAlertManager.textFields.firstObject.text);
}];
}
JS交互实现流程
如果用WKWebView,JS调iOS端必须使用window.webkit.messageHandlers.kJS_Name.postMessage(null)
,跟调安卓的不一样,kJS_Name是iOS端提供的JS交互name,在注入JS交互Handler时用到:[userContentController addScriptMessageHandler:self name:kJS_Name]
下面有个HTML端的iOSCallJsAlert函数,里面会执行alert弹窗,并通过JS调iOS端(kJS_Name)
function iOSCallJsAlert() {
alert('弹个窗,再调用iOS端的kJS_Name');
window.webkit.messageHandlers.kJS_Name.postMessage({body: 'paramters'});
}
咱要实现在iOS端通过JS调用这个iOSCallJsAlert函数,并接受JS调iOS端的ScriptMessage。有以下主要代码:
首先添加JS交互的消息处理者(遵守WKScriptMessageHandler协议)以及JS_Name(一般由iOS端提供给Web端)。
[WKUserContentController addScriptMessageHandler:JS_ScriptMessageReceiver name:JS_Name]
有添加就有移除,一般在ViewDidDisappear中移除,不然JS_ScriptMessageReceiver会被强引用而无法释放(内存泄露),个人猜测是被WebKit里面某个单例强引用。
[userContentController removeScriptMessageHandlerForName:JS_Name]
实现WKScriptMessageHandler协议方法,用来接收JS调iOS的消息。
WKScriptMessage.name即[WKUserContentController addScriptMessageHandler:JS_ScriptMessageReceiver name:JS_Name]
中的JS_Name,可以区分不同的JS交互,message.body是传递的参数。
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
JKLog(@"JS调iOS name : %@ body : %@",message.name,message.body);
}
iOS端调JS中的函数就简单多了,调用一个方法即可。
@"iOSCallJsAlert()"代表要调用的函数名,如果有参数就这样写@"iOSCallJsAlert('p1','p2')"
[webView evaluateJavaScript:@"iOSCallJsAlert()" completionHandler:nil]
我之前是看了标哥的文章,讲的很细,现在找不到原文了,就找了个转载的文章,详见参考文献
JS交互 踩坑、填坑
- 没移除ScriptMessageHandler导致内存泄露,解决方案已在上面提到。
[userContentController removeScriptMessageHandlerForName:JS_Name]
- 如果对一个WKWebView进行多次loadRequest,而这个WKWebView只进行一次JS注入,就可能出现后面loadRequest的网页无法通过JS调iOS端(也许跟Web端有关),解决方案是在每次loadRequest前重新注入JS对象。另外为了避免内存泄露(JS_ScriptMessageReceiver会被强引用而无法释放),要将之前的注入的JS对象移除掉。对loadRequest和注入JS进行了接口封装,代码如下:
WebViewController.m
/// JSBridge是封装的JS交互桥梁,遵守WKScriptMessageHandler协议
- (void)reloadWebViewWithUrl:(NSString *)url{
// 先移除
[self.jsBridge removeAllUserScripts];
// 再注入
self.jsBridge.userScriptNames = @[kJS_Login,kJS_Logout,kJS_Alipay,kJS_WeiChatPay,kJS_Location];
// 再加载URL
self.urlStr = url;
[self.webView loadRequest:[[NSURLRequest alloc] initWithURL:self.urlStr.URL]];
}
- (void)viewDidDisappear:(BOOL)animated{
[super viewDidDisappear:animated];
/// 移除,避免JS_ScriptMessageReceiver被引用
[self.jsBridge removeAllUserScripts];
}
JSBridge.m 实现WKScriptMessageHandler协议方法
@interface JSBridge ()<WKScriptMessageHandler>
@property (nonatomic, weak)WKUserContentController * userContentController;
@end
- (instancetype)initWithUserContentController:(WKUserContentController *)userContentController{
if (self = [super init]) {
_userContentController = userContentController;
}return self;
}
/// 注入JS MessageHandler和Name
- (void)setUserScriptNames:(NSArray *)userScriptNames{
_userScriptNames = userScriptNames;
[userScriptNames enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[self.userContentController addScriptMessageHandler:self name:obj];
}];
}
/// 移除JS MessageHandler
- (void)removeAllUserScripts{
[self.userScriptNames enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[self.userContentController removeScriptMessageHandlerForName:obj];
}];
self.userScriptNames = nil;
}
/// 接收JS调iOS的事件消息
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
JKLog(@"JS调iOS name : %@ body : %@",message.name,message.body);
if ([message.name isEqualToString:kJS_Login]) {
/// 登录JS
} else if ([message.name isEqualToString:kJS_Logout]) {
/// 退出JS
}
}
@end
- 如果message.body中无参数,JS代码中需要传个null,不然iOS端不会接受到JS交互,window.webkit.messageHandlers.kJS_Login.postMessage(null)
- 如果在网页上点击某些链接却不响应,试试再实现一个协议方法(属于WKUIDelegate协议),参考http://stackoverflow.com/questions/25713069/why-is-wkwebview-not-opening-links-with-target-blank
-(WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures {
if (!navigationAction.targetFrame.isMainFrame) {
[webView loadRequest:navigationAction.request];
}
return nil;
}
- HTML不能通过
<a href="tel:123456789">拨号</a>
直接调iOS拨打电话的功能,需要我们在WKNavigationDelegate协议方法中截取URL中的号码再拨打电话。
/// 发送请求前决定是否跳转,并在此拦截拨打电话的URL
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler{
/// <a href="tel:123456789">拨号</a>
if ([navigationAction.request.URL.scheme isEqualToString:@"tel"]) {
decisionHandler(WKNavigationActionPolicyCancel);
NSString * mutStr = [NSString stringWithFormat:@"telprompt://%@",navigationAction.request.URL.resourceSpecifier];
if ([[UIApplication sharedApplication] canOpenURL:mutStr.URL]) {
if (iOS10()) {
[[UIApplication sharedApplication] openURL:mutStr.URL options:@{} completionHandler:^(BOOL success) {}];
} else {
[[UIApplication sharedApplication] openURL:mutStr.URL];
}
}
} else {
decisionHandler(WKNavigationActionPolicyAllow);
}
}
- 执行
goBack
或reload
或goToBackForwardListItem
后马上执行loadRequest
,即一起执行,在didFailProvisionalNavigation方法中会报错,error.code = -999( NSURLErrorCancelled)。
[self.webView goBack];
[self.webView loadRequest:[[NSURLRequest alloc] initWithURL:URL]];
原因是上一页面还没加载完,就加载当下一页面,会取消加载之前的URL并报-999错误。
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error{
if (error.code == NSURLErrorCancelled){
/// -999
return;
}
}
解决方案是在执行goBack
或reload
或goToBackForwardListItem
后延迟一会儿(0.5秒)再执行loadRequest
。
[self.webView goBack];
/// 延迟加载新的url,否则报错-999
[self excuteDelayTask:0.5 InMainQueue:^{
[self.webView loadRequest:[[NSURLRequest alloc] initWithURL:URL]];
}];
- 如果开启了侧滑返回上一页的功能,即
self.webView.allowsBackForwardNavigationGestures = YES
; WKWebView侧滑返回会直接加载之前缓存下来的数据(也有说是缓存了渲染),不会刷新界面,而有时需要在返回后刷新数据,就需要做特殊处理。
比如咱实现后面页面跳转逻辑:A --> B --> C --> A(刷新数据)
A页跳到B页,在B页执行一些任务后展示C页面,但是C页侧滑要返回到A页面,并且此过程中A会刷新数据。
具体实现的逻辑是B --> C的过程中先goBack到A,同时保留返回的WKNavigation对象,加载完A后,根据WKNavigation对A reload一次,再loadRequest跳到C,这样C返回到A就是新的数据。
所以可对之前封装的loadRequset接口reloadWebViewWithUrl进行二次封装。这是最终版本:
- (void)reloadWebViewWithUrl:(NSString *)url backToHomePage:(BOOL)backToHomePage{
void (^LoadWebViewBlock)() = ^() {
/// 每次加载新url前重新注入JS对象
[self.jsBridge removeAllUserScripts];
self.jsBridge.userScriptNames = @[kJS_Login,kJS_Logout,kJS_Alipay,kJS_WeiChatPay,kJS_Location];
self.urlStr = url;
[self.webView loadRequest:[[NSURLRequest alloc] initWithURL:self.urlStr.URL]];
};
if (self.webView.backForwardList.backList.count && backToHomePage) {
/// 返回首页再跳转,并且保留WKNavigation对象
self.gobackNavigation = [self.webView goToBackForwardListItem:self.webView.backForwardList.backList.firstObject];
/// 延迟加载新的url,否则报错-999
[self excuteDelayTask:0.5 InMainQueue:^{
LoadWebViewBlock();
}];
} else {
LoadWebViewBlock();
}
}
/// 根据self.gobackNavigation重载页面
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation{
/// 之前的代码已省略
/// 新增下面的代码
if ([navigation isEqual:self.gobackNavigation] || !navigation) {
/// 重载刷新
[self.webView reload];
self.gobackNavigation = nil;
}
}
暂时没有分享完整的代码,下周不忙就整理下代码。