深度理解 NSURLProtocol

歡迎訪問我的博客原文

NSURLProtocol 是什么

NSURLProtocol 是 Foundation 框架中 URL Loading System 的一部分。它可以讓開發(fā)者可以在不修改應(yīng)用內(nèi)原始請求代碼的情況下,去改變 URL 加載的全部細(xì)節(jié)。換句話說,NSURLProtocol 是一個(gè)被 Apple 默許的中間人攻擊。

雖然 NSURLProtocol 叫“Protocol”,卻不是協(xié)議,而是一個(gè)抽象類。

既然 NSURLProtocol 是一個(gè)抽象類,說明它無法被實(shí)例化,那么它又是如何實(shí)現(xiàn)網(wǎng)絡(luò)請求攔截的?

答案就是通過子類化來定義新的或是已經(jīng)存在的 URL 加載行為。如果當(dāng)前的網(wǎng)絡(luò)請求是可以被攔截的,那么開發(fā)者只需要將一個(gè)自定義的 NSURLProtocol 子類注冊到 App 中,在這個(gè)子類中就可以攔截到所有請求并進(jìn)行修改。

那么到底哪些網(wǎng)絡(luò)請求可以被攔截?

NSURLProtocol 使用場景

前面已經(jīng)說了,NSURLProtocol 是 URL Loading System 的一部分,所以它可以攔截所有基于 URL Loading System 的網(wǎng)絡(luò)請求:

  • NSURLSession
  • NSURLConnection
  • NSURLDownload
  • NSURLResponse
  • NSHTTPURLResponse
  • NSURLRequest
  • NSMutableURLRequest

相應(yīng)的,基于它們實(shí)現(xiàn)的第三方網(wǎng)絡(luò)框架 AFNetworkingAlamofire 的網(wǎng)絡(luò)請求,也可以被 NSURLProtocol 攔截到。

但早些年基于 CFNetwork 實(shí)現(xiàn)的,比如 ASIHTTPRequest,其網(wǎng)絡(luò)請求就無法被攔截。

另外,UIWebView 也是可以被 NSURLProtocol 攔截的,但 WKWebView 不可以。(因?yàn)?WKWebView 是基于 WebKit,并不走 C socket。)

因此,在實(shí)際應(yīng)用中,它的功能十分強(qiáng)大,比如:

  • 重定向網(wǎng)絡(luò)請求,解決 DNS 域名劫持的問題
  • 進(jìn)行全局或局部的網(wǎng)絡(luò)請求設(shè)置,比如修改請求地址、header 等
  • 忽略網(wǎng)絡(luò)請求,使用 H5 離線包或是緩存數(shù)據(jù)等
  • 自定義網(wǎng)絡(luò)請求的返回結(jié)果,比如過濾敏感信息

下面來看一下 NSURLProtocol 的相關(guān)方法。

NSURLProtocol 的相關(guān)方法

創(chuàng)建協(xié)議對象

// 創(chuàng)建一個(gè) URL 協(xié)議實(shí)例來處理 request 請求
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;
// 創(chuàng)建一個(gè) URL 協(xié)議實(shí)例來處理 session task 請求
- (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;

注冊和注銷協(xié)議類

// 嘗試注冊 NSURLProtocol 的子類,使之在 URL 加載系統(tǒng)中可見
+ (BOOL)registerClass:(Class)protocolClass;
// 注銷 NSURLProtocol 的指定子類
+ (void)unregisterClass:(Class)protocolClass;

確定子類是否可以處理請求

子類化 NSProtocol 的首要任務(wù)就是告知它,需要控制什么類型的網(wǎng)絡(luò)請求。

// 確定協(xié)議子類是否可以處理指定的 request 請求,如果返回 YES,請求會被其控制,返回 NO 則直接跳入下一個(gè) protocol
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 確定協(xié)議子類是否可以處理指定的 task 請求
+ (BOOL)canInitWithTask:(NSURLSessionTask *)task;

獲取和設(shè)置請求屬性

NSURLProtocol 允許開發(fā)者去獲取、添加、刪除 request 對象的任意元數(shù)據(jù)。這幾個(gè)方法常用來處理請求無限循環(huán)的問題。

// 在指定的請求中獲取與指定鍵關(guān)聯(lián)的屬性
+ (id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request;
// 設(shè)置與指定請求中的指定鍵關(guān)聯(lián)的屬性
+ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
// 刪除與指定請求中的指定鍵關(guān)聯(lián)的屬性
+ (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;

提供請求的規(guī)范版本

如果你想要用特定的某個(gè)方式來修改請求,可以用下面這個(gè)方法。

// 返回指定請求的規(guī)范版本
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;

確定請求是否相同

// 判斷兩個(gè)請求是否相同,如果相同可以使用緩存數(shù)據(jù),通常只需要調(diào)用父類的實(shí)現(xiàn)
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;

啟動和停止加載

這是子類中最重要的兩個(gè)方法,不同的自定義子類在調(diào)用這兩個(gè)方法時(shí)會傳入不同的內(nèi)容,但共同點(diǎn)都是圍繞 protocol 客戶端進(jìn)行操作。

// 開始加載
- (void)startLoading;
// 停止加載
- (void)stopLoading;

獲取協(xié)議屬性

// 獲取協(xié)議接收者的緩存
- (NSCachedURLResponse *)cachedResponse;
// 接受者用來與 URL 加載系統(tǒng)通信的對象,每個(gè) NSProtocol 的子類實(shí)例都擁有它
- (id<NSURLProtocolClient>)client;
// 接收方的請求
- (NSURLRequest *)request;
// 接收方的任務(wù)
- (NSURLSessionTask *)task;

NSURLProtocol 在實(shí)際應(yīng)用中,主要是完成兩步:攔截 URL 和 URL 轉(zhuǎn)發(fā)。先來看如何攔截網(wǎng)絡(luò)請求。

如何利用 NSProtocol 攔截網(wǎng)絡(luò)請求

創(chuàng)建 NSURLProtocol 子類

這里創(chuàng)建一個(gè)名為 HTCustomURLProtocol 的子類。

@interface HTCustomURLProtocol : NSURLProtocol
@end

注冊 NSURLProtocol 的子類

在合適的位置注冊這個(gè)子類。對基于 NSURLConnection 或者使用 [NSURLSession sharedSession] 初始化對象創(chuàng)建的網(wǎng)絡(luò)請求,調(diào)用 registerClass 方法即可。

[NSURLProtocol registerClass:[NSClassFromString(@"HTCustomURLProtocol") class]];
// 或者
// [NSURLProtocol registerClass:[HTCustomURLProtocol class]]; 

如果需要全局監(jiān)聽,可以設(shè)置在 AppDelegate.mdidFinishLaunchingWithOptions 方法中。如果只需要在單個(gè) UIViewController 中使用,記得在合適的時(shí)機(jī)注銷監(jiān)聽:

[NSURLProtocol unregisterClass:[NSClassFromString(@"HTCustomURLProtocol") class]];

如果是基于 NSURLSession 的網(wǎng)絡(luò)請求,且不是通過 [NSURLSession sharedSession] 方式創(chuàng)建的,就得配置 NSURLSessionConfiguration 對象的 protocolClasses 屬性。

NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
sessionConfiguration.protocolClasses = @[[NSClassFromString(@"HTCustomURLProtocol") class]];

實(shí)現(xiàn) NSURLProtocol 子類

實(shí)現(xiàn)子類分為五個(gè)步驟:

注冊 → 攔截 → 轉(zhuǎn)發(fā) → 回調(diào) → 結(jié)束

以攔截 UIWebView 為例,這里需要重寫父類的這五個(gè)核心方法。

// 定義一個(gè)協(xié)議 key
static NSString * const HTCustomURLProtocolHandledKey = @"HTCustomURLProtocolHandledKey";

// 在拓展中定義一個(gè) NSURLConnection 屬性。通過 NSURLSession 也可以攔截,這里只是以 NSURLConnection 為例。
@property (nonatomic, strong) NSURLConnection *connection;
// 定義一個(gè)可變的請求返回值,
@property (nonatomic, strong) NSMutableData *responseData;

// 方法 1:在攔截到網(wǎng)絡(luò)請求后會調(diào)用這一方法,可以再次處理攔截的邏輯,比如設(shè)置只針對 http 和 https 的請求進(jìn)行處理。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    // 只處理 http 和 https 請求
    NSString *scheme = [[request URL] scheme];
    if ( ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame ||
          [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame)) {
        // 看看是否已經(jīng)處理過了,防止無限循環(huán)
        if ([NSURLProtocol propertyForKey:HTCustomURLProtocolHandledKey inRequest:request]) {
            return NO;
        }
        // 如果還需要截取 DNS 解析請求中的鏈接,可以繼續(xù)加判斷,是否為攔截域名請求的鏈接,如果是返回 NO
        return YES;
    }
    return NO;
}

// 方法 2:【關(guān)鍵方法】可以在此對 request 進(jìn)行處理,比如修改地址、提取請求信息、設(shè)置請求頭等。
+ (NSURLRequest *) canonicalRequestForRequest:(NSURLRequest *)request {
    // 可以打印出所有的請求鏈接包括 CSS 和 Ajax 請求等
    NSLog(@"request.URL.absoluteString = %@",request.URL.absoluteString);
    NSMutableURLRequest *mutableRequest = [request mutableCopy];
    return mutableRequest;
}

// 方法 3:【關(guān)鍵方法】在這里設(shè)置網(wǎng)絡(luò)代理,重新創(chuàng)建一個(gè)對象將處理過的 request 轉(zhuǎn)發(fā)出去。這里對應(yīng)的回調(diào)方法對應(yīng) <NSURLProtocolClient> 協(xié)議方法
- (void)startLoading {
    // 可以修改 request 請求
    NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
    // 打 tag,防止遞歸調(diào)用
    [NSURLProtocol setProperty:@YES forKey:HTCustomURLProtocolHandledKey inRequest:mutableRequest];
    // 也可以在這里檢查緩存
    // 將 request 轉(zhuǎn)發(fā),對于 NSURLConnection 來說,就是創(chuàng)建一個(gè) NSURLConnection 對象;對于 NSURLSession 來說,就是發(fā)起一個(gè) NSURLSessionTask。
    self.connection = [NSURLConnection connectionWithRequest:mutableRequest delegate:self];
}

// 方法 4:主要判斷兩個(gè) request 是否相同,如果相同的話可以使用緩存數(shù)據(jù),通常只需要調(diào)用父類的實(shí)現(xiàn)。
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b {
    return [super requestIsCacheEquivalent:a toRequest:b];
}

// 方法 5:處理結(jié)束后停止相應(yīng)請求,清空 connection 或 session
- (void)stopLoading {
    if (self.connection != nil) {
        [self.connection cancel];
        self.connection = nil;
    }
}

// 按照在上面的方法中做的自定義需求,看情況對轉(zhuǎn)發(fā)出來的請求在恰當(dāng)?shù)臅r(shí)機(jī)進(jìn)行回調(diào)處理。
#pragma mark- NSURLConnectionDelegate

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    [self.client URLProtocol:self didFailWithError:error];
}

#pragma mark - NSURLConnectionDataDelegate

// 當(dāng)接收到服務(wù)器的響應(yīng)(連通了服務(wù)器)時(shí)會調(diào)用
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    self.responseData = [[NSMutableData alloc] init];
    // 可以處理不同的 statusCode 場景
    // NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode];
    // 可以設(shè)置 Cookie
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
}

// 接收到服務(wù)器的數(shù)據(jù)時(shí)會調(diào)用,可能會被調(diào)用多次,每次只傳遞部分?jǐn)?shù)據(jù)
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    [self.responseData appendData:data];
    [self.client URLProtocol:self didLoadData:data];
}

// 服務(wù)器的數(shù)據(jù)加載完畢后調(diào)用
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    [self.client URLProtocolDidFinishLoading:self];
}

// 請求錯(cuò)誤(失?。┑臅r(shí)候調(diào)用,比如出現(xiàn)請求超時(shí)、斷網(wǎng),一般指客戶端錯(cuò)誤
- (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;
// 響應(yīng)緩存是否合法
- (void)URLProtocol:(NSURLProtocol *)protocol cachedResponseIsValid:(NSCachedURLResponse *)cachedResponse;
// 剛接收到 response 信息
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;
// 數(shù)據(jù)加載成功
- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;
// 數(shù)據(jù)完成加載
- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
// 數(shù)據(jù)加載失敗
- (void)URLProtocol:(NSURLProtocol *)protocol didFailWithError:(NSError *)error;
// 為指定的請求啟動驗(yàn)證
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
// 為指定的請求取消驗(yàn)證
- (void)URLProtocol:(NSURLProtocol *)protocol didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
@end

補(bǔ)充內(nèi)容

使用 NSURLSession 時(shí)的注意事項(xiàng)

如果在 NSURLProtocol 中使用 NSURLSession,需要注意:

  • 攔截到的 request 請求的 HTTPBody 為 nil,但可以借助 HTTPBodyStream 來獲取 body;
  • 如果要用 registerClass 注冊,只能通過 [NSURLSession sharedSession] 的方式創(chuàng)建網(wǎng)絡(luò)請求。

注冊多個(gè) NSURLProtocol 子類

當(dāng)有多個(gè)自定義 NSURLProtocol 子類注冊到系統(tǒng)中的話,會按照他們注冊的反向順序依次調(diào)用 URL 加載流程,也就是最后注冊的 NSURLProtocol 會被優(yōu)先判斷。

對于通過配置 NSURLSessionConfiguration 對象的 protocolClasses 屬性來注冊的情況,protocolClasses 數(shù)組中只有第一個(gè) NSURLProtocol 會起作用,后續(xù)的 NSURLProtocol 就無法攔截到了。

所以 OHHTTPStubs 在注冊 NSURLProtocol 子類的時(shí)候是這樣處理的:

+ (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 的第一個(gè),進(jìn)行攔截
            [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));
    }
}

如何攔截 WKWebView

雖然 NSURLProtocol 無法直接攔截 WKWebView,但其實(shí)還是有解決方案的。就是使用 WKBrowsingContextControllerregisterSchemeForCustomProtocol。

// 注冊 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"];
}

但由于這涉及到了私有方法,直接引用無法過蘋果的機(jī)審,所以使用的時(shí)候需要對字符串做下處理,比如對方法名進(jìn)行算法加密處理等,實(shí)測也是可以通過審核的。

總之,NSURLProtocol 非常強(qiáng)大,無論是優(yōu)化 App 的性能,還是拓展功能,都具有很強(qiáng)的可塑空間,但在使用的同時(shí),又要多關(guān)注它帶來的問題。盡管它在很多框架或者知名項(xiàng)目中都已經(jīng)得以應(yīng)用,其奧義依然值得開發(fā)者們?nèi)ド钊胙芯俊?/p>

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容