WKWebView適配中最麻煩的就是cookie同步問題
WKWebView采用了獨立存儲控件,因此和以往的UIWebView并不互通
雖然iOS11以后,iOS開放了WKHTTPCookieStore讓開發者去同步,但是還是需要考慮低版本的 同步問題,本章節從各個角度切入考慮cookie同步問題
iOS11+
可以直接使用WKHTTPCookieStore遍歷方式設值,可以在創建wkwebview時候就同步也可以是請求時候
// iOS11同步 HTTPCookieStorag到WKHTTPCookieStore
WKHTTPCookieStore *cookieStore = self.wkWebView.configuration.websiteDataStore.httpCookieStore;
- (void)syncCookiesToWKCookieStore:(WKHTTPCookieStore *)cookieStore API_AVAILABLE(ios(11.0)){
NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
if (cookies.count == 0) return;
for (NSHTTPCookie *cookie in cookies) {
[cookieStore setCookie:cookie completionHandler:^{
if ([cookies.lastObject isEqual:cookie]) {
[self wkwebviewSetCookieSuccess];
}
}];
}
}
同步cookie可以在初始化wkwebview的時候,也可以在請求的時候。初始化時候同步可以確保發起html頁面請求的時候帶上cookie
例如:請求在線頁面時候要通過cookie來認證身份,如果不是初始化時同步,可能請求頁面時就是401了
iOS11-
通過前端執行js注入cookie,在請求時候執行
//wkwebview執行JS
- (void)injectCookiesLT11 {
WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource:[self cookieString] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
[self.wkWebView.configuration.userContentController addUserScript:cookieScript];
}
//遍歷NSHTTPCookieStorage,拼裝JS并執行
- (NSString *)cookieString {
NSMutableString *script = [NSMutableString string];
[script appendString:@"var cookieNames = document.cookie.split('; ').map(function(cookie) { return cookie.split('=')[0] } );\n"];
for (NSHTTPCookie *cookie in NSHTTPCookieStorage.sharedHTTPCookieStorage.cookies) {
// Skip cookies that will break our script
if ([cookie.value rangeOfString:@"'"].location != NSNotFound) {
continue;
}
[script appendFormat:@"if (cookieNames.indexOf('%@') == -1) { document.cookie='%@'; };\n", cookie.name, [self formatCookie:cookie]];
}
return script;
}
//Format cookie的js方法
- (NSString *)formatCookie:(NSHTTPCookie *)cookie {
NSString *string = [NSString stringWithFormat:@"%@=%@;domain=%@;path=%@",
cookie.name,
cookie.value,
cookie.domain,
cookie.path ?: @"/"];
if (cookie.secure) {
string = [string stringByAppendingString:@";secure=true"];
}
return string;
}
但是上面方法執行js,也無法保證第一個頁面請求帶有cookie
所以請求時候創建request需要設置cookie,并且loadRequest
-(void)injectRequestCookieLT11:(NSMutableURLRequest*)mutableRequest {
// iOS11以下,手動同步所有cookie
NSArray *cookies = NSHTTPCookieStorage.sharedHTTPCookieStorage.cookies;
NSMutableArray *mutableCookies = @[].mutableCopy;
for (NSHTTPCookie *cookie in cookies) {
[mutableCookies addObject:cookie];
}
// Cookies數組轉換為requestHeaderFields
NSDictionary *requestHeaderFields = [NSHTTPCookie requestHeaderFieldsWithCookies:(NSArray *)mutableCookies];
// 設置請求頭
mutableRequest.allHTTPHeaderFields = requestHeaderFields;
}
wkwebview產生的cookie也可能在某些場景需要同步給NSHTTPCookieStorage
iOS11+可以直接用WKHTTPCookieStore去同步,
iOS11-可以采用js端獲取,觸發bridge同步給NSHTTPCookieStorage
但是js同步方式無法同步httpOnly,所以真的遇到了,還是要結合服務器等方式去做這個同步。
將代碼準備完畢后調用API即可,回調函數可以接收js執行結果或者錯誤信息,So Easy。
[self.wkWebView evaluateJavaScript:jsCode completionHandler:^(id object, NSError *error){}];
其實就是提前注入一些JS方法,可以提供給JS端調用。
比如有的框架會將bridge直接通過這種方式注入到WK的執行環境中,而不是從前端引入JS,這種好處就是假設前端的JS是在線加載,JS服務器掛了或者網絡問題,這樣前端頁面就失去了Naitve的Bridge通信能力了。
-(instancetype)initWithSource:(NSString *)source injectionTime:(WKUserScriptInjectionTime)injectionTime forMainFrameOnly:(BOOL)forMainFrameOnly;
//WKUserScriptInjectionTime說明
typedef NS_ENUM(NSInteger, WKUserScriptInjectionTime) {
WKUserScriptInjectionTimeAtDocumentStart, /**文檔開始時候就注入**/
WKUserScriptInjectionTimeAtDocumentEnd /**文檔加載完成時注入**/
} API_AVAILABLE(macos(10.10), ios(8.0));
代理類要實現WKScriptMessageHandler
@interface WeakScriptMessageDelegate : NSObject<WKScriptMessageHandler>
@property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate;
- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate;
@end
WKScriptMessageHandler就一個方法
@implementation WeakScriptMessageDelegate
- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate {
self = [super init];
if (self) {
_scriptDelegate = scriptDelegate;
}
return self;
}
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
[self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}
合適時機(一般初始化)設置代理類,并且指定name
NSString* MessageHandlerName = @"bridge";
[config.userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:MessageHandlerName];
執行完上面語句后就會在JS端注入了一個對象"window.webkit.messageHandlers.bridge"
//JS端發送消息,參數最好選用String,比較通用
window.webkit.messageHandlers.bridge.postMessage("type");
然后native端可以通過WKScriptMessage的body屬性中獲得傳入的值
- (void)userContentController:(WKUserContentController*)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
if ([message.name isEqualToString:HistoryBridageName]) {
} else if ([message.name isEqualToString:MessageHandlerName]) {
[self jsToNativeImpl:message.body];
}
}
這里我們為什么要使用WeakScriptMessageDelegate,并且再設置個delegate指向self(controller),為什么不直接指向?
提示:可以參考NSTimer的循環引用問題
-(void)_defaultConfig{
WKWebViewConfiguration* config = [WKWebViewConfiguration new];
…… ……
…… ……
WKUserContentController* userController = [[WKUserContentController alloc] init];
config.userContentController = userController;
[self injectHistoryBridge:config];
…… ……
…… ……
}
-(void)injectHistoryBridge:(WKWebViewConfiguration*)config{
[config.userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:HistoryBridageName];
NSString *_jsSource = [NSString stringWithFormat:
@"(function(history) {\n"
" function notify(type) {\n"
" setTimeout(function() {\n"
" window.webkit.messageHandlers.%@.postMessage(type)\n"
" }, 0)\n"
" }\n"
" function shim(f) {\n"
" return function pushState() {\n"
" notify('other')\n"
" return f.apply(history, arguments)\n"
" }\n"
" }\n"
" history.pushState = shim(history.pushState)\n"
" history.replaceState = shim(history.replaceState)\n"
" window.addEventListener('popstate', function() {\n"
" notify('backforward')\n"
" })\n"
"})(window.history)\n", HistoryBridageName
];
WKUserScript *script = [[WKUserScript alloc] initWithSource:_jsSource injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];
[config.userContentController addUserScript:script];
}
在iOS8 beta5前,JS和Native這樣通信設置是不行的,所以可以采用生命周期中做URL的攔截去解析數據來達到效果,這里不做贅述,可以自行參考網上類似UIWebview的橋接原理文章
添加UA
實際過程中最好只是原有UA上做添加操作,全部替換可能導致服務器的拒絕(安全策略)
日志中紅線部分是整個模擬器的UA,綠色部門是UA中的ApplicationName部分
iOS9上,WKWebview提供了API可以設置ua中的ApplicationName
config.applicationNameForUserAgent = [NSString stringWithFormat:@"%@ %@", config.applicationNameForUserAgent, @"arleneConfig"];
全部替換UA
iOS9以上直接可以指定wkwebview的customUserAgent,iOS9以下的話,設置NSUserDefaults
if (@available(iOS 9.0, *)) {
self.wkWebView.customUserAgent = @"Hello My UserAgent";
}else{
[[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent":@"Hello My UserAgent"}];
[[NSUserDefaults standardUserDefaults] synchronize];
}
wkwebview可以監控頁面加載進度,類似瀏覽器中打開頁面中的進度條的顯示
頁面切換的時候也會自動更新頁面中設置的title,可以在實際項目中動態切換容器的title,比如根據切換的title設置navigationItem.title
原理直接通過KVO方式監聽值的變化,然后在回調中處理相關邏輯
//kvo 加載進度
[self.webView addObserver:self
forKeyPath:@"estimatedProgress"
options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
context:nil];
//kvo title
[self.webView addObserver:self
forKeyPath:@"title"
options:NSKeyValueObservingOptionNew
context:nil];
/** KVO 監聽具體回調**/
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
if ([keyPath isEqual:@"estimatedProgress"] && object == self.webView) {
ALLOGF(@"Progress--->%@",[NSNumber numberWithDouble:self.webView.estimatedProgress]);
}else if([keyPath isEqualToString:@"title"]
&& object == self.webview){
self.navigationItem.title = self.webView.title;
}else{
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
/**銷毀時候記得移除**/
[self.webView removeObserver:self
forKeyPath:NSStringFromSelector(@selector(estimatedProgress))];
[self.webView removeObserver:self
forKeyPath:NSStringFromSelector(@selector(title))];
下面介紹自己實現的bridge通信框架,前端無需關心所在容器,框架層做適配。
import {WebBridge} from 'XXX'
/**
* 方法: WebBridge.call(taskName,options,callback)
* 參數說明:
* taskName String task的名字,用于Native處理分發任務的標識
* options Object 傳遞的其它參數
* callback function 回調函數
*. 回調參數
* json object native返回的內容
**/
WebBridge.call("Alert",{"content":"彈框內容","btn":"btn內容"},function(json){
console.log("call back is here",JSON.stringify(json));
});
上面調用了Native的Alert控件,然后返回調用結果。
調用到的Native代碼如下:
//AlertTask.m
#import "AlertTask.h"
#import <lib-base/ALBaseConstants.h>
@interface AlertTask (){}
@property (nonatomic,weak) ArleneWebViewController* mCtrl;
@end
@implementation AlertTask
-(instancetype)initWithContext:(ArleneWebViewController*)controller{
self = [super init];
self.mCtrl = controller;
return self;
}
-(NSString*)taskName{
return @"Alert";
}
-(void)doTask:(NSDictionary*)params{
ALShowAlert(@"Title",@"message");//彈出Alert
NSMutableDictionary* callback = [ArleneTaskUtils basicCallback:params];//獲取callback
[callback addEntriesFromDictionary:params];
[self.mCtrl callJS:callback];//執行回調
}
@end
年做了大量的 HTML5 項目,遇到了很多坑。在這個過程中學到了一些之前不具備的知識,所以這篇文章就簡單分享一下這方面的話題。
傳統的MPA
首先,說一個比較古老的東西,叫做 MPA。
MPA 的全稱是 Multi-page Application,意思是整個應用(站點)由多個完整的 html 構成。用戶在頁面 1 點擊跳轉,需要向服務端請求頁面 2,請求成功后渲染。而用戶返回時,相當于是點擊了瀏覽器的返回,頁面退回到之前的歷史記錄,并重新加載出來。
在這樣的模式下,頁面間切換慢、不流暢的問題比較突出,尤其是在移動端。
同時,它還產生了幾個小問題:
SPA
隨著對移動端體驗需求的提高以及技術的進步,另一種模式 SPA(Single-page Application)逐漸成為主流。
SPA 簡單來說,就是原來在 MPA 中的多個 html,現在被放在了一個 html 中,并被分成若干個片段。跳轉、返回的本質變成了分段的「隱藏」與「顯示」。跳轉不需要反復對服務端進行請求,從而使得頁面與頁面之間切換更加快速流暢。
在這樣的機制下,跳轉與返回完全由代碼控制,所以可以通過代碼定義頁面轉場的效果、返回。
在設計轉場動畫時,我們需要留意的是導航欄是 Native 的還是 HTML5 的。如果導航欄是 Native 的,那 HTML5 頁面不包括導航欄,它相當于是網頁外的元素,不在轉場效果的設計范圍內。
WebView
說 HTML5 的跳轉,就不得不說 WebView。簡單來說,WebView 是在 App 中用于顯示 web 內容的容器。上文提到的 MPA 和 SPA,都裝在了這個叫做 WebView 的容器中。
用戶點擊頁面中的元素進行跳轉,除了前面的兩種方式外,還有第三種:新打開 WebView 的方式。在這樣的方式下,跳轉的本質是 HTML5「告訴」Native,由 Native 執行打開新 WebView,并在新 WebView 中加載頁面。
因為 Native 的機制,打開新 WebView 的同時,之前的 WebView 會被自然、完整地保留。所以這時,之前的幾個問題就變為:
不過需要注意的地方是,打開新 WebView 是一個資源消耗比較大的操作。如果我們在設計一個流程時,需要比較多的連續使用這種方式,需要和研發同學進行充分的溝通。
比較特殊的Replace
前述的三種跳轉,都會產生歷史記錄。MPA、SPA 的歷史記錄是在 HTML5 中產生,新開 WebView 中的記錄是在 Native 中產生。
在 MPA 或 SPA 中,如果跳轉時使用 Replace 方法,它會用新頁面替換之前的頁面,歷史記錄中沒有之前頁面的記錄。
這是一種特殊的跳轉方式,在設計一些不可逆的流程時可考慮使用。
多頁面回退
了解了上述的幾種機制后,我們來看一個小的應用場景──多頁面回退。
我們在實際業務中,經常會有這樣的需求。假設我們有 1、2、3 三個頁組成的一個流程,在頁面 3 上有個「完成」按鈕點擊回到頁面 1。在不同的交互模式下,實現這樣的跳轉有著不同的機制。
1. SPA模式下的正常跳轉
這種模式是 3 個頁面都在一個 WebView 中。點擊頁面 3 中的「完成」按鈕,回退 -2 ,即回退 2 步歷史記錄,到頁面 1。
2. 新打開WebView
打開新 WebView 又分三種方式。
如果我們把 3 個頁面,拆分到 2 個 WebView 中,如下圖,點擊完成按鈕,即關閉自身所在的 WebView。
同樣是打開新的 WebView,如果我們按如下圖的方法拆分會稍微復雜。這時點擊完成按鈕,首先關閉自身所在的 WebView,當頁面 2「意識」到自己重新被展現時,自動退回 1 步到頁面 1。
每次打開新的 WebView,這時點擊完成,回退的本質是 HTML5「告訴」Native 關閉多個 WebView。需要特別注意的是,HTML5 中實現這種方式不是天然具備的,它需要 Native 具有一次關閉多個 WebView 的能力。所以我們在設計方案時,需要了解清楚自家的 Native 是否有這樣的能力。
總結
以上,簡單說了幾種 HTML5 的跳轉方式。這些跳轉方式,沒有絕對的對與錯,我們在設計方案時,需要根據實際的業務需求與技術的限制,來整體考慮解決方案。
根據個人經驗,也有幾點小帖士分享給大家:
N 在 0.37 版本中加入了WebView功能,所以想要在使用WebView,版本必須>=0.37,發送的 message 只能是字符串,所以需要將其他格式的數據轉換成字符串,在接收到后再轉換回去,其實直接用JSON.stringify和JSON.parse就可以加載html source屬性用于指定加載的 html,可以加載在線的頁面,也可以加載本地的頁面,代碼如下:
注意 ??在 RN 中可以加載 WebView,但是無法調試,也不能使用 alert 來驗證代碼 js 代碼是否運行成功,只能通過往 html 寫入東西(innerHTML)來驗證 js 是否運行成功。
WebView 與 html 的通信
webview 發送信息到 html。WebView 給 html 發送信息需要使用postMessage,而 html 接收 RN 發過來的信息需要監聽message事件,代碼如下:
這里需要注意一點,postMessage需要在 webview 加載完成之后再去 post,如果放在commponentWillMount里由于頁面沒有加載完成就 post 信息,會導致 html 端無法監聽到 message 事件。html 發送信息到 webview。
RN 中 debug webview 和安卓開發中看起來是差不多的,連接好設備后,在 chrome 中輸入chrome://inspect。就可以看到安卓設備上正在運行的 webview 了,點擊inspect就會開啟一個調試頁面,就可以進行 debug 了,RN 似乎默認開啟了 debug 調試,直接就可以看到 webview 中輸出的信息。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。