WKWebView使用指南
github
地址: ofollow,noindex">JXBWKWebView ,如果覺得專案不錯可以點個star支援一下,謝謝~
前言
目前iOS系統已經更新到iOS11,大多數專案向下相容最多相容到iOS8,因此,在專案中對WebView元件進行重構再封裝時,打算直接捨棄 UIWebView
轉用 WKWebView
。
如果你目前正在網上瀏覽關於 WKWebView
的一些文章,相信你已經清楚了 WKWebView
的優點,也目睹了大家在使用 WKWebView
的過程中遇到的坑,而這篇文章,會對到目前為止大家遇到的關於 WKWebView
的問題給出詳細的解決方案,文章的最後,也會講述關於對 WKWebView
進行效能優化的方案。
解決的問題
-
goback
返回頁面不重新整理 -
Cookie
-
POST
請求失效 -
crash
-
navigationBackItem
- 進度條
-
Native
與JS
的互動 - 優化
H5
頁面啟動速度
入坑
goback Api
返回不重新整理
在之前使用 UIWebView
時,呼叫 goback
後,頁面會重新整理。使用 WKWebView
後,呼叫 goback
,即便呼叫 reload
方法, H5
依然不會重新整理。
原因是呼叫 goback
時, UIWebView
會觸發 onload
事件, WKWebView
不會觸發 onload
事件,如果前端依舊在 onload
事件中處理 iOS
的頁面返回事件,是處理不了的,解決方案是讓前端使用 onpageshow
事件監聽 WKWebView
的頁面 goback
事件。
前端程式碼如下:
window.addEventListener("pageshow", function(event){ if(event.persisted){ location.reload(); } });
為了檢視頁面是直接從伺服器上載入還是從快取中讀取,可以使用 PageTransitionEvent
物件的 persisted
屬性來判斷。
如果頁面從瀏覽器的快取中讀取該屬性返回 ture
,否則返回 false
。然後在根據 true
或 false
在執行相應的頁面重新整理動作或者直接 ajax
請求介面更新資料。
關於 onload
和 onpageshow
事件在 safari
和 chrome
上的區別如下:
. | 事件 | Chrome | Safari |
---|---|---|---|
第一次載入頁面 | onload | 觸發 | 觸發 |
第一次載入頁面 | onpageshow | 觸發 | 觸發 |
從其他頁面返回 | onload | 觸發 | 不觸發 |
從其他頁面返回 | onpageshow | 觸發 | 觸發 |
關於cookie
WKWebView
屬於 webkit
框架,其將瀏覽器核心渲染程序提取出 App
主程序,由另外一個程序進行管理,減少了相當一部分的效能損失,這也是效能上比 UIWebView
優越的原因之一。
既然 WKWebView
的工作程序獨立於 App Process
之外,我們暫且稱為 WK Process
(隨便起的)。
在使用 AFN
進行網路請求時,如果 server
使用 set-cookie
將 cookie
寫入 header
, AFN
接受到響應後會將 cookie
儲存到 NSHTTPCookieStorage
,下次如果是同域的 request url
, AFN
會將 cookie
從 NSHTTPCookieStorage
中取出然後作為 request header
的 cookie
傳送給 server
端,而這一切發生在 App Process
。
那麼在 WK Process
工作的 WKWebView
在傳送網路請求及收到響應後對 cookie
的處理是否也會使用 NSHTTPCookieStorage
呢,經過測試後,答案是 yes
,但在存取的過程中會有一些問題需要注意。
先說存:
iphone 6p iOS:10
測試過程:
1. client
使用 AFN
傳送一個網路請求
server
接收到請求後,使用
set-cookie
寫入
cookie
3. client
接收到 success response
後,使用如下方式輸出 log
:
NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:fields forURL:url]; for (NSHTTPCookie *cookie in cookies) { NSLog(@"cookie,name:= %@,valuie = %@",cookie.name,cookie.value); }
4.進入 WKWebView
所在頁面,使用 loadRequest
隨便傳送一個同域的網路請求,在 decidePolicyForNavigationResponse
代理方法中,使用如下程式碼輸出 log
:
NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response; NSArray *cookies =[NSHTTPCookie cookiesWithResponseHeaderFields:[response allHeaderFields] forURL:response.URL]; for (NSHTTPCookie *cookie in cookies) { NSLog(@"wkwebview中的cookie:%@", cookie); }
也可以使用如下程式碼輸出該請求的 server response header
的 set-cookie
:
NSString *cookieString = [[response allHeaderFields] valueForKey:@"Set-Cookie"];
那麼, WKWebView
將 cookie
存入 NSHTTPCookieStorage
的時機是什麼時候?
1. JS
執行 document.cookie
或伺服器 set-cookie
注入的 Cookie
會很快同步到 NSHTTPCookieStorage
中。
2. H5
頁面進行跳轉時會將 Cookie
同步到 NSHTTPCookieStorage
中。
3.控制器頁面跳轉時會將 Cookie
同步到 NSHTTPCookieStorage
中。
再說取:
WKWebView
使用 loadRequest
傳送網路時不會主動將 cookie
存入到 NSHTTPCookieStorage
中,即使是同域的請求。
所以,如果你有一個請求需要附帶 cookie
,就不能直接載入 URL
,需要你根據 URL
建立一個 URLMutableRequest
物件,將需要附加的 cookie
使用 addValue:forHTTPHeaderField:
方法手動將 cookie
新增到 request header
中,但這僅能解決首次請求不帶 cookie
的問題,如果頁面傳送 ajax
請求, cookie
同樣帶不上,解決方案是通過 document.cookie
設定 cookie
,也就是說在你例項化 WKWebView
時就應該注入相關 script
。
上面我們說的都是在同域的情況下,如果發生 302
請求 (可以理解域名發生變化,也就是說不同域)
,上面的解決方案就用不了了,這時就需要你在 WKWebView
的 decidePolicyForNavigationAction
代理方法中攔截 URL
,判斷當前 URL
與初次請求的 URL
是否同域,如果不同域,在該代理方法中獲取到當前請求的 request
物件並 copy
出一個新的物件,通過 addValue:forHeaderField:
方法將 cookie
手動新增到 header
中,然後讓 WKWebView
使用 loadRequest
重新載入這個 copy
出來的新的 request
物件。
問題就沒了嗎? NO
,上面的解決方法同樣有侷限,即只能解決後續的同域 ajax
請求不加 cookie
的問題。如果發生 iframe
跨域請求,我們攔截不到請求,所以也沒法給請求的 header
手動新增 cookie
, WKWebView
只適合載入 mainFrame
請求。
所以,要和前端同學提前打好招呼,儘量避免使用 iframe
,能使用 ajax
的地方儘量使用 ajax
,另一方面, iframe
現在已經不怎麼提倡使用了,除非是解決一些特殊的問題。
POST請求
使用 WKWebView
無法正常傳送 POST
請求。
所以,這個時候我們需要通過自定義 NSURLProtocol
攔截 WKWebView
的網路請求,並且,使用 NSURLProtocol
攔截 WKWebView
網路請求的好處還有就是:
1.如果產品需求要求 client
需要日誌採集,包括所有的網路請求記錄,通過這種方式你是可以獲取到的。
2.如果公司對使用者體驗的要求較高,可以在這裡實現 WKWebView
初始化和相關網路請求的併發執行,以縮短使用者在 client
開啟 H5
的速度,甚至可以秒開,達到和 native
相同的體驗。
但問題是正常情況下 NSURLProtocol
是攔截不到 WKWebView
的網路請求的。
通過觀看 webkit
的原始碼( github
直接搜 webkit
)可以得到的結果是,通過 WKWebView
傳送一個網路請求其實也會走 NSURLProtocol
,只不過 Apple
把 http
和 https
這兩個 scheme
給過濾掉了,導致我們攔截不到 WKWebView
傳送的網路請求。
因此,在我們自定義 NSURLProtocol
時,要通過使用私有 api
來註冊一些 scheme
,註冊 scheme
的類名叫 WKBrowsingContextController
, WKWebView
中有一個屬性叫 browsingContextController
,就是這個類的物件。註冊的方法叫 registerSchemeForCustomProtocol:
,知道這個私有 api
,我們就可以通過 target-action
的方式,註冊 WKWebView
發起網路請求時需要攔截的 URL scheme
,此時註冊的 scheme
至少要包括3種,分別是 http
、 https
、 post
。
問題還沒玩,解決一個問題的同時往往伴隨另一個問題的產生。
使用這種方案攔截 WKWebView
的網路請求造成的問題就是 post
請求 body
資料被清空,還是 Apple
所為,看 webkit
原始碼:
void ArgumentCoder<ResourceRequest>::encodePlatformData(Encoder& encoder, const ResourceRequest& resourceRequest) { RetainPtr<CFURLRequestRef> requestToSerialize = resourceRequest.cfURLRequest(DoNotUpdateHTTPBody); bool requestIsPresent = requestToSerialize; encoder << requestIsPresent; if (!requestIsPresent) return; // We don't send HTTP body over IPC for better performance. // Also, it's not always possible to do, as streams can only be created in process that does networking. RetainPtr<CFDataRef> requestHTTPBody = adoptCF(CFURLRequestCopyHTTPRequestBody(requestToSerialize.get())); RetainPtr<CFReadStreamRef> requestHTTPBodyStream = adoptCF(CFURLRequestCopyHTTPRequestBodyStream(requestToSerialize.get())); if (requestHTTPBody || requestHTTPBodyStream) { CFMutableURLRequestRef mutableRequest = CFURLRequestCreateMutableCopy(0, requestToSerialize.get()); requestToSerialize = adoptCF(mutableRequest); CFURLRequestSetHTTPRequestBody(mutableRequest, nil); CFURLRequestSetHTTPRequestBodyStream(mutableRequest, nil); } RetainPtr<CFDictionaryRef> dictionary = adoptCF(WKCFURLRequestCreateSerializableRepresentation(requestToSerialize.get(), IPC::tokenNullTypeRef())); IPC::encode(encoder, dictionary.get()); // The fallback array is part of CFURLRequest, but it is not encoded by WKCFURLRequestCreateSerializableRepresentation. encoder << resourceRequest.responseContentDispositionEncodingFallbackArray(); encoder.encodeEnum(resourceRequest.requester()); }
主要看程式碼中間那兩句註釋,大致的意思就是 Apple
不會在程序間通訊傳送 http
的 body
。
因為 WKWebView
屬於 webkit
框架,因此 WKWebView
的網路請求、內容載入/渲染都是在 WK Process
中進行,但 NSURLProtocol
攔截請求還在 App Process
,一旦註冊 http(s) scheme
後,網路請求將從獨立程序中傳送到 App Process
,這樣自定義的 NSURLProtocol
才能攔截到網路請求,為了提升程序間通訊效率,出於效能上的考慮, Apple
會將 request
的 body
資料丟棄,因為 body
資料(二進位制型別)大小沒有限制, size
偏大的話就會對資料傳輸效率有嚴重影響進而影響到攔截請求時的操作及延時後續的網路請求,因此, Apple
在進行程序間通訊時會把 post
請求的 body
丟棄。
如何解決?
終極思路就是雖然 http
的 body
會在程序間通訊時被丟棄,但 header
不會。
因此,解決問題步驟如下:
-
WKWebView
在loadRequest
前對request
物件進行一些處理,這個request
物件我們記為old request
。
1.記下old request
的scheme
和NSData
型別的http body
。
2.獲取當前old request
的URL
,替換URL
的scheme
為post
(這也是我們為什麼要在前面使用NSURLProtocol
註冊post scheme
的原因),並根據這個替換好的URL
重新生成一個新的NSMutableURLRequest
物件,這個物件記為new request
。
3.給new request
的header
賦值,把步驟1中獲取的scheme
和http body
手動新增到這個new request
的header
中,如果這個post
請求需要附帶cookie
的話,你也要把cookie
從old request
中拿出來放到new request
的header
中。
4.讓WKWebView
載入這個new request
。 -
WKWebView
傳送新的request
時(這個request url
的scheme
是post
),我們可以在自定義NSURLProtocol
中攔截到這個請求,執行如下步驟:
1.替換scheme
,此時的scheme
是post
,你需要把post scheme
替換成old request
的scheme
,這個欄位我們之前已經儲存下來了。
2.替換scheme
後會生成一個新的URL
,根據這個新的URL
生成一個NSURLMutableRequest
物件,將之前儲存的http body
、cookie
放到這個新的request
物件的header
中。
3.使用NSURLSession
,根據新的request
物件傳送網路請求,然後通過NSURLProtocol Client
將載入結果返回給WKWebView
。
注意:在這幾個步驟中一共產生了3個 request
物件。
crash
1. alert
彈窗
引起 crash
的原因是 js
呼叫 alert()
引起的,也就是說,當 WKWebView
銷燬的時候, JS
剛好執行了 alert()
,原生的 alert
彈窗可能彈不出來, completionHandler
回撥最後沒有被執行,導致 crash
;另一種情況是在 WKWebView
剛開啟, JS
就執行 alert()
,這個時候由於 WKWebView
所在的 UIViewController
的 push
或 present
的動畫尚未結束, alert
框可能彈不出來, completionHandler
最後沒有被執行,導致 crash
。
解決方案:獲取當前 window
上最終的 UIViewController
,判斷 UIViewController
是否未被銷燬、 UIViewController
是否已經載入完成、動畫是否執行完畢。
2.另一個 crash
發生在 WKWebView
退出前呼叫:
執行JS程式碼的情況下。WKWebView 退出並被釋放後導致 completionHandler
變成野指標,而此時 javaScript Core 還在執行JS程式碼,待 javaScript Core 執行完畢後會呼叫 completionHandler()
,導致 crash
。這個 crash
只發生在 iOS 8
系統上,參考 Apple Open Source
,在 iOS9
及以後系統蘋果已經修復了這個 bug
,主要是對 completionHandler block
做了 copy(refer: https://trac.webkit.org/changeset/179160)
;對於 iOS 8
系統,可以通過在 completionHandler
裡 retain WKWebView
防止 completionHandler
被過早釋放。
解決方案是使用 method swizzling hook
了這個系統方法,在回撥中對 self
進行了強引用來保證在執行 completionHandler
的時候 self
還在。
navigationBackItem
實現導航欄 back item
的方式有兩種。
- 自定義導航欄
這個比較簡單,根據 WebView
是否可以 goback
決定 navigationBarButtonItems
的個數和功能。
- 使用系統預設的導航返回按鈕,類似於微信
難點在於我們要獲取到點選系統導航返回按鈕時的事件,然後進行一些處理。
點選返回按鈕時,實際上呼叫了 UINavigationController
的 navigationBar:shouldPopItem
方法,我們可以使用 method swizzling hook
住這個方法,在這個方法中通過呼叫代理方法的方式告訴 WKWebView
所在的 UIViewController
進行相應的處理。
UIProgressView
這個簡單,也不多說了。
Native與JS的互動
- 攔截URL
在 WKWebView
的 decidePolicyForNavigationAction
代理方法中可對 URL
進行攔截,一般使用攔截 URL
的方式 URL
的格式如下:
scheme://host?paramKey=paramValue
一般情況下 scheme
對應業務, host
是業務對應的服務( method
), ?
後面就是引數。
使用攔截 URL
的互動方式時,業務邏輯不復雜情況下, JS
呼叫 Native
沒什麼問題,但當業務邏輯複雜時, JS
需要拿到 Native
處理好的回撥資料的話,處理起來將十分麻煩。
並且使用攔截 URL
的互動方式,不利於今後 JS
與 Native
的業務拓展。
- 使用
Bridge
WKWebView
對 JS
與 Native
通過 Bridge
互動提供了非常好的支援,我們可以通過 ScriptMessageHandler
來達成各種互動的目的。使用 ScriptMessageHandler
新增指令碼的具體程式碼在此不多贅述,大家可自行研究。重點說一下 Bridge
的指令碼程式碼。
現在關於 Bridge
的開源解決方案有很多,但基本都遵循一個模式,在注入的 Bridge
指令碼程式碼中,定義好供 JS
呼叫的方法名稱,該方法通常包括如下幾個引數:
1.要呼叫的 native
業務模組名稱(有些有,有些沒有,如果專案中實施模組化建議加上)。
2.要呼叫的 native
服務名稱(通常是方法名)。
3.傳遞給 native
的引數(也就是方法需要的引數)。
4. callback
, JS
呼叫 native
的方法後腳本需要呼叫的回撥。
詳細來描述一下使用 Bridge
整個互動過程,從建立 Bridge
指令碼到 Bridge
指令碼執行 callback
:
Bridge
指令碼下稱指令碼。
1.指令碼為 JS
提供 JavaScript
語言的方法,該方法用來呼叫 native
方法,方法的4個引數如前所述。
2.在該方法中,會根據前述的部分引數生成一個唯一識別符號,記為 identifier
。
3.在指令碼中給全域性物件( window
)繫結一個字典屬性, key
是步驟2中的 identifier
, value
是 callback
。
4.呼叫 messagehandler
的 postMessage
函式,將前述的引數和 identifier
都發送給 native
(沒發 callback
,callback的作用主要就是步驟3)。
5.前端呼叫你的指令碼中的程式碼呼叫 native
的方法,具體程式碼可參見 Apple
官方文件。
5. native
在自定義的 MessageHandler
物件的 userContentController:didReceiveScriptMessage:
代理方法中接收到 JS
傳過來的引數(記為 param
)。獲取到了模組名稱、服務名稱、引數、identifier等,額外的,需要建立幾個 block
,對應 JS
那邊的 callback
,比如 JS
那邊有個 success callback
,那麼在 native
就要有一個 success block
,而建立的這些 block
,我們會賦值給前面說的那個 param
裡面,那麼現在,這個param有如下幾個值:
targetName(模組名稱) actionName(服務名稱) identifier(通過該屬性最後我們可以找到js的callback) success block failure block progress block 上面這些引數基本上已經夠了,如果需要擴充套件就自己加吧
那麼這些 block
裡面的操作主要是什麼呢? block
封裝了 WKWebView
的 evaluateJavaScript
操作,這個 block
最後可以拿到 native
處理任務後的結果和 identifier
,然後把結果轉換為 json
資料,通過 identifier
找到 JS
那邊的 callback
,然後把結果的 json
資料作為 callback
的引數回傳給 JS
那邊。程式碼如下:
NSString *resultDataString = [self jsonStringWithData:resultDictionary]; NSString *callbackString = [NSString stringWithFormat:@"window.Callback('%@', '%@', '%@')", identifier, result, resultDataString]; [message.webView evaluateJavaScript:callbackString completionHandler:nil];
6.利用 target-action
機制,根據 targetName
例項化物件,根據 actionName
呼叫方法,並把引數( param
)傳遞過去,目標物件將任務處理完成後,呼叫 param
的 success block, failure block, progress block
,將任務處理的結果回傳給 JS
。
- 互動總結
無論是攔截 URL
還是使用 Bridge
,最後呼叫 native
方法的機制都是利用 target-action
,使用 target-action
機制的原因之一就是可減少類與類之間的耦合程度,減少硬編碼的同時有利於今後的業務擴充套件。
當然,如果你不喜歡 target-action
的方案,也可以自行擴充套件。
攔截WKWebView的網路請求
通過觀看 WebKit
的原始碼可以瞭解到 WKWebView
是支援攔截網路請求的,但是 WebKit
沒有註冊需要攔截的 scheme
,所以我們只能進行手動註冊了。
手動註冊需要呼叫 WKWebView
的私有 api
,註冊 scheme
的私有 api
是 registerSchemeForCustomProtocol:
,登出的私有 api
是 unregisterSchemeForCustomProtocol:
,有些同學會考慮到在專案中使用私有 api
在稽核時會被蘋果爸爸打回,我這裡測試不會,如果你遇到了被打回的情況,可以把私有 api
拆分成多個字串,然後把多個字串拼接在一起。
所以攔截WKWebView網路請求的步驟是:
(1)自定義 NSURLProtocol
,用來處理攔截到的網路請求。
(2)利用系統提供的 NSURLProtocol
註冊(1)中自定義的 NSURLProtocol
。
(3)通過私有 api
註冊需要攔截的網路請求的 scheme
。
(4)在合適的時機登出(3)中註冊的 scheme
。
H5啟動效能優化
H5
最讓人詬病的一點就是它的使用者體驗沒有 native
好,其實 H5
的互動效果(不包括複雜的動效)已經非常接近於 native
了,所以剩下的缺點總體來說就是關於 WebView
的渲染問題,我們在寫 native
介面的時候,頁面一開啟就能看到我們建立的 UI
元素,但是遠端的 H5
不能,因為遠端 H5
的頁面元素都需要去伺服器獲取,隨後經過渲染才能展示,過程大致如下:

H5啟動流程
所以,一個 H5
頁面完全展示給使用者所需要的時間遠比 native
頁面長的多。
所以針對於移動端來說,優化 H5
啟動效能的點主要有兩個:
(1)優化 WebView
的啟動速度
(2)讓 HTML/CSS/JavaScript
檔案下載的更快一些,也就是離線包方案。
(1)優化 WebView
的啟動速度
App
開啟的時候並不會初始化瀏覽器核心,當我們建立一個 WKWebView
的時候,系統才會初始化瀏覽器核心,也就是說,當我們第一次用 WebView
開啟 H5
的時候, H5
的顯示時間需要加上瀏覽器核心啟動時間,所以優化點就在於優化瀏覽器核心啟動時間。
很多解決方案是初始化一個單例 WebView
,讓這一個 WebView
全域性可用,這樣開啟每個 H5
的時候用的都是同一個 WebView
物件,工作原理有點接近 PC
端瀏覽器,這樣做的缺點就是如果這個 WebView
因為某些原因導致異常終止之後,再用這個 WebView
開啟 H5
可能會產生一些意料之外的問題,所以,這裡推薦使用另外一種解決方案。
另外一種解決方案就是維護一個全域性的 WebView
複用池,複用原理同 UITableViewCell
一樣,這裡不細講。如果一個 WebView
一直是正常工作的就放入複用池中,如果一個 WebView
因為某些原因異常終止,那麼就把這個 WebView
從複用池中移除。
無論是哪種複用方案,都會產生一個新問題,當我們利用複用 WebView
開啟一個新 H5
的時候,瀏覽器的瀏覽歷史記錄裡還保留著上一次開啟的 H5
的痕跡,所以,我們需要在複用時清除這個痕跡並讓頁面開啟一個空白頁。
(2)使用離線包打包 H5
的靜態資源。
我們通過一個遠端 URL
開啟 H5
就可以理解為是線上開啟的。
把一個 H5
的 HTML/CSS/JavaScript
檔案分別打包成靜態資原始檔儲存在伺服器,這些儲存在伺服器的靜態資原始檔就可以理解為是離線包,移動端可以選擇一個合適的時機下載離線包,然後在本地解壓縮,當我們開啟一個 H5
的時候其實開啟的是已經下載到本地的 HTML
檔案,免去了線上拉取資源的過程,從而節省了時間。
當 H5
頁面需要更新的時候,直接對離線包做增量更新可以了。
更多細節可參考 bang
的這篇 文章 。
基於WKWebView封裝的JXBWKWebView
1.核心決定了 goback
返回不重新整理問題需要前端支援
natigationBackItem & navigationLeftItems
3.支援自定義
rightBarButtonItem
4.支援進度條
5.提供 cookie
解決方案,首次自己加,後續的 ajax
請求自動加, 302
請求自動加
6.支援攔截 WKWebView
攔截網路請求
7.支援 POST
請求
8.支援子類繼承
9.支援攔截 URL
的互動方式,支援自定義攔截 URL
操作。
10.提供 native
與 H5
的互動解決方案,支援自定義 MessageHandler
操作。
11.提供 H5
秒開解決方案, server
使用 Go
實現。
12. iOS
和 Android
為 JS
提供統一的原生呼叫方式。
github
地址: JXBWKWebView ,如果覺得專案不錯可以點個star支援一下,謝謝~