最近用AFNetworking替換掉了工程裡的ASIHttpRequest,結果陸續碰到很多問題:
·如何統一地添加全局的HTTP頭(不僅僅是UA而已)
·如何優雅地進行流量統計
·對特定的地址進行CDN加速(URL到IP的替換)
·怎麼實現HTTP的同步請求
前三個需求對於ASIHttpReqeust來說都不是問題,只需要在幾個統一的點進行修改即可。而使用AFNetworking後就沒有那麼容易了:一方面AFNetworking中生成NSURLRequest的點比較多,並沒有一個統一的路徑。其次工程中會有部分直接使用NSURLConnecion的場景,無法統一。經cyzju提醒發現了NSURLProtocol這個大殺器,可惜對應的文檔過於簡略,唯一比較詳細的介紹就只有RW的這篇教程而已,掉了很多坑,值得記上一筆。
NSURLProtocol
概念
NSURLProtocol也是蘋果眾多黑魔法中的一種,使用它可以輕松地重定義整個URL Loading System。當你注冊自定義NSURLProtocol後,就有機會對所有的請求進行統一的處理,基於這一點它可以讓你
·自定義請求和響應
·提供自定義的全局緩存支持
·重定向網絡請求
·提供HTTP Mocking (方便前期測試)
·其他一些全局的網絡請求修改需求
使用方法
繼承NSURLPorotocl,並注冊你的NSURLProtocol
[NSURLProtocol registerClass:[YXURLProtocol class]];
當NSURLConnection准備發起請求時,它會遍歷所有已注冊的NSURLProtocol,詢問它們能否處理當前請求。所以你需要盡早注冊這個Protocol。
實現NSURLProtocol的相關方法
當遍歷到我們自定義的NSURLProtocol時,系統先會調用canInitWithRequest:這個方法。顧名思義,這是整個流程的入口,只有這個方法返回YES我們才能夠繼續後續的處理。我們可以在這個方法的實現裡面進行請求的過濾,篩選出需要進行處理的請求。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request { if ([NSURLProtocol propertyForKey:YXURLProtocolHandled inRequest:request]) { return NO; } NSString *scheme = [[request URL] scheme]; NSDictionary *dict = [request allHTTPHeaderFields]; return [dict objectForKey:@"custom_header"] == nil && ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame || [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame); }
當篩選出需要處理的請求後,就可以進行後續的處理,需要至少實現如下4個方法
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { return request; } + (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b { return [super requestIsCacheEquivalent:a toRequest:b]; } - (void)startLoading { NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy]; [YXURLProtocol applyCustomHeaders:mutableReqeust]; [NSURLProtocol setProperty:@(YES) forKey:YXURLProtocolHandled inRequest:mutableReqeust]; self.connection = [NSURLConnection connectionWithRequest:mutableReqeust delegate:self]; } - (void)stopLoading { [self.connection cancel]; self.connection = nil; }
·canonicalRequestForRequest: 返回規范化後的request,一般就只是返回當前request即可。
·requestIsCacheEquivalent:toRequest: 用於判斷你的自定義reqeust是否相同,這裡返回默認實現即可。它的主要應用場景是某些直接使用緩存而非再次請求網絡的地方。
·startLoading和stopLoading 實現請求和取消流程。
實現NSURLConnectionDelegate和NSURLConnectionDataDelegate
因為在第二步中我們接管了整個請求過程,所以需要實現相應的協議並使用NSURLProtocolClient將消息回傳給URL Loading System。在我們的場景中推薦實現所有協議。
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { [self.client URLProtocol:self didFailWithError:error]; } - (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response { if (response != nil) { [[self client] URLProtocol:self wasRedirectedToRequest:request redirectResponse:response]; } return request; } - (BOOL)connectionShouldUseCredentialStorage:(NSURLConnection *)connection { return YES; } - (void)connection:(NSURLConnection *)connection didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge { [self.client URLProtocol:self didReceiveAuthenticationChallenge:challenge]; } - (void)connection:(NSURLConnection *)connection didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge { [self.client URLProtocol:self didCancelAuthenticationChallenge:challenge]; } - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:[[self request] cachePolicy]]; } - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { [self.client URLProtocol:self didLoadData:data]; } - (NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse { return cachedResponse; } - (void)connectionDidFinishLoading:(NSURLConnection *)connection { [self.client URLProtocolDidFinishLoading:self]; }
在每個delgate的實現中我都刨去了工程中的特定實現(流量統計),只保留了需要實現的最小Protocol集合。
NSURLProtocol那些坑
從上面的介紹來看,NSURLProtocol還是比較簡單,但是實際使用的過程中卻容易掉進各種坑,一方面是文檔不夠詳盡,另一方面也是對於蘋果這套URL Loading Sytem並不熟悉,不能將整個調用過程有機地統一。
·坑1:企圖在canonicalRequestForRequest:進行request的自定義操作,導致各種遞歸調用導致連接超時。這個API的表述其實很暧昧:
It is up to each concrete protocol implementation to define what “canonical” means. A protocol should guarantee that the same input request always yields the same canonical form.
所謂的canonical form到底是什麼呢?而圍觀了包括NSEtcHosts和RNCachingURLProtocol在內的實現,它們都是直接返回當前request。在這個方法內進行request的修改非常容易導致遞歸調用(即使通過setProperty:forKey:inRequest:對請求打了標記)
·坑2:沒有實現足夠的回調方法導致各種奇葩問題。如connection:willSendRequest:redirectResponse: 內如果沒有通過[self client]回傳消息,那麼需要重定向的網頁就會出現問題:host不對或者造成跨域調用導致資源無法加載。
同步AFNetworking請求
雖然Mattt各種鄙視同步做網絡請求,但是我們不可否認某些場景下使用同步調用會帶來不少便利。一種比較簡單的實現是使用信號量做同步:
@implementation AFHTTPRequestOperation (YX) - (void)yxStartSynchronous { dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); [self setCompletionBlockWithSuccess:^(AFHTTPRequestOperation *operation, id responseObject) { dispatch_semaphore_signal(semaphore); } failure:^(AFHTTPRequestOperation *operation, NSError *error) { dispatch_semaphore_signal(semaphore); }]; [self start]; dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); } @end
但是這樣帶來的問題是在UI線程調用同步請求就會導致線程堵死崩潰(好吧,就不應該允許UI線程上這麼做)。一種改進的方法是使用NSRunLoop
即:
while (_shouldBlock) { [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; }
但是這種寫法是有大坑的:如果當前NSRunLoop並沒有任何NSTimer或Input Source,runMode:beforeDate:方法將立刻返回NO,於是造成死循環,占用大量CPU,進而導致NSURLConnection請求超時。 規避的方法是往RunLoop中添加NSTimer或者空NSPort使得NSRunLoop掛起而不占用CPU。(ASIHttpRequest就是在當前RunLoop中添加了0.25秒觸發一次的刷新Timer)
If no input sources or timers are attached to the run loop, this method exits immediately and returns NO; otherwise, it returns after either the first input source is processed or limitDate is reached. Manually removing all known input sources and timers from the run loop does not guarantee that the run loop will exit immediately. OS X may install and remove additional input sources as needed to process requests targeted at the receiver’s thread. Those sources could therefore prevent the run loop from exiting.
(via:阿毛的蛋疼地)