【瞎搞iOS開發(fā)08】WKWebView (含JS交互) 踩坑、填坑小結(jié)

簡書的Markdown內(nèi)部跳轉(zhuǎn)太別扭,建議看原始版本
最近在開發(fā)一款大部分都是Web網(wǎng)頁的APP,項(xiàng)目采用WKWebView加載網(wǎng)頁,我和Web端的兄弟對(duì)WebKit都不熟悉,踩了不少坑。在此對(duì)WKWebView的使用做些小結(jié),另外填些踩過的坑。



  • 配置WKWebView
  • 利用KVO實(shí)現(xiàn)進(jìn)度條
  • WKNavigationDelegate協(xié)議
  • WKUIDelegate協(xié)議
  • JS交互實(shí)現(xiàn)流程
  • JS交互 踩坑+填坑
  • 參考文獻(xiàn)


配置WKWebView

對(duì)WKWebView就不細(xì)說了,貼出主要的代碼,有興趣可以看看末尾的參考文獻(xiàn)

    /// 偏好設(shè)置,涉及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()) {
//        /// 緩存機(jī)制(未研究)
//        configuration.websiteDataStore = [WKWebsiteDataStore defaultDataStore];
//    }
    configuration.userContentController = [[WKUserContentController alloc] init];
    
    WKWebView * webView = [[WKWebView alloc]initWithFrame:JKMainScreen configuration:configuration];

    /// 側(cè)滑返回上一頁,側(cè)滑返回不會(huì)加載新的數(shù)據(jù),選擇性開啟
    self.webView.allowsBackForwardNavigationGestures = YES;
    /// 在這個(gè)代理相應(yīng)的協(xié)議方法可以監(jiān)聽加載網(wǎng)頁的周期和結(jié)果
    self.webView.navigationDelegate = self;
    /// 這個(gè)代理對(duì)應(yīng)的協(xié)議方法常用來顯示彈窗
    self.webView.UIDelegate = self;
    
    
    /// 如果涉及到JS交互,比如Web通過JS調(diào)iOS native,最好在[webView loadRequest:]前注入JS對(duì)象,詳細(xì)代碼見文章后半部分代碼。
    self.jsBridge = [[JSBridge alloc]initWithUserContentController:configuration.userContentController];
    self.jsBridge.webView = webView;
    self.jsBridge.webViewController = self;

利用KVO實(shí)現(xiàn)進(jìn)度條

KVO能監(jiān)聽加載進(jìn)度,也能監(jiān)聽當(dāng)前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協(xié)議,監(jiān)聽網(wǎng)頁加載周期


/// 發(fā)送請(qǐng)求前決定是否跳轉(zhuǎn),并在此攔截?fù)艽螂娫挼腢RL
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler{
    
    /// decisionHandler(WKNavigationActionPolicyCancel);不允許加載
    /// decisionHandler(WKNavigationActionPolicyAllow);允許加載
    
    decisionHandler(WKNavigationActionPolicyAllow);
}


/// 收到響應(yīng)后決定是否跳轉(zhuǎn)
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler{
    decisionHandler(WKNavigationResponsePolicyAllow);
}


/// 內(nèi)容開始加載
- (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];
        /// 無網(wǎng)絡(luò)(APP第一次啟動(dòng)并且沒有得到網(wǎng)絡(luò)授權(quán)時(shí)可能也會(huì)報(bào)錯(cuò))
        
    } else if (error.code == NSURLErrorCancelled){
    /// -999 上一頁面還沒加載完,就加載當(dāng)下一頁面,就會(huì)報(bào)這個(gè)錯(cuò)。
        return;
    }
    JKLog(@"webView加載失敗:error %@",error);
}


WKUIDelegate協(xié)議,常用來顯示UIAlertController彈窗


// 在JS端調(diào)用alert函數(shù)時(shí)(警告彈窗),會(huì)觸發(fā)此代理方法。
// 通過completionHandler()回調(diào)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端調(diào)用confirm函數(shù)時(shí)(確認(rèn)、取消式彈窗),會(huì)觸發(fā)此方法
// completionHandler(true)返回結(jié)果
- (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調(diào)用prompt函數(shù)(輸入框)時(shí)回調(diào),completionHandler回調(diào)結(jié)果
- (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交互實(shí)現(xiàn)流程

如果用WKWebView,JS調(diào)iOS端必須使用window.webkit.messageHandlers.kJS_Name.postMessage(null),跟調(diào)安卓的不一樣,kJS_Name是iOS端提供的JS交互name,在注入JS交互Handler時(shí)用到:[userContentController addScriptMessageHandler:self name:kJS_Name]

下面有個(gè)HTML端的iOSCallJsAlert函數(shù),里面會(huì)執(zhí)行alert彈窗,并通過JS調(diào)iOS端(kJS_Name)

function iOSCallJsAlert() {
        alert('彈個(gè)窗,再調(diào)用iOS端的kJS_Name');
        window.webkit.messageHandlers.kJS_Name.postMessage({body: 'paramters'});
}

咱要實(shí)現(xiàn)在iOS端通過JS調(diào)用這個(gè)iOSCallJsAlert函數(shù),并接受JS調(diào)iOS端的ScriptMessage。有以下主要代碼:

首先添加JS交互的消息處理者(遵守WKScriptMessageHandler協(xié)議)以及JS_Name(一般由iOS端提供給Web端)。

[WKUserContentController addScriptMessageHandler:JS_ScriptMessageReceiver name:JS_Name]

有添加就有移除,一般在ViewDidDisappear中移除,不然JS_ScriptMessageReceiver會(huì)被強(qiáng)引用而無法釋放(內(nèi)存泄露),個(gè)人猜測(cè)是被WebKit里面某個(gè)單例強(qiáng)引用。

[userContentController removeScriptMessageHandlerForName:JS_Name]

實(shí)現(xiàn)WKScriptMessageHandler協(xié)議方法,用來接收J(rèn)S調(diào)iOS的消息。
WKScriptMessage.name即[WKUserContentController addScriptMessageHandler:JS_ScriptMessageReceiver name:JS_Name]中的JS_Name,可以區(qū)分不同的JS交互,message.body是傳遞的參數(shù)。

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    JKLog(@"JS調(diào)iOS  name : %@    body : %@",message.name,message.body);
}

iOS端調(diào)JS中的函數(shù)就簡單多了,調(diào)用一個(gè)方法即可。
@"iOSCallJsAlert()"代表要調(diào)用的函數(shù)名,如果有參數(shù)就這樣寫@"iOSCallJsAlert('p1','p2')"

[webView evaluateJavaScript:@"iOSCallJsAlert()" completionHandler:nil]

我之前是看了標(biāo)哥的文章,講的很細(xì),現(xiàn)在找不到原文了,就找了個(gè)轉(zhuǎn)載的文章,詳見參考文獻(xiàn)


JS交互 踩坑、填坑


  • 沒移除ScriptMessageHandler導(dǎo)致內(nèi)存泄露,解決方案已在上面提到。
[userContentController removeScriptMessageHandlerForName:JS_Name]
  • 如果對(duì)一個(gè)WKWebView進(jìn)行多次loadRequest,而這個(gè)WKWebView只進(jìn)行一次JS注入,就可能出現(xiàn)后面loadRequest的網(wǎng)頁無法通過JS調(diào)iOS端(也許跟Web端有關(guān)),解決方案是在每次loadRequest前重新注入JS對(duì)象。另外為了避免內(nèi)存泄露(JS_ScriptMessageReceiver會(huì)被強(qiáng)引用而無法釋放),要將之前的注入的JS對(duì)象移除掉。對(duì)loadRequest和注入JS進(jìn)行了接口封裝,代碼如下:

WebViewController.m

/// JSBridge是封裝的JS交互橋梁,遵守WKScriptMessageHandler協(xié)議
- (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 實(shí)現(xiàn)WKScriptMessageHandler協(xié)議方法

@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;
}

/// 接收J(rèn)S調(diào)iOS的事件消息
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    JKLog(@"JS調(diào)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中無參數(shù),JS代碼中需要傳個(gè)null,不然iOS端不會(huì)接受到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">撥號(hào)</a>直接調(diào)iOS撥打電話的功能,需要我們?cè)赪KNavigationDelegate協(xié)議方法中截取URL中的號(hào)碼再撥打電話。
/// 發(fā)送請(qǐng)求前決定是否跳轉(zhuǎn),并在此攔截?fù)艽螂娫挼腢RL
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler{
    
    /// <a href="tel:123456789">撥號(hào)</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);
    }
}

  • 執(zhí)行goBackreloadgoToBackForwardListItem后馬上執(zhí)行loadRequest,即一起執(zhí)行,在didFailProvisionalNavigation方法中會(huì)報(bào)錯(cuò),error.code = -999( NSURLErrorCancelled)。
[self.webView goBack];
[self.webView loadRequest:[[NSURLRequest alloc] initWithURL:URL]];

原因是上一頁面還沒加載完,就加載當(dāng)下一頁面,會(huì)取消加載之前的URL并報(bào)-999錯(cuò)誤。

- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error{

     if (error.code == NSURLErrorCancelled){
    /// -999 
        return;
    }
}

解決方案是在執(zhí)行goBackreloadgoToBackForwardListItem后延遲一會(huì)兒(0.5秒)再執(zhí)行loadRequest。

[self.webView goBack];
/// 延遲加載新的url,否則報(bào)錯(cuò)-999
[self excuteDelayTask:0.5 InMainQueue:^{
      [self.webView loadRequest:[[NSURLRequest alloc] initWithURL:URL]];
}];

  • 如果開啟了側(cè)滑返回上一頁的功能,即self.webView.allowsBackForwardNavigationGestures = YES; WKWebView側(cè)滑返回會(huì)直接加載之前緩存下來的數(shù)據(jù)(也有說是緩存了渲染),不會(huì)刷新界面,而有時(shí)需要在返回后刷新數(shù)據(jù),就需要做特殊處理。
    比如咱實(shí)現(xiàn)后面頁面跳轉(zhuǎn)邏輯:A --> B --> C --> A(刷新數(shù)據(jù))
    A頁跳到B頁,在B頁執(zhí)行一些任務(wù)后展示C頁面,但是C頁側(cè)滑要返回到A頁面,并且此過程中A會(huì)刷新數(shù)據(jù)。
    具體實(shí)現(xiàn)的邏輯是B --> C的過程中先goBack到A,同時(shí)保留返回的WKNavigation對(duì)象,加載完A后,根據(jù)WKNavigation對(duì)A reload一次,再loadRequest跳到C,這樣C返回到A就是新的數(shù)據(jù)。
    所以可對(duì)之前封裝的loadRequset接口reloadWebViewWithUrl進(jìn)行二次封裝。這是最終版本
- (void)reloadWebViewWithUrl:(NSString *)url backToHomePage:(BOOL)backToHomePage{
    
    void (^LoadWebViewBlock)() = ^() {
        /// 每次加載新url前重新注入JS對(duì)象
        [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) {
        /// 返回首頁再跳轉(zhuǎn),并且保留WKNavigation對(duì)象
        self.gobackNavigation = [self.webView goToBackForwardListItem:self.webView.backForwardList.backList.firstObject];
        
        /// 延遲加載新的url,否則報(bào)錯(cuò)-999
        [self excuteDelayTask:0.5 InMainQueue:^{
            LoadWebViewBlock();
        }];
    } else {
        LoadWebViewBlock();
    }
}



/// 根據(jù)self.gobackNavigation重載頁面
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation{

    /// 之前的代碼已省略
    /// 新增下面的代碼
    if ([navigation isEqual:self.gobackNavigation] || !navigation) {
        /// 重載刷新
        [self.webView reload];
        self.gobackNavigation = nil;
    }
}

暫時(shí)沒有分享完整的代碼,下周不忙就整理下代碼。

參考文獻(xiàn)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容