【瞎搞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)

-(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);
    }
}

  • 执行goBackreloadgoToBackForwardListItem后马上执行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;
    }
}

解决方案是在执行goBackreloadgoToBackForwardListItem后延迟一会儿(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;
    }
}

暂时没有分享完整的代码,下周不忙就整理下代码。

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容