歡迎訪問個人博客原文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 攔截到。markdown
但早些年基於 CFNetwork 實現的,好比 ASIHTTPRequest,其網絡請求就沒法被攔截。網絡
另外,UIWebView 也是能夠被 NSURLProtocol 攔截的,但 WKWebView 不能夠。(由於 WKWebView 是基於 WebKit,並不走 C socket。)session
所以,在實際應用中,它的功能十分強大,好比:
下面來看一下 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 的性能,仍是拓展功能,都具備很強的可塑空間,但在使用的同時,又要多關注它帶來的問題。儘管它在不少框架或者知名項目中都已經得以應用,其奧義依然值得開發者們去深刻研究。