歡迎訪問個人博客原文git
NSURLProtocol 是 Foundation 框架中 URL Loading System 的一部分。它可讓開發者能夠在不修改應用內原始請求代碼的狀況下,去改變 URL 加載的所有細節。換句話說,NSURLProtocol 是一個被 Apple 默許的中間人攻擊。github
雖然 NSURLProtocol 叫「Protocol」,卻不是協議,而是一個抽象類。web
既然 NSURLProtocol 是一個抽象類,說明它沒法被實例化,那麼它又是如何實現網絡請求攔截的?算法
答案就是經過子類化來定義新的或是已經存在的 URL 加載行爲。若是當前的網絡請求是能夠被攔截的,那麼開發者只須要將一個自定義的 NSURLProtocol 子類註冊到 App 中,在這個子類中就能夠攔截到全部請求並進行修改。數組
那麼到底哪些網絡請求能夠被攔截?緩存
前面已經說了,NSURLProtocol 是 URL Loading System 的一部分,因此它能夠攔截全部基於 URL Loading System 的網絡請求:服務器
相應的,基於它們實現的第三方網絡框架 AFNetworking 和 Alamofire 的網絡請求,也能夠被 NSURLProtocol 攔截到。網絡
但早些年基於 CFNetwork 實現的,好比 ASIHTTPRequest,其網絡請求就沒法被攔截。session
另外,UIWebView 也是能夠被 NSURLProtocol 攔截的,但 WKWebView 不能夠。(由於 WKWebView 是基於 WebKit,並不走 C socket。)app
所以,在實際應用中,它的功能十分強大,好比:
下面來看一下 NSURLProtocol 的相關方法。
// 建立一個 URL 協議實例來處理 request 請求
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;
// 建立一個 URL 協議實例來處理 session task 請求
- (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;
複製代碼
// 嘗試註冊 NSURLProtocol 的子類,使之在 URL 加載系統中可見
+ (BOOL)registerClass:(Class)protocolClass;
// 註銷 NSURLProtocol 的指定子類
+ (void)unregisterClass:(Class)protocolClass;
複製代碼
子類化 NSProtocol 的首要任務就是告知它,須要控制什麼類型的網絡請求。
// 肯定協議子類是否能夠處理指定的 request 請求,若是返回 YES,請求會被其控制,返回 NO 則直接跳入下一個 protocol
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 肯定協議子類是否能夠處理指定的 task 請求
+ (BOOL)canInitWithTask:(NSURLSessionTask *)task;
複製代碼
NSURLProtocol 容許開發者去獲取、添加、刪除 request 對象的任意元數據。這幾個方法經常使用來處理請求無限循環的問題。
// 在指定的請求中獲取與指定鍵關聯的屬性
+ (id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request;
// 設置與指定請求中的指定鍵關聯的屬性
+ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
// 刪除與指定請求中的指定鍵關聯的屬性
+ (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
複製代碼
若是你想要用特定的某個方式來修改請求,能夠用下面這個方法。
// 返回指定請求的規範版本
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
複製代碼
// 判斷兩個請求是否相同,若是相同可使用緩存數據,一般只須要調用父類的實現
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;
複製代碼
這是子類中最重要的兩個方法,不一樣的自定義子類在調用這兩個方法時會傳入不一樣的內容,但共同點都是圍繞 protocol 客戶端進行操做。
// 開始加載
- (void)startLoading;
// 中止加載
- (void)stopLoading;
複製代碼
// 獲取協議接收者的緩存
- (NSCachedURLResponse *)cachedResponse;
// 接受者用來與 URL 加載系統通訊的對象,每一個 NSProtocol 的子類實例都擁有它
- (id<NSURLProtocolClient>)client;
// 接收方的請求
- (NSURLRequest *)request;
// 接收方的任務
- (NSURLSessionTask *)task;
複製代碼
NSURLProtocol 在實際應用中,主要是完成兩步:攔截 URL 和 URL 轉發。先來看如何攔截網絡請求。
這裏建立一個名爲 HTCustomURLProtocol
的子類。
@interface HTCustomURLProtocol : NSURLProtocol
@end
複製代碼
在合適的位置註冊這個子類。對基於 NSURLConnection 或者使用 [NSURLSession sharedSession]
初始化對象建立的網絡請求,調用 registerClass
方法便可。
[NSURLProtocol registerClass:[NSClassFromString(@"HTCustomURLProtocol") class]];
// 或者
// [NSURLProtocol registerClass:[HTCustomURLProtocol class]];
複製代碼
若是須要全局監聽,能夠設置在 AppDelegate.m
的 didFinishLaunchingWithOptions
方法中。若是隻須要在單個 UIViewController 中使用,記得在合適的時機註銷監聽:
[NSURLProtocol unregisterClass:[NSClassFromString(@"HTCustomURLProtocol") class]];
複製代碼
若是是基於 NSURLSession 的網絡請求,且不是經過 [NSURLSession sharedSession]
方式建立的,就得配置 NSURLSessionConfiguration 對象的 protocolClasses
屬性。
NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
sessionConfiguration.protocolClasses = @[[NSClassFromString(@"HTCustomURLProtocol") class]];
複製代碼
實現子類分爲五個步驟:
註冊 → 攔截 → 轉發 → 回調 → 結束
以攔截 UIWebView 爲例,這裏須要重寫父類的這五個核心方法。
// 定義一個協議 key
static NSString * const HTCustomURLProtocolHandledKey = @"HTCustomURLProtocolHandledKey";
// 在拓展中定義一個 NSURLConnection 屬性。經過 NSURLSession 也能夠攔截,這裏只是以 NSURLConnection 爲例。
@property (nonatomic, strong) NSURLConnection *connection;
// 定義一個可變的請求返回值,
@property (nonatomic, strong) NSMutableData *responseData;
// 方法 1:在攔截到網絡請求後會調用這一方法,能夠再次處理攔截的邏輯,好比設置只針對 http 和 https 的請求進行處理。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
// 只處理 http 和 https 請求
NSString *scheme = [[request URL] scheme];
if ( ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame ||
[scheme caseInsensitiveCompare:@"https"] == NSOrderedSame)) {
// 看看是否已經處理過了,防止無限循環
if ([NSURLProtocol propertyForKey:HTCustomURLProtocolHandledKey inRequest:request]) {
return NO;
}
// 若是還須要截取 DNS 解析請求中的連接,能夠繼續加判斷,是否爲攔截域名請求的連接,若是是返回 NO
return YES;
}
return NO;
}
// 方法 2:【關鍵方法】能夠在此對 request 進行處理,好比修改地址、提取請求信息、設置請求頭等。
+ (NSURLRequest *) canonicalRequestForRequest:(NSURLRequest *)request {
// 能夠打印出全部的請求連接包括 CSS 和 Ajax 請求等
NSLog(@"request.URL.absoluteString = %@",request.URL.absoluteString);
NSMutableURLRequest *mutableRequest = [request mutableCopy];
return mutableRequest;
}
// 方法 3:【關鍵方法】在這裏設置網絡代理,從新建立一個對象將處理過的 request 轉發出去。這裏對應的回調方法對應 <NSURLProtocolClient> 協議方法
- (void)startLoading {
// 能夠修改 request 請求
NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
// 打 tag,防止遞歸調用
[NSURLProtocol setProperty:@YES forKey:HTCustomURLProtocolHandledKey inRequest:mutableRequest];
// 也能夠在這裏檢查緩存
// 將 request 轉發,對於 NSURLConnection 來講,就是建立一個 NSURLConnection 對象;對於 NSURLSession 來講,就是發起一個 NSURLSessionTask。
self.connection = [NSURLConnection connectionWithRequest:mutableRequest delegate:self];
}
// 方法 4:主要判斷兩個 request 是否相同,若是相同的話可使用緩存數據,一般只須要調用父類的實現。
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b {
return [super requestIsCacheEquivalent:a toRequest:b];
}
// 方法 5:處理結束後中止相應請求,清空 connection 或 session
- (void)stopLoading {
if (self.connection != nil) {
[self.connection cancel];
self.connection = nil;
}
}
// 按照在上面的方法中作的自定義需求,看狀況對轉發出來的請求在恰當的時機進行回調處理。
#pragma mark- NSURLConnectionDelegate
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
[self.client URLProtocol:self didFailWithError:error];
}
#pragma mark - NSURLConnectionDataDelegate
// 當接收到服務器的響應(連通了服務器)時會調用
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
self.responseData = [[NSMutableData alloc] init];
// 能夠處理不一樣的 statusCode 場景
// NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode];
// 能夠設置 Cookie
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
}
// 接收到服務器的數據時會調用,可能會被調用屢次,每次只傳遞部分數據
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
[self.responseData appendData:data];
[self.client URLProtocol:self didLoadData:data];
}
// 服務器的數據加載完畢後調用
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
[self.client URLProtocolDidFinishLoading:self];
}
// 請求錯誤(失敗)的時候調用,好比出現請求超時、斷網,通常指客戶端錯誤
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
[self.client URLProtocol:self didFailWithError:error];
}
複製代碼
上面用到的一些 NSURLProtocolClient 方法:
@protocol NSURLProtocolClient <NSObject>
// 請求重定向
- (void)URLProtocol:(NSURLProtocol *)protocol wasRedirectedToRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)redirectResponse;
// 響應緩存是否合法
- (void)URLProtocol:(NSURLProtocol *)protocol cachedResponseIsValid:(NSCachedURLResponse *)cachedResponse;
// 剛接收到 response 信息
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;
// 數據加載成功
- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;
// 數據完成加載
- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
// 數據加載失敗
- (void)URLProtocol:(NSURLProtocol *)protocol didFailWithError:(NSError *)error;
// 爲指定的請求啓動驗證
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
// 爲指定的請求取消驗證
- (void)URLProtocol:(NSURLProtocol *)protocol didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
@end
複製代碼
若是在 NSURLProtocol 中使用 NSURLSession,須要注意:
registerClass
註冊,只能經過 [NSURLSession sharedSession]
的方式建立網絡請求。當有多個自定義 NSURLProtocol 子類註冊到系統中的話,會按照他們註冊的反向順序依次調用 URL 加載流程,也就是最後註冊的 NSURLProtocol 會被優先判斷。
對於經過配置 NSURLSessionConfiguration 對象的 protocolClasses
屬性來註冊的狀況,protocolClasses
數組中只有第一個 NSURLProtocol 會起做用,後續的 NSURLProtocol 就沒法攔截到了。
因此 OHHTTPStubs 在註冊 NSURLProtocol 子類的時候是這樣處理的:
+ (void)setEnabled:(BOOL)enable forSessionConfiguration:(NSURLSessionConfiguration*)sessionConfig
{
// Runtime check to make sure the API is available on this version
if ([sessionConfig respondsToSelector:@selector(protocolClasses)]
&& [sessionConfig respondsToSelector:@selector(setProtocolClasses:)])
{
NSMutableArray * urlProtocolClasses = [NSMutableArray arrayWithArray:sessionConfig.protocolClasses];
Class protoCls = HTTPStubsProtocol.class;
if (enable && ![urlProtocolClasses containsObject:protoCls])
{
// 將本身的 NSURLProtocol 插入到 protocolClasses 的第一個,進行攔截
[urlProtocolClasses insertObject:protoCls atIndex:0];
}
else if (!enable && [urlProtocolClasses containsObject:protoCls])
{
// 攔截完成後移除
[urlProtocolClasses removeObject:protoCls];
}
sessionConfig.protocolClasses = urlProtocolClasses;
}
else
{
NSLog(@"[OHHTTPStubs] %@ is only available when running on iOS7+/OSX9+. "
@"Use conditions like 'if ([NSURLSessionConfiguration class])' to only call "
@"this method if the user is running iOS7+/OSX9+.", NSStringFromSelector(_cmd));
}
}
複製代碼
雖然 NSURLProtocol 沒法直接攔截 WKWebView,但其實仍是有解決方案的。就是使用 WKBrowsingContextController
和 registerSchemeForCustomProtocol
。
// 註冊 scheme
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([cls respondsToSelector:sel]) {
// 經過 http 和 https 的請求,同理可經過其餘的 Scheme 可是要知足 URL Loading System
[cls performSelector:sel withObject:@"http"];
[cls performSelector:sel withObject:@"https"];
}
複製代碼
但因爲這涉及到了私有方法,直接引用沒法過蘋果的機審,因此使用的時候須要對字符串作下處理,好比對方法名進行算法加密處理等,實測也是能夠經過審覈的。
總之,NSURLProtocol 很是強大,不管是優化 App 的性能,仍是拓展功能,都具備很強的可塑空間,但在使用的同時,又要多關注它帶來的問題。儘管它在不少框架或者知名項目中都已經得以應用,其奧義依然值得開發者們去深刻研究。