簡書的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)
- 如果在網(wǎng)頁上點(diǎn)擊某些鏈接卻不響應(yīng),試試再實(shí)現(xiàn)一個(gè)協(xié)議方法(屬于WKUIDelegate協(xié)議),參考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">撥號(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í)行
goBack或reload或goToBackForwardListItem后馬上執(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í)行goBack或reload或goToBackForwardListItem后延遲一會(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í)沒有分享完整的代碼,下周不忙就整理下代碼。