一步一步構建你的iOS網路層 - HTTP篇

黑花白花發表於2018-01-09

從簡書遷移到掘金...

前言

本文參考casa先生的網路層架構設計從網路請求的構建到請求結果的處理為你概述如何構建一個方便易用的iOS網路層, 全文約五千字, 預計花費閱讀時間20 - 30分鐘.

目錄

  • 網路請求的構建

  • 網路請求的派發

    1. 請求的派發與取消
    2. 多伺服器的切換
  • 合理的使用請求派發器

    1. 協議還是配置物件?
    2. 簡單的請求結果快取器
    3. 請求結果的格式化
    4. 兩個小玩意兒

一.網路請求的構建

網路請求的構建很簡單, 根據一個請求需要的條件如URL, 請求方式, 請求引數, 請求頭等定義請求生成的介面即可. 定義如下:

@interface HHURLRequestGenerator : NSObject

+ (instancetype)sharedInstance;

- (void)switchService;
- (void)switchToService:(HHServiceType)serviceType;

- (NSMutableURLRequest *)generateRequestWithUrlPath:(NSString *)urlPath
                                           useHttps:(BOOL)useHttps
                                             method:(NSString *)method
                                             params:(NSDictionary *)params
                                             header:(NSDictionary *)header;

- (NSMutableURLRequest *)generateUploadRequestUrlPath:(NSString *)urlPath
                                             useHttps:(BOOL)useHttps
                                               params:(NSDictionary *)params
                                             contents:(NSArray<HHUploadFile *> *)contents
                                               header:(NSDictionary *)header;

@end
複製程式碼

可以看到方法引數都是生成請求基本組成部分, 當然, 這裡的引數比較少, 因為在我的專案中像請求超時時間都是一樣的, 類似這些公用的設定我都偷懶直接寫在請求配置檔案裡面了. 我們看看請求介面的具體實現, 以資料請求為例:

- (NSMutableURLRequest *)generateRequestWithUrlPath:(NSString *)urlPath useHttps:(BOOL)useHttps method:(NSString *)method params:(NSDictionary *)params header:(NSDictionary *)header {
    
    NSString *urlString = [self urlStringWithPath:urlPath useHttps:useHttps];
    NSMutableURLRequest *request = [self.requestSerialize requestWithMethod:method URLString:urlString parameters:params error:nil];
    request.timeoutInterval = RequestTimeoutInterval;
    [self setCookies];//設定cookie
    [self setCommonRequestHeaderForRequest:request];// 在這裡做公用請求頭的設定
    [header enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull value, BOOL * _Nonnull stop) {
        [request setValue:value forHTTPHeaderField:key];
    }];
    return request;
}
複製程式碼
- (NSString *)urlStringWithPath:(NSString *)path useHttps:(BOOL)useHttps {
    
    if ([path hasPrefix:@"http"]) {
        return path;
    } else {
        
        NSString *baseUrlString = [HHService currentService].baseUrl;
        if (useHttps && baseUrlString.length > 4) {
            
            NSMutableString *mString = [NSMutableString stringWithString:baseUrlString];
            [mString insertString:@"s" atIndex:4];
            baseUrlString = [mString copy];
        }
        return [NSString stringWithFormat:@"%@%@", baseUrlString, path];
    }
}
複製程式碼

程式碼很簡單, 介面根據引數呼叫urlStringWithPath:useHttps:通過BaseURL和URLPath拼裝出完整的URL, 然後用這個URL和其他引數生成一個URLRequest, 然後呼叫setCommonRequestHeaderForRequest:設定公用請求, 最後返回這個URLRequest.

BaseURL來自HHService, HHService對外暴露各個環境(測試/開發/釋出)下的baseURL和切換伺服器的介面, 內部走工廠生成當前的伺服器, 我的設定是預設連線第一個伺服器且APP關閉後恢復此設定, APP執行中可根據需要呼叫switchService切換伺服器. HHService定義如下:

@protocol HHService <NSObject>

@optional
- (NSString *)testEnvironmentBaseUrl;
- (NSString *)developEnvironmentBaseUrl;
- (NSString *)releaseEnvironmentBaseUrl;

@end

@interface HHService : NSObject<HHService>

+ (HHService *)currentService;

+ (void)switchService;
+ (void)switchToService:(HHServiceType)serviceType;

- (NSString *)baseUrl;
- (HHServiceEnvironment)environment;
@end
複製程式碼
#import "HHService.h"

@interface HHService ()

@property (assign, nonatomic) HHServiceType type;
@property (assign, nonatomic) HHServiceEnvironment environment;

@end

@interface HHServiceX : HHService
@end

@interface HHServiceY : HHService
@end

@interface HHServiceZ : HHService
@end

@implementation HHService

#pragma mark - Interface

static HHService *currentService;
static dispatch_semaphore_t lock;
+ (HHService *)currentService {
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        lock = dispatch_semaphore_create(1);
        currentService = [HHService serviceWithType:HHService0];
    });
    
    return currentService;
}

+ (void)switchService {
    [self switchToService:self.currentService.type + 1];
}

+ (void)switchToService:(HHServiceType)serviceType {
    
    dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
    currentService = [HHService serviceWithType:(serviceType % ServiceCount)];
    dispatch_semaphore_signal(lock);
}

+ (HHService *)serviceWithType:(HHServiceType)type {
    
    HHService *service;
    switch (type) {
        case HHService0: service = [HHServiceX new];  break;
        case HHService1: service = [HHServiceY new];  break;
        case HHService2: service = [HHServiceZ new];  break;
    }
    service.type = type;
    service.environment = BulidServiceEnvironment;
    return service;
}

- (NSString *)baseUrl {
    
    switch (self.environment) {
        case HHServiceEnvironmentTest: return [self testEnvironmentBaseUrl];
        case HHServiceEnvironmentDevelop: return [self developEnvironmentBaseUrl];
        case HHServiceEnvironmentRelease: return [self releaseEnvironmentBaseUrl];
    }
}

@end
複製程式碼

2.網路請求的派發

請求的派發是通過一個單例HHNetworkClient來實現的, 如果把請求比作炮彈的話, 那麼這個單例就是發射炮彈的炮臺, 使用炮臺的人只需要告訴炮臺需要發射什麼樣的炮彈和炮彈的打擊目標便可發射了. 另外, 應該提供取消打擊的功能以處理不必要的打擊的情況, 那麼, 根據炮臺的作用. HHNetworkClient定義如下:

@interface HHNetworkClient : NSObject

+ (instancetype)sharedInstance;

- (NSURLSessionDataTask *)dataTaskWithUrlPath:(NSString *)urlPath
                                     useHttps:(BOOL)useHttps
                                  requestType:(HHNetworkRequestType)requestType
                                       params:(NSDictionary *)params
                                       header:(NSDictionary *)header
                            completionHandler:(void (^)(NSURLResponse *response,id responseObject,NSError *error))completionHandler;

- (NSNumber *)dispatchTaskWithUrlPath:(NSString *)urlPath
                             useHttps:(BOOL)useHttps
                          requestType:(HHNetworkRequestType)requestType
                               params:(NSDictionary *)params
                               header:(NSDictionary *)header
                    completionHandler:(void (^)(NSURLResponse *response,id responseObject,NSError *error))completionHandler;

- (NSNumber *)dispatchTask:(NSURLSessionTask *)task;

- (NSNumber *)uploadDataWithUrlPath:(NSString *)urlPath
                           useHttps:(BOOL)useHttps
                             params:(NSDictionary *)params
                           contents:(NSArray<HHUploadFile *> *)contents
                             header:(NSDictionary *)header
                    progressHandler:(void(^)(NSProgress *))progressHandler
                  completionHandler:(void (^)(NSURLResponse *response,id responseObject,NSError *error))completionHandler;

- (void)cancelAllTask;
- (void)cancelTaskWithTaskIdentifier:(NSNumber *)taskIdentifier;

@end
複製程式碼
@interface HHNetworkClient ()

@property (strong, nonatomic) AFHTTPSessionManager *sessionManager;
@property (strong, nonatomic) NSMutableDictionary<NSNumber *, NSURLSessionTask *> *dispathTable;

@property (assign, nonatomic) CGFloat totalTaskCount;
@property (assign, nonatomic) CGFloat errorTaskCount;
@end

複製程式碼

1.請求的派發與取消

外部暴露資料請求和檔案上傳的介面, 引數為構建請求所需的必要引數, 返回值為此次請求任務的taskIdentifier, 呼叫方可以通過taskIdentifier取消正在執行的請求任務. 內部宣告一個dispathTable保持著此時正在執行的任務, 並在任務執行完成或者任務取消時移除任務的引用, 以資料請求為例, 具體實現如下:

- (NSURLSessionDataTask *)dataTaskWithUrlPath:(NSString *)urlPath useHttps:(BOOL)useHttps requestType:(HHNetworkRequestType)requestType params:(NSDictionary *)params header:(NSDictionary *)header completionHandler:(void (^)(NSURLResponse *, id, NSError *))completionHandler {
    
    NSString *method = (requestType == HHNetworkRequestTypeGet ? @"GET" : @"POST");
    NSMutableURLRequest *request = [[HHURLRequestGenerator sharedInstance] generateRequestWithUrlPath:urlPath useHttps:useHttps method:method params:params header:header];
    NSMutableArray *taskIdentifier = [NSMutableArray arrayWithObject:@-1];
    NSURLSessionDataTask *task = [self.sessionManager dataTaskWithRequest:request completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
        
        dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
        [self checkSeriveWithTaskError:error];
        [self.dispathTable removeObjectForKey:taskIdentifier.firstObject];
        dispatch_semaphore_signal(lock);
        
        completionHandler ? completionHandler(response, responseObject, error) : nil;
    }];
    taskIdentifier[0] = @(task.taskIdentifier);
    return task;
}

- (NSNumber *)dispatchTaskWithUrlPath:(NSString *)urlPath useHttps:(BOOL)useHttps requestType:(HHNetworkRequestType)requestType params:(NSDictionary *)params header:(NSDictionary *)header completionHandler:(void (^)(NSURLResponse *, id, NSError *))completionHandler {
    
    return [self dispatchTask:[self dataTaskWithUrlPath:urlPath useHttps:useHttps requestType:requestType params:params header:header completionHandler:completionHandler]];
}

- (NSNumber *)dispatchTask:(NSURLSessionDataTask *)task {
    
    if (task == nil) { return @-1; }
    
    dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
    self.totalTaskCount += 1;
    [self.dispathTable setObject:task forKey:@(task.taskIdentifier)];
    dispatch_semaphore_signal(lock);
    [task resume];
    return @(task.taskIdentifier);
}
複製程式碼

程式碼很簡單, 通過引數生成URLRequest, 然後通過AFHTTPSessionManager執行任務, 在任務執行前我們以task.taskIdentifier為key保持一下執行的任務, 然後在任務執行後我們移除這個任務, 當然, 外部也可以在必要的時候通過我們返回的task.taskIdentifier手動移除任務.

注意我們先宣告一個NSMutableArray來標誌taskIdentifier, 然後在任務生成後設定taskIdentifier[0]為task. taskIdentifier, 最後在任務完成的回撥block中使用taskIdentifier[0]來移除這個已經完成的任務. 可能有人會有疑問為什麼不直接使用task.taskIdentifier, block不是可以捕獲task嗎? 下面解釋一下為什麼這樣寫:

我們知道block之於函式最大的區別就在於它可以捕獲自身作用域外的物件, 並在block執行的時候訪問被捕獲的物件, 具體的, 對於值型別物件block會生成一份此物件的拷貝, 對於引用型別物件block會生成一個此物件的引用並使該物件的引用計數+1(這裡我們只描述非__block修飾的情況). 那麼代入到上面的程式碼, 我們來一步一步分析:

  • 直接捕獲task的寫法
NSURLSessionDataTask *task = [self.sessionManager dataTaskWithRequest:request completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
  ...略
        [self.dispathTable removeObjectForKey:@(task.taskIdentifier)];
...略
    }];
[self.dispathTable setObject:task forKey:@(task.taskIdentifier)];
複製程式碼

我們把它拆開來看:

NSURLSessionDataTask *task; 
NSURLSessionDataTask *returnTask = [self.sessionManager dataTaskWithRequest:request completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
  ...略
        [self.dispathTable removeObjectForKey:@(task.taskIdentifier)];
...略
    }];
task =  returnTask;
[self.dispathTable setObject:task forKey:@(task.taskIdentifier)];
複製程式碼

可以看到returnTask是我們實際儲存的任務, 而task只是一個臨時變數, 此時task指向nil, 那我們生成returnTask的block此時捕獲到的task也就是nil, 所以在任務完成的時候我們的task.taskIdentifier一定是0, 這樣寫的結果就是dispathTable只會新增不會刪除(系統的taskIdentifier是從0開始依次遞增的), 當然, 因為進行中的returnTask我們是做了儲存的, 所以在任務未完成的時候我們還是可以做取消的.

  • 如果一開始給task一個佔位物件呢不讓它為nil可以嗎?
NSURLSessionDataTask *task = [NSObject new]; //1.suspend
NSURLSessionDataTask *returnTask = [self.sessionManager dataTaskWithRequest:request completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
  ...略
        [self.dispathTable removeObjectForKey:@(task.taskIdentifier)];//3.completed
...略
    }];//2.alloc
task =  returnTask;
[self.dispathTable setObject:task forKey:@(task.taskIdentifier)];
複製程式碼

這樣其實就是一個簡單的引用變換題了, 我們來看看各個指標的指向情況:

suspend: pTask->NSObject block.pTask->nil pReturnTask->nil

alloc: pTask-> NSObject block.pTask->NSObject pReturnTask->returnTask

completed: pTask->returnTask block.pTask->NSObject pReturnTask->returnTask

可以看到在任務執行完成時我們訪問block.pTask時也不過是我們一開始的佔位物件, 所以這個方案也不行, 當然, 取消任務依然可用

事實上block.pTask確實是捕獲了佔位物件, 只是我們在那之後沒有替換block.pTask指向到returnTask, 然而block.pTask我們是訪問不了的, 所以這個方案行不通.

  • 如果我們的佔位物件是一個容器呢?
NSMutableArray *taskIdentifier = [NSMutableArray arrayWithObject:@-1];
NSURLSessionDataTask *returnTask = [self.sessionManager dataTaskWithRequest:request completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
  ...略
        [self.dispathTable removeObjectForKey:@(taskIdentifier.firstObject)];
...略
    }];
taskIdentifier[0] = @(returnTask.taskIdentifier);
[self.dispathTable setObject:task forKey:@(task.taskIdentifier)];
複製程式碼

既然我們訪問不了block.pTask那就訪問block.pTask指向的物件嘛, 更改這個物件的內容不就相當於更改了block.pTask麼, 大家照著2的思路走一下應該很容易就能想通, 我就不多說了.

2.多伺服器的切換

關於多伺服器其實我也沒有實際的經驗, 公司正在部署第二臺伺服器, 具體需求是如果訪問第一臺伺服器總是超時或者出錯, 那就切換到第二臺伺服器, 基於此需求我簡單的實現一下:

- (NSNumber *)dispatchTask:(NSURLSessionDataTask *)task {
    ...略
    dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
    self.totalTaskCount += 1;
    [self.dispathTable setObject:task forKey:@(task.taskIdentifier)];
    dispatch_semaphore_signal(lock);
    ...略
}
複製程式碼
- (NSURLSessionDataTask *)dataTaskWithUrlPath:(NSString *)urlPath useHttps:(BOOL)useHttps requestType:(HHNetworkRequestType)requestType params:(NSDictionary *)params header:(NSDictionary *)header completionHandler:(void (^)(NSURLResponse *, id, NSError *))completionHandler {
    
    NSString *method = (requestType == HHNetworkRequestTypeGet ? @"GET" : @"POST");
    ...略
    NSURLSessionDataTask *task = [self.sessionManager dataTaskWithRequest:request completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
       ...略
        [self checkSeriveWithTaskError:error];
       ...略
    }];
    ...略
}
複製程式碼
- (void)checkSeriveWithTaskError:(NSError *)error {
    
    if ([HHAppContext sharedInstance].isReachable) {
        switch (error.code) {
                
            case NSURLErrorUnknown:
            case NSURLErrorTimedOut:
            case NSURLErrorCannotConnectToHost: {
                self.errorTaskCount += 1;
            }
            default:break;
        }
        
        if (self.totalTaskCount >= 40 && (self.errorTaskCount / self.totalTaskCount) == 0.1) {
            
            self.totalTaskCount = self.errorTaskCount = 0;
            [[HHURLRequestGenerator sharedInstance] switchService];
        }
    }
}
複製程式碼
- (void)didReceivedSwitchSeriveNotification:(NSNotification *)notif {
    
    dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
    self.totalTaskCount = self.errorTaskCount = 0;
    dispatch_semaphore_signal(lock);
    [[HHURLRequestGenerator sharedInstance] switchToService:[notif.userInfo[@"service"] integerValue]];
}
複製程式碼

假設認為APP在此次使用過程中網路任務的錯誤率達到10%那就應該切換一下伺服器, 我們在任務派發前將任務總數+1, 然後在任務結束後判斷任務是否成功, 失敗的話將任務失敗總數+1再判斷是否到達最大錯誤率, 進而切換到另一臺伺服器. 另外還有一種情況是大部分伺服器都掛了, 後臺直接走APNS推送可用的伺服器序號過來, 就不用挨個挨個切換了.

三.合理的使用請求派發器

OK, 炮彈有了, 炮臺也就緒了, 接下來看看如何使用這個炮臺.

#pragma mark - HHAPIConfiguration

typedef void(^HHNetworkTaskProgressHandler)(CGFloat progress);
typedef void(^HHNetworkTaskCompletionHander)(NSError *error, id result);

@interface HHAPIConfiguration : NSObject

@property (copy, nonatomic) NSString *urlPath;
@property (strong, nonatomic) NSDictionary *requestParameters;

@property (assign, nonatomic) BOOL useHttps;
@property (strong, nonatomic) NSDictionary *requestHeader;
@property (assign, nonatomic) HHNetworkRequestType requestType;
@end

@interface HHDataAPIConfiguration : HHAPIConfiguration

@property (assign, nonatomic) NSTimeInterval cacheValidTimeInterval;

@end

@interface HHUploadAPIConfiguration : HHAPIConfiguration

@property (strong, nonatomic) NSArray<HHUploadFile *> * uploadContents;

@end

#pragma mark - HHAPIManager

@interface HHAPIManager : NSObject

- (void)cancelAllTask;
- (void)cancelTaskWithtaskIdentifier:(NSNumber *)taskIdentifier;
+ (void)cancelTaskWithtaskIdentifier:(NSNumber *)taskIdentifier;
+ (void)cancelTasksWithtaskIdentifiers:(NSArray *)taskIdentifiers;

- (NSURLSessionDataTask *)dataTaskWithConfiguration:(HHDataAPIConfiguration *)config completionHandler:(HHNetworkTaskCompletionHander)completionHandler;
- (NSNumber *)dispatchDataTaskWithConfiguration:(HHDataAPIConfiguration *)config completionHandler:(HHNetworkTaskCompletionHander)completionHandler;
- (NSNumber *)dispatchUploadTaskWithConfiguration:(HHUploadAPIConfiguration *)config progressHandler:(HHNetworkTaskProgressHandler)progressHandler completionHandler:(HHNetworkTaskCompletionHander)completionHandler;

@end
複製程式碼
- (void)cancelAllTask {
    
    for (NSNumber *taskIdentifier in self.loadingTaskIdentifies) {
        [[HHNetworkClient sharedInstance] cancelTaskWithTaskIdentifier:taskIdentifier];
    }
    [self.loadingTaskIdentifies removeAllObjects];
}

- (void)cancelTaskWithtaskIdentifier:(NSNumber *)taskIdentifier {
    
    [[HHNetworkClient sharedInstance] cancelTaskWithTaskIdentifier:taskIdentifier];
    [self.loadingTaskIdentifies removeObject:taskIdentifier];
}

+ (void)cancelTaskWithtaskIdentifier:(NSNumber *)taskIdentifier {
    [[HHNetworkClient sharedInstance] cancelTaskWithTaskIdentifier:taskIdentifier];
}

+ (void)cancelTasksWithtaskIdentifiers:(NSArray *)taskIdentifiers {

    for (NSNumber *taskIdentifier in taskIdentifiers) {
        [[HHNetworkClient sharedInstance] cancelTaskWithTaskIdentifier:taskIdentifier];
    }
}

- (NSURLSessionDataTask *)dataTaskWithConfiguration:(HHDataAPIConfiguration *)config completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    return [[HHNetworkClient sharedInstance] dataTaskWithUrlPath:config.urlPath useHttps:config.useHttps requestType:config.requestType params:config.requestParameters header:config.requestHeader completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
        completionHandler ? completionHandler([self formatError:error], responseObject) : nil;
    }];
}
複製程式碼

HHAPIManager對外提供資料請求和取消的介面, 內部呼叫HHNetworkClient進行實際的請求操作.

1.協議還是配置物件?

HHAPIManager的介面我們並沒有像之前一樣提供多個引數, 而是將多個引數組合為一個配置物件, 下面說一下為什麼這樣做:

  • 為什麼多個引數的介面方式不好?

一個APP中呼叫的API通常都是數以百計甚至千計, 如果有一天需要對已成型的所有的API都追加一個引數, 此時的改動之多, 足使男程式設計師沉默, 女程式設計師流淚. 舉個例子: APP1.0已經上線, 1.1版本總監突然要求對資料請求加上快取, 操作請求不用加快取, 如果是引數介面的形式一般就是這樣寫:

//老介面
- (NSNumber *)dispatchTaskWithUrlPath:(NSString *)urlPath
                             useHttps:(BOOL)useHttps
                               method:(NSString *)method
                               params:(NSDictionary *)params
                               header:(NSDictionary *)header;
//新介面
- (NSNumber *)dispatchTaskWithUrlPath:(NSString *)urlPath
                             useHttps:(BOOL)useHttps
                               method:(NSString *)method
                               params:(NSDictionary *)params
                               header:(NSDictionary *)header
                          shouldCache:(BOOL)shouldCache;
複製程式碼

然後原來的老介面全都呼叫新介面shouldCache預設傳NO, 不需要快取的API不用做改動, 而需要快取的API都得改呼叫新介面然後shouldCache傳YES.

這樣能暫時解決問題, 工作量也會小一些, 然後過了兩天總監過來說, 為什麼沒有對API區分快取時間? 還有, 我們又有新需求了. 呵呵!

  • 使用協議提升擴充性
@protocol HHAPIManager <NSObject>

@required
- (BOOL)useHttps;
- (NSString *)urlPath;
- (NSDictionary *)parameters;
- (OTSNetworkRequestType)requestType;

@optional
- (BOOL)checkParametersIsValid;
- (NSTimeInterval)cacheValidTimeInterval;
- (NSArray<OTSUploadFile *> *)uploadContents;
@end
複製程式碼
@interface HHAPIManager : NSObject<HHAPIManager>
...略
- (NSNumber *)dispatchTaskWithCompletionHandler:(OTSNetworkTaskCompletionHander)completionHandler;
...略
@end
複製程式碼

其實最初的設計是走協議的, HHAPIManager遵守這個協議, 內部給上預設引數, dispatchTaskWithCompletionHandler:會去挨個獲取這些引數, 各個子類自行實現自己自定義的部分, 這樣以後就算有任何擴充, 只需要在協議裡面加個方法基類給上預設值, 有需要的子類API重寫一下就行了.

  • 替換協議為配置物件
- (NSURLSessionDataTask *)dataTaskWithConfiguration:(HHDataAPIConfiguration *)config completionHandler:(HHNetworkTaskCompletionHander)completionHandler;
- (NSNumber *)dispatchDataTaskWithConfiguration:(HHDataAPIConfiguration *)config completionHandler:(HHNetworkTaskCompletionHander)completionHandler;
- (NSNumber *)dispatchUploadTaskWithConfiguration:(HHUploadAPIConfiguration *)config progressHandler:(HHNetworkTaskProgressHandler)progressHandler completionHandler:(HHNetworkTaskCompletionHander)completionHandler;
複製程式碼

協議的方案其實很好, 也是我想要的設計. 但是協議是針對類而言的, 這意味著今後的每新增一個API就需要新建一個HHAPIManager的子類, 很容易就有了幾百個API類檔案, 維護起來很麻煩, 找起來很麻煩(以上是同事要求替換協議的理由, 我仍然支援協議, 但是他們人多). 所以將協議替換為配置物件, 然後API以模組功能劃分, 每個模組一個類檔案給出多個API介面 ,內部每個API搭上合適的配置物件, 這樣一來只需要十幾個類檔案.

總之, 考慮到配置物件既可以實現單個API單個類的設計, 也可以滿足同事的需求, 協議被換成了配置物件. 另外, 所有的block引數都不寫在配置物件裡, 而是直接在介面處宣告, 看著彆扭寫著方便(block做引數和做屬性哪個寫起來簡單大家都懂的).

2.簡單的請求結果快取器

上面簡單提到了請求快取, 其實我們是沒有做快取的, 因為我司HTTP的API現在基本上都被廢棄了, 全是走TCP, 然而TCP的快取又是另一個故事了.但是還是簡單實現一下吧:

#define HHCacheManager [HHNetworkCacheManager sharedManager]

@interface HHNetworkCache : NSObject

+ (instancetype)cacheWithData:(id)data;
+ (instancetype)cacheWithData:(id)data validTimeInterval:(NSUInteger)interterval;

- (id)data;
- (BOOL)isValid;

@end

@interface HHNetworkCacheManager : NSObject

+ (instancetype)sharedManager;

- (void)removeObejectForKey:(id)key;
- (void)setObjcet:(HHNetworkCache *)object forKey:(id)key;
- (HHNetworkCache *)objcetForKey:(id)key;

@end
複製程式碼
#define ValidTimeInterval 60

@implementation HHNetworkCache

+ (instancetype)cacheWithData:(id)data {
    return [self cacheWithData:data validTimeInterval:ValidTimeInterval];
}

+ (instancetype)cacheWithData:(id)data validTimeInterval:(NSUInteger)interterval {
    
    HHNetworkCache *cache = [HHNetworkCache new];
    cache.data = data;
    cache.cacheTime = [[NSDate date] timeIntervalSince1970];
    cache.validTimeInterval = interterval > 0 ? interterval : ValidTimeInterval;
    return cache;
}

- (BOOL)isValid {
    
    if (self.data) {
        return [[NSDate date] timeIntervalSince1970] - self.cacheTime < self.validTimeInterval;
    }
    return NO;
}

@end

#pragma mark - HHNetworkCacheManager

@interface HHNetworkCacheManager ()

@property (strong, nonatomic) NSCache *cache;

@end

@implementation HHNetworkCacheManager

+ (instancetype)sharedManager {
    static HHNetworkCacheManager *sharedManager;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        sharedManager = [[super allocWithZone:NULL] init];
        [sharedManager configuration];
    });
    return sharedManager;
}

+ (instancetype)allocWithZone:(struct _NSZone *)zone {
    return [self sharedManager];
}

- (void)configuration {
    
    self.cache = [NSCache new];
    self.cache.totalCostLimit = 1024 * 1024 * 20;
}

#pragma mark - Interface

- (void)setObjcet:(HHNetworkCache *)object forKey:(id)key {
    [self.cache setObject:object forKey:key];
}

- (void)removeObejectForKey:(id)key {
    [self.cache removeObjectForKey:key];
}

- (HHNetworkCache *)objcetForKey:(id)key {
    
    return [self.cache objectForKey:key];
}

@end
複製程式碼
- (NSNumber *)dispatchDataTaskWithConfiguration:(HHDataAPIConfiguration *)config completionHandler:(HHNetworkTaskCompletionHander)completionHandler{
        
    NSString *cacheKey;
    if (config.cacheValidTimeInterval > 0) {
        
        NSMutableString *mString = [NSMutableString stringWithString:config.urlPath];
        NSMutableArray *requestParameterKeys = [config.requestParameters.allKeys mutableCopy];
        if (requestParameterKeys.count > 1) {
            [requestParameterKeys sortedArrayUsingComparator:^NSComparisonResult(NSString * _Nonnull obj1, NSString * _Nonnull obj2) {
                return [obj1 compare:obj2];
            }];
        }
        [requestParameterKeys enumerateObjectsUsingBlock:^(NSString *  _Nonnull key, NSUInteger idx, BOOL * _Nonnull stop) {
            [mString appendFormat:@"&%@=%@",key, config.requestParameters[key]];
        }];
        cacheKey = [self md5WithString:[mString copy]];
        HHNetworkCache *cache = [HHCacheManager objcetForKey:cacheKey];
        if (!cache.isValid) {
            [HHCacheManager removeObejectForKey:cacheKey];
        } else {
            
            completionHandler ? completionHandler(nil, cache.data) : nil;
            return @-1;
        }
    }
    
    NSMutableArray *taskIdentifier = [NSMutableArray arrayWithObject:@-1];
    taskIdentifier[0] = [[HHNetworkClient sharedInstance] dispatchTaskWithUrlPath:config.urlPath useHttps:config.useHttps requestType:config.requestType params:config.requestParameters header:config.requestHeader completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
        
        if (!error && config.cacheValidTimeInterval > 0) {
            
            HHNetworkCache *cache = [HHNetworkCache cacheWithData:responseObject validTimeInterval:config.cacheValidTimeInterval];
            [HHCacheManager setObjcet:cache forKey:cacheKey];
        }
        
        [self.loadingTaskIdentifies removeObject:taskIdentifier.firstObject];
        completionHandler ? completionHandler([self formatError:error], responseObject) : nil;
    }];
    [self.loadingTaskIdentifies addObject:taskIdentifier.firstObject];
    return taskIdentifier.firstObject;
複製程式碼

簡單定義一個HHCache物件, 存放快取資料, 快取時間, 快取時效, 然後HHNetworkCacheManager單例物件內部用NSCache儲存快取物件, 因為NSCache自帶執行緒安全特效, 連鎖都不用.

在任務發起之前我們檢查一下是否有可用快取, 有可用快取直接返回, 沒有就走網路, 網路任務成功後存一下請求資料即可.

3.請求結果的格式化

網路任務完成後帶回的資料以什麼樣的形式返回給呼叫方, 分兩種情況: 任務成功和任務失敗.這裡我們定義一下任務成功和失敗, 成功表示網路請求成功且帶回了可用資料, 失敗表示未獲取到可用資料. 舉個例子: 獲取一個話題列表, 使用者希望看到的看到是一排排彩色頭像, 如果你呼叫API拿不到這一堆資料那對於使用者來說就是失敗的. 那麼沒拿到資料可能是網路出錯了, 或者網路沒有問題只是使用者沒有關注過任何話題, 那麼相應的展示網路錯誤提示或者推薦話題提示.

任務成功的話很簡單, 直接做相應JSON解析正常返回就行, 如果某個XXXAPI有特殊需求那就新加一個XXXAPIConfig繼承APIConfig基類, 在裡面新增屬性或者方法描述一下你有什麼特殊需求, XXXAPI負責格式好返回就行了(所以還是一個API一個類好, 乾淨).

任務失敗的話就麻煩一點, 我希望任何API都能友好的返回錯誤提示, 具體的, 如果有錯誤發生了, 那麼返回給呼叫方的error.code一定是可讀的列舉而不是301之類的需要比對文件的錯誤碼(必須), error.domain通常就是錯誤提示語(可選), 這就要求程式設計師寫每個API時都定義好錯誤列舉(所以還是一個API一個類好, 乾淨)和相應的錯誤提示.大概是這樣子:

//HHNetworkTaskError.h 通用錯誤
typedef enum : NSUInteger {
    HHNetworkTaskErrorTimeOut = 101,
    HHNetworkTaskErrorCannotConnectedToInternet = 102,
    HHNetworkTaskErrorCanceled = 103,
    HHNetworkTaskErrorDefault = 104,
    HHNetworkTaskErrorNoData = 105,
    HHNetworkTaskErrorNoMoreData = 106
} HHNetworkTaskError;

static NSError *HHError(NSString *domain, int code) {
    return [NSError errorWithDomain:domain code:code userInfo:nil];
}

static NSString *HHNoDataErrorNotice = @"這裡什麼也沒有~";
static NSString *HHNetworkErrorNotice = @"當前網路差, 請檢查網路設定~";
static NSString *HHTimeoutErrorNotice = @"請求超時了~";
static NSString *HHDefaultErrorNotice = @"請求失敗了~";
static NSString *HHNoMoreDataErrorNotice = @"沒有更多了~";
複製程式碼
- (NSError *)formatError:(NSError *)error {
    
    if (error != nil) {
        switch (error.code) {
            case NSURLErrorCancelled: {
                error = HHError(HHDefaultErrorNotice, HHNetworkTaskErrorCanceled);
            }   break;
                
            case NSURLErrorTimedOut: {
                error = HHError(HHTimeoutErrorNotice, HHNetworkTaskErrorTimeOut);
            }   break;
                
            case NSURLErrorCannotFindHost:
            case NSURLErrorCannotConnectToHost:
            case NSURLErrorNotConnectedToInternet: {//應產品要求, 所有連不上伺服器都是使用者網路的問題
                error = HHError(HHNetworkErrorNotice, HHNetworkTaskErrorCannotConnectedToInternet);
            }   break;
                
            default: {
                error = HHError(HHNoDataErrorNotice, HHNetworkTaskErrorDefault);
            }   break;
        }
    }
    return error;
}
複製程式碼

通用的錯誤列舉和提示語定義在一個.h中, 以後有新增通用描述都在這裡新增, 便於管理. HHAPIManager基類會先格式好某些通用錯誤, 然後各個子類定義自己特有的錯誤列舉(不可和通用描述衝突)和錯誤描述, 像這樣:

//HHTopicAPIManager.h
typedef enum : NSUInteger {
    HHUserInfoTaskErrorNotExistUserId = 1001,//使用者不存在
    HHUserInfoTaskError1,//瞎寫的, 意思到就行
    HHUserInfoTaskError2
} HHUserInfoTaskError;

typedef enum : NSUInteger {
    HHUserFriendListTaskError0 = 1001,
    HHUserFriendListTaskError1,
    HHUserFriendListTaskError2,
} HHTopicListTaskError;
複製程式碼
//HHTopicAPIManager.m
- (NSNumber *)fetchUserInfoWithUserId:(NSUInteger)userId completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    HHDataAPIConfiguration *config = [HHDataAPIConfiguration new];
    config.urlPath = @"fetchUserInfoWithUserIdPath";
    config.requestParameters = nil;

    return [super dispatchDataTaskWithConfiguration:config completionHandler:^(NSError *error, id result) {
        
        if (!error) {//通用錯誤基類已經處理好, 做好自己的資料格式就行
            
            switch ([result[@"code"] integerValue]) {
                case 200: {
                    //                    請求資料無誤做相應解析
                    //                    result = [HHUser objectWithKeyValues:result[@"data"]];
                }   break;
                    
                case 301: {
                    error = HHError(@"使用者不存在", HHUserInfoTaskErrorNotExistUserId);
                }  break;
                    
                case 302: {
                    error = HHError(@"xxx錯誤", HHUserInfoTaskError1);
                }   break;
                    
                case 303: {
                    error = HHError(@"yyy錯誤", HHUserInfoTaskError2);
                }   break;
                default:break;
            }
        }
        completionHandler ? completionHandler(error, result) : nil;
    }];
}

複製程式碼

然後呼叫方一般情況下只需要這樣:

[[HHTopicAPIManager new] fetchUserInfoWithUserId:123 completionHandler:^(NSError *error, id result) {
       error ? [self showToastWithText:error.domain] : [self reloadTableViewWithNames:result];
    }];
複製程式碼

當然, 情況複雜的話只能這樣, 程式碼多一點, 但是有列舉讀起來也不麻煩:

[[HHTopicAPIManager new] fetchUserInfoWithUserId:123 completionHandler:^(NSError *error, id result) {
        error ? [self showErrorViewWithError:error] : [self reloadTableViewWithNames:result];
    }];

- (void)showErrorViewWithError:(NSError *)error {

    switch (error.code) {//如果情況複雜就自己switch
                case HHNetworkTaskErrorTimeOut: {
                    //                    展示請求超時錯誤頁面
                }   break;
                case HHNetworkTaskErrorCannotConnectedToInternet: {
                    //                    展示網路錯誤頁面
                }
                case HHUserInfoTaskErrorNotExistUserId: {
                    //                    ...
                }
                    //                    ...
                default:break;
            }
}
複製程式碼

這裡多扯兩句, 請求的回撥我是以(error, id)的形式返回的, 而不是像AFN那樣分別給出successBlock和failBlock. 其實我本身是很支援AFN的做法的, 區分成功和錯誤強行讓兩種業務的程式碼出現在兩個不同的部分, 這很好, 不同的業務處理就該在不同函式/方法裡面.

但是實際開發中有很多成功和失敗都會執行的操作, 典型的例子就是HUD, 兩個block的話我需要在兩個地方都加上[HUD hide], 這樣的程式碼寫的多了就會很煩, 而我又懶, 所以就成功失敗都在一個回撥返回了.

但是! 你也應該區分不同的業務寫出兩個不同方法(像上面那樣做), 至於公用的部分就只寫一次就夠了.像這樣:

[hud show:YES];
[[HHTopicAPIManager new] fetchUserInfoWithUserId:123 completionHandler:^(NSError *error, id result) {
      [hud hide:YES];
       error ? [self showToastWithText:error.domain] : [self reloadTableViewWithNames:result];
    }];
複製程式碼

再說一句, 即使你比我還懶, 不宣告兩個方法那也應該將較短的邏輯寫在前面, 較長的寫在後面, 易讀, 像這樣:

if (!error) {
            ...短
            ...短
        } else {
            
            switch (error.code) {//如果情況複雜就自己switch
                case HHNetworkTaskErrorTimeOut: {
                    //                    展示請求超時錯誤頁面
                }   break;
                case HHNetworkTaskErrorCannotConnectedToInternet: {
                    //                    展示網路錯誤頁面
                }
                case HHUserInfoTaskErrorNotExistUserId: {
                    //                    ...長
                }
                    //                    ...長
                default:break;
            }
        }
    }
複製程式碼

4.兩個小玩意兒

文章到這基本上這個網路層該說的都說的差不多了, 各位可以根據自己的需求改動改動就能用了, 最後簡單介紹下兩個和它相關的小玩意兒就結尾吧:

  • HHNetworkTaskGroup
@protocol HHNetworkTask <NSObject>

- (void)cancel;
- (void)resume;

@end

@interface HHNetworkTaskGroup : NSObject

- (void)addTaskWithMessgeType:(NSInteger)type message:(id)message completionHandler:(HHNetworkTaskCompletionHander)completionHandler;
- (void)addTask:(id<HHNetworkTask>)task;

- (void)cancel;
- (void)dispatchWithNotifHandler:(void(^)(void))notifHandler;

@end
複製程式碼
@interface HHNetworkTaskGroup ()

@property (copy, nonatomic) void(^notifHandler)(void);
@property (assign, nonatomic) NSInteger signal;
@property (strong, nonatomic) NSMutableSet *tasks;
@property (strong, nonatomic) dispatch_semaphore_t lock;

@property (strong, nonatomic) id keeper;

@end

@implementation HHNetworkTaskGroup

//- (void)addTaskWithMessgeType:(HHSocketMessageType)type message:(PBGeneratedMessage *)message completionHandler:(HHNetworkCompletionHandler)completionHandler {
//    
//    HHSocketTask *task = [[HHSocketManager sharedManager] taskWithMessgeType:type message:message completionHandler:completionHandler];
//    [self addTask:task];
//}

- (void)addTask:(id<HHNetworkTask>)task {
    
    if ([task respondsToSelector:@selector(cancel)] &&
        [task respondsToSelector:@selector(resume)] &&
        ![self.tasks containsObject:task]) {
        
        [self.tasks addObject:task];
        [(id)task addObserver:self forKeyPath:NSStringFromSelector(@selector(state)) options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:nil];
    }
}

- (void)dispatchWithNotifHandler:(void (^)(void))notifHandler {
    
    if (self.tasks.count == 0) {
        
        dispatch_async(dispatch_get_main_queue(), ^{
            notifHandler ? notifHandler() : nil;
        });
        return;
    }
    
    self.lock = dispatch_semaphore_create(1);
    self.keeper = self;
    self.signal = self.tasks.count;
    self.notifHandler = notifHandler;
    for (id<HHNetworkTask> task in self.tasks.allObjects) {
        [task resume];
    }
}

- (void)cancel {
    
    for (id<HHNetworkTask> task in self.tasks.allObjects) {
        
        if ([(id)task state] < NSURLSessionTaskStateCanceling) {
            
            [(id)task removeObserver:self forKeyPath:NSStringFromSelector(@selector(state))];
            [task cancel];
        }
    }
    [self.tasks removeAllObjects];
    self.keeper = nil;
}

#pragma mark - KVO

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:NSStringFromSelector(@selector(state))]) {
        
        NSURLSessionTaskState oldState = [change[NSKeyValueChangeOldKey] integerValue];
        NSURLSessionTaskState newState = [change[NSKeyValueChangeNewKey] integerValue];
        if (oldState != newState && newState >= NSURLSessionTaskStateCanceling) {
            [object removeObserver:self forKeyPath:NSStringFromSelector(@selector(state))];
            
            dispatch_semaphore_wait(self.lock, DISPATCH_TIME_FOREVER);
            self.signal--;
            dispatch_semaphore_signal(self.lock);

            if (self.signal == 0) {
                
                dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                    
                    self.notifHandler ? self.notifHandler() : nil;
                    [self.tasks removeAllObjects];
                    self.keeper = nil;
                });
            }
        }
    }
}

#pragma mark - Getter

- (NSMutableSet *)tasks {
    if (!_tasks) {
        _tasks = [NSMutableSet set];
    }
    return _tasks;
}

@end
複製程式碼

看名字應該就知道這個是和dispatch_group_notif差不多的東西, 不過是派發的物件不是dispatch_block_t而是id. 程式碼很簡單, 說說思路就行了.

  • keeper 系統大部分帶有Block的API都有一個特性就是隻需要生成不需要持有, 也不用擔心Block持有我們的物件而造成迴圈引用, 例如:dispatch_async, dataTaskWithURL:completionHandler:等等, 其實具體的實現就是先迴圈引用再破除迴圈引用, 比如dispatch_async的queue和block會迴圈引用, 這樣在block執行期間雙方都不會釋放, 然後等到block執行完成後再將queue.block置nil破除迴圈引用, block沒了, 那它捕獲的queue和其他物件計數都能-1,也就都能正常釋放了.程式碼裡面的keeper就是來製造這個迴圈引用的.

  • signal和tasks signal其實就是tasks.count, 為什麼我們不直接在task完成後直接tasks.remove然後判斷tasks.count == 0而是要間接給一個signal來做這事兒? 原因很簡單: forin過程中是不能改變容器物件的. 當我們forin派發task的時候, task是非同步執行的, 有可能在task執行完成觸發KVO的時候我們的forin還在遍歷, 此時直接remove就會crash. 如果不用forin, 而是用while或者for(;;)就會漏發. 所以就宣告一個signal來做計數了. 另外addObserve和removeObserve必須成對出現, 控制好就行.

  • dispatch_after 在所有任務執行完成後並沒有馬上執行notif(), 而是等待0.1秒以後再執行notif(), 這是因為task.state的設定會在task.completionHandler之前執行, 所以我們需要等一下, 確認completionHandler執行後在走我們的notif().

  • 如何使用

    HHNetworkTaskGroup *group = [HHNetworkTaskGroup new];
    HHTopicAPIManager *manager = [HHTopicAPIManager new];
    for (int i = 1; i < 6; i++) {
        
        NSURLSessionDataTask *task = [manager topicListDataTaskWithPage:i pageSize:20 completionHandler:^(NSError *error, id result) {
            //...completionHandler... i
        }];
        
        [group addTask:(id)task];
    }
    [group dispatchWithNotifHandler:^{
        //notifHandler
    }];
複製程式碼

強調一下, 絕對不應該直接呼叫HHNetworkClient或者HHAPIManger的dataTaskxxx...這些通用介面來生成task, 應該在該task所屬的API暴露介面生成task, 簡單說就是不要跨層訪問. 每個API的引數甚至簽名規則都是不一樣的, API的呼叫方應該只提供生成task的相應引數而不應該也不需要知道這些引數具體的拼裝邏輯.

  • HHNetworkAPIRecorder
@interface HHNetworkAPIRecorder : NSObject

@property (strong, nonatomic) id rawValue;
@property (assign, nonatomic) int pageSize;
@property (assign, nonatomic) int currentPage;
@property (assign, nonatomic) NSInteger itemsCount;
@property (assign, nonatomic) NSInteger lastRequestTime;

- (void)reset;
- (BOOL)hasMoreData;
- (NSInteger)maxPage;
@end
複製程式碼

日常請求中有很多介面涉及到分頁, 然而毫無疑問分頁的邏輯在每個頁面都是一模一樣的, 但是卻需要每個呼叫頁面都保持一下currentPage然後呼叫邏輯都寫一次, 其實直接在API內部實現一下分頁的邏輯, 然後對外暴露第一頁和下一頁的介面就不用宣告currentPage和重複這些無聊的邏輯了. 像這樣:

//XXXAPI.h
- (NSNumber *)refreshTopicListWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler;//第一頁
- (NSNumber *)loadmoreTopicListWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler;//當前頁的下一頁
- (NSNumber *)fetchTopicListWithPage:(NSInteger)page completionHandler:(HHNetworkTaskCompletionHander)completionHandler;//指定頁(一般外部用不到, 看情況暴露)
複製程式碼
//XXXAPI.m
- (NSNumber *)refreshTopicListWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    [self.topicListAPIRecorder reset];
    return [self fetchTopicListWithPage:self.topicListAPIRecorder.currentPage completionHandler:completionHandler];
}

- (NSNumber *)loadmoreTopicListWithCompletionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    self.topicListAPIRecorder.currentPage++;
    return [self fetchTopicListWithPage:self.topicListAPIRecorder.currentPage completionHandler:completionHandler];
}
複製程式碼
//SomeViewController
self.topicAPIManager = [HHTopicAPIManager new];
...
self.tableView.header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{//下拉重新整理
        [weakSelf.topicAPIManager refreshTopicListWithCompletionHandler:^(NSError *error, id result) {
                ...
        }];
    }];
self.tableView.footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{//上拉載入
        [weakSelf.topicAPIManager loadmoreTopicListWithCompletionHandler:^(NSError *error, id result) {
                ...
        }];
    }];
複製程式碼

總結

HHURLRequestGenerator: 網路請求的生成器, 公用的請求頭, cookie都在此設定.

HHNetworkClient: 網路請求的派發器, 這裡會記錄每一個服役中的請求, 並在必要的時候切換伺服器.

HHAPIManager: 網路請求派發器的呼叫者, 這裡對請求的結果做相應的資料格式化後返回給API呼叫方, 提供請求模組的擴充性支援, 並提供合理的Task供TaskGroup派發.

本文附帶的demo地址

一步一步構建你的iOS網路層 - TCP篇

相關文章