本文中含有需要您注意的重要提示信息,忽略该信息可能对您的业务造成影响,请务必仔细阅读。
本文主要介绍在WebView场景下接入阿里云公共DNS SDK所遇到的一些问题及解决方案。
概述
在App WebView加载网络请求场景下,iOS系统可基于系统API进行网络请求拦截,并实现自定义逻辑注入。但iOS系统在WebView场景下拦截网络请求后,需要自行接管基于IP的网络请求的发送、数据接收、页面重定向、页面解码、Cookie、缓存等逻辑。综合来看,WebView场景下使用SDK解析的IP直连进行网络请求门槛比较高,移动操作系统针对这个场景的支持粒度很粗,且存在一些缺陷,需要开发者具备较强的网络/OS Framework的代码级掌控能力来规避和优化上述问题。
目前UIWebView已经被苹果弃用,而WKWebView没有成熟的IP直连方案,只能使用私有API进行注册拦截的方法,使用私有API有被苹果拒审的风险,请开发者谨慎使用。
UIWebView使用NSURLProtocol拦截请求
方案概述
基于
NSURLProtocol
可拦截iOS系统上基于上层网络库NSURLConnection/NSURLSession
发出的网络请求,UIWebView发出的请求同样包含在内;通过以下接口注册自定义
NSURLProtocol
,用于拦截UIWebView上层网络请求,并创建新的网络请求接管数据发送、接收、重定向等处理逻辑,将结果反馈给原始请求;[NSURLProtocol registerClass:[CustomProtocol class]];
自定义
NSURLProtocol
处理过程概述:在
canInitWithRequest
中拦截需要做域名解析的请求;请求拦截后,做公共DNS域名解析;
解析完成后,同普通请求一样,替换URL.host字段,替换HTTP Header Host域,并接管该请求的数据发送、接收、重定向等处理;
通过
NSURLProtocol
的接口,将请求处理结果反馈到UIWebView原始请求。
NSURLProtocol使用参考Apple NSURLProtocol API,苹果官方示例代码参考Apple Sample Code - CustomHTTPProtocol。
WKWebView使用私有API进行注册拦截请求
方案概述
WKWebView在独立于app进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在WKWebView上直接使用NSURLProtocol无法拦截请求。
目前在WKWebView场景下,没有成熟的IP直连方案,下面介绍下使用私有API进行注册拦截的方法。
// 注册自己的 protocol
[NSURLProtocol registerClass:[CustomProtocol class]];
// 创建 WKWebview
WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
WKWebView * wkWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height) configuration:config];
[wkWebView loadRequest:webViewReq];
[self.view addSubview:wkWebView];
//注册 scheme
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([cls respondsToSelector:sel]) {
// 通过 http 和 https 的请求,同理可通过其他的 Scheme 但是要满足 ULR Loading System
[cls performSelector:sel withObject:@"http"];
[cls performSelector:sel withObject:@"https"];
}
NSURLProtocol使用参考Apple NSURLProtocol API,苹果官方示例代码参考Apple Sample Code - CustomHTTPProtocol。
避免执行太晚,建议在+load
方法中执行 。如果在- (void)viewDidLoad
中注册,可能会因为注册太晚,引发问题。
WKWebView中的Cookie处理业务场景“IP直连”方案说明
以下讨论类似这样的问题:
WKWebView对于Cookie的管理一直是它的短板,那么iOS 11是否有改进,如果有,如何利用这样的改进 ?
采用IP直连方案后,服务端返回的Cookie里的Domain字段也会使用IP。如果IP是动态的,就有可能导致一些问题:由于许多H5业务都依赖于Cookie作登录态校验 ,而WKWebView上请求不会自动携带Cookie。
WKWebView使用NSURLProtocol拦截请求无法获取 Cookie 信息
iOS 11推出了新的API
WKHTTPCookieStore
可以用来拦截WKWebView的Cookie信息。用法示例如下:
WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore; // get cookies [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) { NSLog(@"All cookies %@",cookies); }]; // set cookie NSMutableDictionary *dict = [NSMutableDictionary dictionary]; dict[NSHTTPCookieName] = @"userid"; dict[NSHTTPCookieValue] = @"123"; dict[NSHTTPCookieDomain] = @"xxxx.com"; dict[NSHTTPCookiePath] = @"/"; NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:dict]; [cookieStroe setCookie:cookie completionHandler:^{ NSLog(@"set cookie"); }]; // delete cookie [cookieStroe deleteCookie:cookie completionHandler:^{ NSLog(@"delete cookie"); }];
利用iOS 11 API WKHTTPCookieStore解决WKWebView首次请求不携带Cookie的问题
问题说明:由于许多H5业务都依赖于Cookie作登录态校验,而WKWebView上请求不会自动携带Cookie。比如,如果您在Native层面做了登录操作,获取了Cookie信息,并使用NSHTTPCookieStorage存储到本地,但是使用WKWebView打开对应网页时,网页依然处于未登录状态。如果是登录操作也在 WebView 里进行,就不会出现这个问题。
iOS11的 API 可以解决该问题,只要是存在 WKHTTPCookieStore 里的 cookie,WKWebView 每次请求都会携带,存在 NSHTTPCookieStorage 的 cookie,并不会每次都携带。于是会发生首次 WKWebView 请求不携带 Cookie 的问题。
解决方法:
在执行
-[WKWebView loadRequest:]
前将NSHTTPCookieStorage
中的内容复制到WKHTTPCookieStore
中,以此来达到 WKWebView Cookie 注入的目的。示例代码如下:[self copyNSHTTPCookieStorageToWKHTTPCookieStoreWithCompletionHandler:^{ NSURL *url = [NSURL URLWithString:@"https://www.v2ex.com"]; NSURLRequest *request = [NSURLRequest requestWithURL:url]; [_webView loadRequest:request]; }];
- (void)copyNSHTTPCookieStorageToWKHTTPCookieStoreWithCompletionHandler:(nullable void (^)())theCompletionHandler; { NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]; WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore; if (cookies.count == 0) { !theCompletionHandler ?: theCompletionHandler(); return; } for (NSHTTPCookie *cookie in cookies) { [cookieStroe setCookie:cookie completionHandler:^{ if ([[cookies lastObject] isEqual:cookie]) { !theCompletionHandler ?: theCompletionHandler(); return; } }]; } }
说明这个是 iOS 11的API,针对iOS 11之前的系统 ,需要另外处理。
利用 iOS 11之前的API解决WKWebView首次请求不携带Cookie的问题
通过让所有WKWebView共享同一个WKProcessPool实例,可以实现多个WKWebView之间共享Cookie(session Cookie and persistent Cookie)数据。不过WKWebView WKProcessPool实例在app杀进程重启后会被重置,导致WKProcessPool中的Cookie、session Cookie数据丢失,目前也无法实现 WKProcessPool实例本地化保存。可以采取Cookie放入Header的方法来做。
WKWebView * webView = [WKWebView new]; NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://xxx.com/login"]]; [request addValue:@"skey=skeyValue" forHTTPHeaderField:@"Cookie"]; [webView loadRequest:request];
其中对于
skey=skeyValue
这个Cookie值的获取,也可以统一通过domain获取,获取的方法可以参照下面的工具类:ALIDNSCookieManager.h #ifndef ALIDNSCookieManager_h #define ALIDNSCookieManager_h // URL匹配Cookie规则 typedef BOOL (^ALIDNSCookieFilter)(NSHTTPCookie *, NSURL *); @interface ALIDNSCookieManager : NSObject + (instancetype)sharedInstance; /** 指定URL匹配Cookie策略 @param filter 匹配器 */ - (void)setCookieFilter:(ALIDNSCookieFilter)filter; /** 处理HTTP Response携带的Cookie并存储 @param headerFields HTTP Header Fields @param URL 根据匹配策略获取查找URL关联的Cookie @return 返回添加到存储的Cookie */ - (NSArray<NSHTTPCookie *> *)handleHeaderFields:(NSDictionary *)headerFields forURL:(NSURL *)URL; /** 匹配本地Cookie存储,获取对应URL的request cookie字符串 @param URL 根据匹配策略指定查找URL关联的Cookie @return 返回对应URL的request Cookie字符串 */ - (NSString *)getRequestCookieHeaderForURL:(NSURL *)URL; /** 删除存储cookie @param URL 根据匹配策略查找URL关联的cookie @return 返回成功删除cookie数 */ - (NSInteger)deleteCookieForURL:(NSURL *)URL; @end #endif /* ALIDNSCookieManager_h */ ALIDNSCookieManager.m #import <Foundation/Foundation.h> #import "ALIDNSCookieManager.h" @implementation ALIDNSCookieManager { ALIDNSCookieFilter cookieFilter; } - (instancetype)init { if (self = [super init]) { /** 此处设置的Cookie和URL匹配策略比较简单,检查URL.host是否包含Cookie的domain字段 通过调用setCookieFilter接口设定Cookie匹配策略, 比如可以设定Cookie的domain字段和URL.host的后缀匹配 | URL是否符合Cookie的path设定 细节匹配规则可参考RFC 2965 3.3节 */ cookieFilter = ^BOOL(NSHTTPCookie *cookie, NSURL *URL) { if ([URL.host containsString:cookie.domain]) { return YES; } return NO; }; } return self; } + (instancetype)sharedInstance { static id singletonInstance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ if (!singletonInstance) { singletonInstance = [[super allocWithZone:NULL] init]; } }); return singletonInstance; } + (id)allocWithZone:(struct _NSZone *)zone { return [self sharedInstance]; } - (id)copyWithZone:(struct _NSZone *)zone { return self; } - (void)setCookieFilter:(ALIDNSCookieFilter)filter { if (filter != nil) { cookieFilter = filter; } } - (NSArray<NSHTTPCookie *> *)handleHeaderFields:(NSDictionary *)headerFields forURL:(NSURL *)URL { NSArray *cookieArray = [NSHTTPCookie cookiesWithResponseHeaderFields:headerFields forURL:URL]; if (cookieArray != nil) { NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage]; for (NSHTTPCookie *cookie in cookieArray) { if (cookieFilter(cookie, URL)) { NSLog(@"Add a cookie: %@", cookie); [cookieStorage setCookie:cookie]; } } } return cookieArray; } - (NSString *)getRequestCookieHeaderForURL:(NSURL *)URL { NSArray *cookieArray = [self searchAppropriateCookies:URL]; if (cookieArray != nil && cookieArray.count > 0) { NSDictionary *cookieDic = [NSHTTPCookie requestHeaderFieldsWithCookies:cookieArray]; if ([cookieDic objectForKey:@"Cookie"]) { return cookieDic[@"Cookie"]; } } return nil; } - (NSArray *)searchAppropriateCookies:(NSURL *)URL { NSMutableArray *cookieArray = [NSMutableArray array]; NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage]; for (NSHTTPCookie *cookie in [cookieStorage cookies]) { if (cookieFilter(cookie, URL)) { NSLog(@"Search an appropriate cookie: %@", cookie); [cookieArray addObject:cookie]; } } return cookieArray; } - (NSInteger)deleteCookieForURL:(NSURL *)URL { int delCount = 0; NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage]; for (NSHTTPCookie *cookie in [cookieStorage cookies]) { if (cookieFilter(cookie, URL)) { NSLog(@"Delete a cookie: %@", cookie); [cookieStorage deleteCookie:cookie]; delCount++; } } return delCount; } @end
发送请求使用方法示例:
WKWebView * webView = [WKWebView new]; NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://xxx.com/login"]]; NSString *value = [[ALIDNSCookieManager sharedInstance] getRequestCookieHeaderForURL:url]; [request setValue:value forHTTPHeaderField:@"Cookie"]; [webView loadRequest:request];
接收处理请求:
NSURLSessionTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (!error) { NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; // 解析 HTTP Response Header,存储cookie [[ALIDNSCookieManager sharedInstance] handleHeaderFields:[httpResponse allHeaderFields] forURL:url]; } }]; [task resume];
通过
document.cookie
设置Cookie解决后续页面(同域)Ajax、iframe请求的Cookie问题;WKUserContentController* userContentController = [WKUserContentController new]; WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource: @"document.cookie = 'skey=skeyValue';" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO]; [userContentController addUserScript:cookieScript];
Cookie包含动态IP导致登录失效问题
关于Cookie失效的问题,假如客户端登录session存在Cookie,此时这个域名配置了多个IP,使用域名访问会读对应域名的Cookie,使用IP访问则去读对应IP的Cookie,假如前后两次使用同一个域名配置的不同IP访问,会导致Cookie的登录session失效。
如果App里面的WebView页面需要用到系统Cookie存的登录session,之前App所有本地网络请求使用域名访问,是可以共用Cookie的登录session的,但现在本地网络请求使用HTTPDNS后改用IP访问,导致还使用域名访问的WebView读不到系统Cookie存的登录session了(系统Cookie对应IP了)。IP直连后,服务端返回Cookie包含动态IP导致登录失效。
使用IP访问后,服务端返回的Cookie也是IP。导致可能使用对应的域名访问,无法使用本地Cookie,或者使用隶属于同一个域名的不同IP去访问,Cookie也对不上,导致登录失效 。
我们提供的思路是这样的:
应该对Cookie的存储进行干预,基于域名。
根源上,API域名返回单IP。
第二种思路将失去DNS调度特性,故不考虑。第一种思路更为可行。
基于iOS 11 API WKHTTPCookieStore来解决WKWebView的Cookie管理问题
当每次服务端返回Cookie后,在存储前都进行下改造,使用域名替换IP。之后虽然每次网络请求都是使用IP访问,但是host我们都手动改为了域名,这样本地存储的Cookie也就能对得上了。
在网络请求成功后,或者加载网页成功后,主动将本地的domain字段为IP的Cookie替换IP为host域名地址,代码演示:
- (void)updateWKHTTPCookieStoreDomainFromIP:(NSString *)IP toHost:(NSString *)host { WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore; [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) { [[cookies copy] enumerateObjectsUsingBlock:^(NSHTTPCookie * _Nonnull cookie, NSUInteger idx, BOOL * _Nonnull stop) { if ([cookie.domain isEqualToString:IP]) { NSMutableDictionary<NSHTTPCookiePropertyKey, id> *dict = [NSMutableDictionary dictionaryWithDictionary:cookie.properties]; dict[NSHTTPCookieDomain] = host; NSHTTPCookie *newCookie = [NSHTTPCookie cookieWithProperties:[dict copy]]; [cookieStroe setCookie:newCookie completionHandler:^{ [self logCookies]; [cookieStroe deleteCookie:cookie completionHandler:^{ [self logCookies]; }]; }]; } }]; }]; }
iOS 11中也提供了对应的API供我们来处理替换Cookie的时机,那就是下面的API:
@protocol WKHTTPCookieStoreObserver <NSObject> @optional - (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore; @end
//WKHTTPCookieStore /*! @abstract Adds a WKHTTPCookieStoreObserver object with the cookie store. @param observer The observer object to add. @discussion The observer is not retained by the receiver. It is your responsibility to unregister the observer before it becomes invalid. */ - (void)addObserver:(id<WKHTTPCookieStoreObserver>)observer; /*! @abstract Removes a WKHTTPCookieStoreObserver object from the cookie store. @param observer The observer to remove. */ - (void)removeObserver:(id<WKHTTPCookieStoreObserver>)observer;
用法如下:
@interface WebViewController ()<WKHTTPCookieStoreObserver> - (void)viewDidLoad { [super viewDidLoad]; [NSURLProtocol registerClass:[WebViewURLProtocol class]]; NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage]; [cookieStorage setCookieAcceptPolicy:NSHTTPCookieAcceptPolicyAlways]; WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore; [cookieStroe addObserver:self]; [self.view addSubview:self.webView]; //... ... } #pragma mark - #pragma mark - WKHTTPCookieStoreObserver Delegate Method - (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore { [self updateWKHTTPCookieStoreDomainFromIP:CYLIP toHost:CYLHOST]; }
-updateWKHTTPCookieStoreDomainFromIP
方法的实现,在上文已经给出。这个方案需要客户端维护一个IP->HOST的映射关系,需要能从IP反向查找到HOST,这个维护成本还是挺高的。下面介绍一下更通用的方法,也是iOS 11之前的处理方法:
iOS 11之前的处理方法:NSURLProtocal拦截后,手动管理Cookie的存储:
步骤:做IP替换时将原始URL保存到Header中:
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { NSMutableURLRequest *mutableReq = [request mutableCopy]; NSString *originalUrl = mutableReq.URL.absoluteString; NSURL *url = [NSURL URLWithString:originalUrl]; // 接口获取IP地址 NSArray *array = [[DNSResolver share] getIpsByCacheWithDomain:domain andExpiredIPEnabled:YES]; NSString *ip = array.firstObject; if (ip) { NSRange hostFirstRange = [originalUrl rangeOfString:url.host]; if (NSNotFound != hostFirstRange.location) { NSString *newUrl = [originalUrl stringByReplacingCharactersInRange:hostFirstRange withString:ip]; mutableReq.URL = [NSURL URLWithString:newUrl]; [mutableReq setValue:url.host forHTTPHeaderField:@"host"]; // 添加originalUrl保存原始URL [mutableReq addValue:originalUrl forHTTPHeaderField:@"originalUrl"]; } } NSURLRequest *postRequestIncludeBody = [mutableReq cyl_getPostRequestIncludeBody]; return postRequestIncludeBody; }
然后获取到数据后,手动管理Cookie :
- (void)handleCookiesFromResponse:(NSURLResponse *)response { NSString *originalURLString = [self.request valueForHTTPHeaderField:@"originalUrl"]; if ([response isKindOfClass:[NSHTTPURLResponse class]]) { NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; NSDictionary<NSString *, NSString *> *allHeaderFields = httpResponse.allHeaderFields; if (originalURLString && originalURLString.length > 0) { NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:allHeaderFields forURL: [[NSURL alloc] initWithString:originalURLString]]; if (cookies && cookies.count > 0) { NSURL *originalURL = [NSURL URLWithString:originalURLString]; [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookies:cookies forURL:originalURL mainDocumentURL:nil]; } } } } - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)newRequest completionHandler:(void (^)(NSURLRequest *))completionHandler { NSString *location = response.allHeaderFields[@"Location"]; NSURL *url = [[NSURL alloc] initWithString:location]; NSMutableURLRequest *mRequest = [newRequest mutableCopy]; mRequest.URL = url; if (location && location.length > 0) { if ([[newRequest.HTTPMethod lowercaseString] isEqualToString:@"post"]) { // POST重定向为GET mRequest.HTTPMethod = @"GET"; mRequest.HTTPBody = nil; } [mRequest setValue:nil forHTTPHeaderField:@"host"]; // 在这里为 request 添加 cookie 信息。 [self handleCookiesFromResponse:response]; [XXXURLProtocol removePropertyForKey:XXXURLProtocolHandledKey inRequest:mRequest]; completionHandler(mRequest); } else{ completionHandler(mRequest); } }
发送请求前,向请求中添加Cookie信息:
+ (void)handleCookieWithRequest:(NSMutableURLRequest *)request { NSString* originalURLString = [request valueForHTTPHeaderField:@"originalUrl"]; if (!originalURLString || originalURLString.length == 0) { return; } NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]; if (cookies && cookies.count >0) { NSDictionary *cookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; NSString *cookieString = [cookieHeaders objectForKey:@"Cookie"]; [request addValue:cookieString forHTTPHeaderField:@"Cookie"]; } } + (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { NSMutableURLRequest *mutableReq = [request mutableCopy]; //... [self handleCookieWithRequest:mutableReq]; return [mutableReq copy]; }
302重定向问题
上面提到的Cookie方案无法解决302请求的Cookie问题,比如,第一个请求是http://www.a.com ,我们通过在request header 里带上Cookie解决该请求的Cookie问题,接着页面302跳转到http://www.b.com ,这个时候 http://www.b.com 这个请求就可能因为没有携带Cookie而无法访问。当然,由于每一次页面跳转前都会调用回调函数:
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;
可以在该回调函数里拦截302请求,copy request,在request header中带上Cookie并重新loadRequest。不过这种方法依然解决不了页面iframe跨域请求的Cookie问题,毕竟-[WKWebView loadRequest:]
只适合加载mainFrame请求。