iOS中NSURLProtocol黑魔法的使用

前言

??因為DNS發生域名劫持,所以需要手動將URL請求的域名重定向到指定的IP地址,但是由于請求可能是通過NSURLConnectionNSURLSession或者AFNetworking等方式,因此要想統一進行處理,可以采用NSURLProtocol

URL loading system 結構圖

??NSURLProtocol是蘋果為我們提供的 URL Loading System 的一部分,能夠讓你去重新定義蘋果的URL加載系統 (URL Loading System)的行為,
URL Loading System里有許多類用于處理URL請求,比如NSURLNSURLRequestNSURLConnectionNSURLSession等,當URL Loading System使用NSURLRequest去獲取資源的時候,它會創建一個NSURLProtocol子類的實例,NSURLProtocol看起來像是一個協議,但其實這是一個類,你不能直接實例化一個NSURLProtocol,而是需要寫一個繼承自 NSURLProtocol 的子類,并通過- registerClass:方法注冊我們的協議類,然后 URL 加載系統就會在請求發出時使用我們創建的協議對象對該請求進行處理。

用一句話解釋NSURLProtocol:就是一個蘋果允許的中間人攻擊。
NSURLProtocol可以劫持系統所有基于C socket的網絡請求。
注意:WKWebView基于Webkit,并不走底層的C socket,所以NSURLProtocol攔截不了WKWebView中的請求。

使用場景

??不管你是通過UIWebView, NSURLConnection或者第三方庫 (AFNetworkingAlamofire等),他們都是基于NSURLConnection或者 NSURLSession實現的,因此你可以通過NSURLProtocol做自定義的操作。

通過 NSURLProtocol可以比較簡單地就能實現:

  • 重定向網絡請求(可以解決電信的DNS域名劫持問題)
  • 忽略網絡請求,使用本地緩存
  • 自定義網絡請求的返回結果Response
  • 攔截圖片加載請求,轉為從本地文件加載
  • 一些全局的網絡請求設置
  • 快速進行測試環境的切換
  • 過濾掉一些非法請求
  • 網絡的緩存處理(H5離線包 和 網絡圖片緩存)
  • 可以攔截UIWebView,基于系統的NSURLConnection或者NSURLSession進行封裝的網絡請求。目前WKWebView無法被NSURLProtocol攔截。
  • 當有多個自定義NSURLProtocol注冊到系統中的話,會按照他們注冊的反向順序依次調用URL加載流程。當其中有一個NSURLProtocol攔截到請求的話,后續的NSURLProtocol就無法攔截到該請求。

具體步驟為:
使用NSURLProtocol的主要可以分為5個步驟:
注冊—>攔截—>轉發—>回調—>結束
即:
注冊NSURLProtocol子類 -> 使用NSURLProtocol子類攔截請求 -> 使用NSURLSession重新發起請求 -> 將NSURLSession請求的響應內容返回 -> 結束

使用方法

1. 子類化:

由于 NSURLProtocol是一個抽象類,所以使用的時候必須定義一個它的子類:

#import <Foundation/Foundation.h>

@interface CustomURLProtocol : NSURLProtocol

@end

2. 注冊:

??對于基于NSURLConnection或者使用[NSURLSession sharedSession]初始化對象創建的網絡請求,調用registerClass方法即可。

//注冊protocol
[NSURLProtocol registerClass:[CustomURLProtocol class]];

??對于基于NSURLSession的網絡請求,如下所示需要通過配置sessionWithConfiguration:delegate:delegateQueue:初始化對象的,需要配置對象的protocolClasses屬性,這個在下面會有詳細的介紹。

// NSURLSession例子
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
NSArray *protocolArray = @[ [CustomURLProtocol class]];
configuration.protocolClasses = protocolArray;
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
NSURLSessionTask *task = [session dataTaskWithRequest:_request];
[task resume];

??一經注冊之后,所有交給URL Loading system的網絡請求都會被攔截,所以當不需要攔截的時候,要進行注銷

[NSURLProtocol unregisterClass:[CustomURLProtocol class]];

3. 抽象對象必須實現的方法(攔截)

注冊成功之后,就需要我們的子類去實現抽象方法:

//所有注冊此Protocol的請求都會經過這個方法的判斷
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
//可選方法,對需要攔截的請求進行自定的處理
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
//主要是用來判斷兩個request是否相同,這個方法基本不常用
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;
//初始化protocol實例,所有來源的請求都以NSURLRequest形式接收
- (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id <NSURLProtocolClient>)client;
/**
    開始請求
    在這里需要我們手動的把請求發出去,可以使用原生的NSURLSessionDataTask,也可以使用的第三方網絡庫
    同時設置"NSURLSessionDataDelegate"協議,接收Server端的響應
*/
- (void)startLoading;
//請求被停止
- (void)stopLoading;

詳細說明:

  • canInitWithRequest

??該方法會拿到request的對象,我們可以通過該方法的返回值來篩選request是否需要被NSURLProtocol做攔截處理。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    
    NSString * scheme = [[request.URL scheme] lowercaseString];
    if ([scheme isEqual:@"http"]) {
        return YES;
    }

    //看看是否已經處理過了,防止無限循環 根據業務來截取 
    if ([NSURLProtocol propertyForKey: URLProtocolHandledKey inRequest:request]) {
        return NO;
    }
    return NO;
}

URLProtocolHandledKey 是:

static NSString * const URLProtocolHandledKey = @"URLProtocolHandledKey";

上面我們就只會攔截http的請求。

  • canonicalRequestForRequest:

??這個方法用來統一處理請求request 對象的,可以修改頭信息,或者重定向。沒有特殊需要,則直接return request
??如果要在這里做重定向以及頭信息的時候注意檢查是否已經添加,因為這個方法可能被調用多次,也可以在后面的方法中做。

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    return request;
}
  • requestIsCacheEquivalent:toRequest:

主要判斷兩個request是否相同,如果相同的話可以使用緩存數據,通常只需要調用父類的實現。

+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b {
    return [super requestIsCacheEquivalent:a toRequest:b];
}

4. 轉發

??在攔截到網絡請求,并且對網絡請求進行定制處理以后。我們需要將網絡請求重新發送出去,就可以初始化一個NSURLProtocol對象了:

- (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id <NSURLProtocolClient>)client {
    return [super initWithRequest:request cachedResponse:cachedResponse client:client];
}

該方法會創建一個NSURLProtocol實例,在這里直接調用super的指定構造器方法,實例化一個對象。

  • startLoading

??接下來就是轉發的核心方法startLoading。在該方法中,把當前請求的request攔截下來以后,可以在這里修改請求信息,重定向網絡,DNS解析,使用自定義的緩存等。至于發送的形式,可以是基于NSURLConnectionNSURLSession甚至AFNetworking等網絡庫。對于NSURLConnection來說,就是創建一個NSURLConnection,對于NSURLSession,就是發起一個NSURLSessionTask 。一般下載前需要設置該請求正在進行下載,防止多次下載的情況發生。

重點:需要標記已經處理過的request
下面就是一個重定向的例子:

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //標示該request已經處理過了,防止無限循環
    [NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];

    //使用NSURLSession繼續把request發送出去
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
//定義全局的NSURLSession對象用于stop請求使用
    self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:mainQueue];
    NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request];
    [task resume];
}

5. 回調

??既是面向切面的編程,就不能影響到原來網絡請求的邏輯。所以上一步將網絡請求轉發出去以后,當收到網絡請求的返回,還需要再將返回值返回給原來發送網絡請求的地方。
主要需要調用到

//將新的response作為request對應的response
[self.client URLProtocol:self
       didReceiveResponse:response
       cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        
//設置request對應的 響應數據 response data
[self.client URLProtocol:self didLoadData:data];
        
//標記請求結束
[self.client URLProtocolDidFinishLoading:self];

所以上面的startLoading的完整版本應該是

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //標示該request已經處理過了,防止無限循環
    [NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];

    //這個enableDebug隨便根據自己的需求了,可以直接攔截到數據返回本地的模擬數據,進行測試
    BOOL enableDebug = NO;
    if (enableDebug) {
        
        NSString *str = @"測試數據";
        NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding];
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:@"text/plain"
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        [self.client URLProtocol:self
              didReceiveResponse:response
              cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        [self.client URLProtocol:self didLoadData:data];
        [self.client URLProtocolDidFinishLoading:self];
    }
    else {
        //使用NSURLSession繼續把request發送出去
        NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
        NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
        self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:mainQueue];
        NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request];
        [task resume];
    }
}

上面采用NSURLSession發送的網絡請求,所以實現NSURLSessionDelegate代理方法進行回調,我們可以做相應的處理,先看看NSURLSessionDelegate的代理方法:

//接收到返回信息時(還未開始下載), 執行的代理方法
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                 didReceiveResponse:(NSURLResponse *)response
                                  completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;
//接收到服務器返回的數據 調用多次
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                     didReceiveData:(NSData *)data;
//請求結束或者是失敗的時候調用
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
                           didCompleteWithError:(nullable NSError *)error;

一般默認使用方式為:

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    // 打印返回數據
    NSString *dataStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    if (dataStr) {
        NSLog(@"***截取數據***: %@", dataStr);
    }
    [self.client URLProtocol:self didLoadData:data];
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
    if (error) {
        [self.client URLProtocol:self didFailWithError:error];
    } else {
        [self.client URLProtocolDidFinishLoading:self];
    }
}

6. 結束

  • stopLoading

??在一個網絡請求完全結束以后,NSURLProtocol回調用到。在該方法里,我們完成在結束網絡請求的操作,以NSURLSession為例:

- (void)stopLoading {
    [self.session invalidateAndCancel];
    self.session = nil;
}

注意點:

1. 如果startLoading中網絡請求采用的是NSURLConnection,例如:

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //做下標記,防止遞歸調用
    [NSURLProtocol setProperty:@YES forKey:hasInitKey inRequest:mutableReqeust];
    
    //這個enableDebug隨便根據自己的需求了,可以直接攔截到數據返回本地的模擬數據,進行測試
    BOOL enableDebug = NO;
    if (enableDebug) {
        NSString *str = @"測試數據";
        NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding];
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:@"text/plain"
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        [self.client URLProtocol:self
              didReceiveResponse:response
              cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        [self.client URLProtocol:self didLoadData:data];
        [self.client URLProtocolDidFinishLoading:self];
    }
    else {
        //采用了NSURLConnection方式請求
        self.connection = [NSURLConnection connectionWithRequest:mutableReqeust delegate:self];
    }
}

停止方法為

- (void)stopLoading
{
   [self.connection cancel];
  self.connection = nil ;
}

那么相應的實現的代理方法為NSURLConnectionDataDelegate

//接收到Response信息
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
}
//接收到服務器的數據(可能調用多次)
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    // 打印返回數據
    NSString *dataStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    if (dataStr) {
        NSLog(@"***截取數據***: %@", dataStr);
    }
    [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];
}

2. 攔截AFNetworking

??目前為止,我們上面的代碼已經能夠監控到絕大部分的網絡請求,但是呢,有一個卻是特殊的,比如AFNetworking請求。

??因為AFNetworking網絡請求的NSURLSession實例方法都是通過
sessionWithConfiguration:delegate:delegateQueue:方法獲得的,我們是不能監聽到的,
然而我們通過[NSURLSession sharedSession]生成session就可以攔截到請求,原因就出在NSURLSessionConfiguration上,我們進到NSURLSessionConfiguration里面看一下,他有一個屬性:

@property (nullable, copy) NSArray<Class> *protocolClasses;

??我們能夠看出,這是一個NSURLProtocol數組,上面我們提到了,我們監控網絡是通過注冊NSURLProtocol來進行網絡監控的,但是通過sessionWithConfiguration:delegate:delegateQueue:得到的session,他的configuration中已經有一個NSURLProtocol,所以他不會走我們的protocol來,怎么解決這個問題呢? 其實很簡單,我們將NSURLSessionConfiguration的屬性protocolClassesget方法hook掉,通過返回我們自己的protocol,這樣,我們就能夠監控到通過sessionWithConfiguration:delegate:delegateQueue:得到的session的網絡請求。
??所以對于AFNetworking中網絡請求初始化方法可以修改為:

NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
//指定其protocolClasses
configuration.protocolClasses = @[[CustomURLProtocol class]];
AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:configuration];
//不采用manager初始化
//AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
[manager GET:@"http://www.baidu.com" parameters:nil success:^(NSURLSessionDataTask *task, id responseObject) {
        
} failure:^(NSURLSessionDataTask *task, NSError *error) {
        
}];

也可以通過runtime來面向切面編程

#import <Foundation/Foundation.h>

@interface FFSessionConfiguration : NSObject
//是否交換方法
@property (nonatomic,assign) BOOL isExchanged;

+ (FFSessionConfiguration *)defaultConfiguration;
// 交換掉NSURLSessionConfiguration的 protocolClasses方法
- (void)load;
// 還原初始化
- (void)unload;

@end
#import "FFSessionConfiguration.h"
#import <objc/runtime.h>
#import "CustomURLProtocol.h"

@implementation FFSessionConfiguration

+ (FFSessionConfiguration *)defaultConfiguration {
    static FFSessionConfiguration *staticConfiguration;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        staticConfiguration=[[FFSessionConfiguration alloc] init];
    });
    return staticConfiguration;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        self.isExchanged = NO;
    }
    return self;
}

- (void)load {
    self.isExchanged=YES;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
    
}

- (void)unload {
    self.isExchanged=NO;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

- (void)swizzleSelector:(SEL)selector fromClass:(Class)original toClass:(Class)stub {
    Method originalMethod = class_getInstanceMethod(original, selector);
    Method stubMethod = class_getInstanceMethod(stub, selector);
    if (!originalMethod || !stubMethod) {
        [NSException raise:NSInternalInconsistencyException format:@"Couldn't load NEURLSessionConfiguration."];
    }
    method_exchangeImplementations(originalMethod, stubMethod);
}

- (NSArray *)protocolClasses {
    // 如果還有其他的監控protocol,也可以在這里加進去
    return @[[CustomURLProtocol class]];
}

@end

在使用時也很簡單,采用下面方式注冊

FFSessionConfiguration *sessionConfiguration = [FFSessionConfiguration defaultConfiguration];
[NSURLProtocol registerClass:[CustomURLProtocol class]];
if (![sessionConfiguration isExchanged]) {
    [sessionConfiguration load];
}

3. 關于不能攔截WKWebView

??原因是WKWebView在獨立于app 進程之外的進程中執行網絡請求,請求數據不經過主進程,因此,在WKWebView上直接使用 NSURLProtocol無法攔截請求。
具體可以參考NSURLProtocol對WKWebView的處理

Demo下載

參考文獻

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374

推薦閱讀更多精彩內容