
這一次我們將要討論的是移動開發中比較重要的一環--網絡請求的封裝.鑒于個人經驗有限,本文將在一定程度上參考 基于AFNetworking2.0和ReactiveCocoa2.1的iOS REST Client,來以LeanCloud的Rest Api來練手.前兩節的示例,我們都是使用自定義的php接口來作為測試服務器,但是真實的服務器接口是涉及到許多細節的,比如一個基本的權限控制機制,用戶登錄登出等.為了能更真實快速的開始網絡請求類的重構,本節選取一個國內較為常用的后端開發平臺LeanCloud. 本文將實現一個擁有真實數據的博客App的Demo,數據源取自博客主站:ios122.com.
完整代碼示例下載: github
首先,你是肯定要先去它們官網注冊一個賬號,然后添加一個應用.這是我是添加了應用iOS122.然后新建一個名為Post的Class,字段信息如下:

iOS122是一個WordPRess搭建的博客站點,導出的文章為xml格式,需要處理成 LeanCloud 需要的JSON格式才能導入,主站文章不多,幾十篇,一個一個手動輸,也是可以的.我將試著寫一小段代碼,來自動解析wp導出的文件,并根據需要生成對應的 JSON 文件.感興趣的,可以自己試著弄下!
/* 要實現的邏輯很簡單:  1.讀取XML文件; 2.解析為JSON,并顯示; 3.將JSON輸出為json文件.*/    /* 1.讀取并解析XML. */NSMutableArray * jsonArray = [NSMutableArray arrayWithCapacity: 42];    NSString *XMLFilePath = [[NSBundle mainBundle] pathForResource:@"Post" ofType:@"xml"];ONOXMLDocument *document = [ONOXMLDocument XMLDocumentWithData:[NSData dataWithContentsOfFile:XMLFilePath] error: NULL];    NSString *XPath = @"//channel/item";    [document enumerateElementsWithXPath:XPath usingBlock:^(ONOXMLElement *element, __unused NSUInteger idx, __unused BOOL *stop) {    ONOXMLElement * titleElement = [element firstChildWithTag:@"title"];    ONOXMLElement * descElement = [element firstChildWithTag: @"encoded" inNamespace: @"excerpt"];    ONOXMLElement * contentElement = [element firstChildWithTag: @"encoded" inNamespace:@"content"];        NSDictionary * jsonDict = @{                                @"title": [titleElement stringValue],                                @"desc": [descElement stringValue],                                @"body": [contentElement stringValue]};        [jsonArray addObject: jsonDict];}];    /* 2.顯示JSON字符串. */NSData * jsonData = [NSJSONSerialization dataWithJSONObject:jsonArray                                                   options:NSJSONWritingPrettyPrinted                                                     error:NULL];NSString * jsonString = [[NSString alloc] initWithData:jsonData                                             encoding:NSUTF8StringEncoding];    self.textView.text = jsonString;    /*3.存儲到文件中. 真機下,暫無法找到Documents目錄下的東西,可以通過模擬器運行此段代碼,并通過finder-->前往文件夾,輸入此處jsonPath對應的文件路徑來獲取 Post.json 文件. */NSArray *paths=NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);NSString * path=[paths objectAtIndex:0];NSString * jsonPath=[path stringByAppendingPathComponent:@"Post.json"];[jsonData writeToFile: jsonPath atomically:YES];
接下來的文字,思路上將在很大程度上參考 @limboy的文章,但是會相對更加完整.另外,其實 LeanCloud 其實是有自己的iOS API的,但是是一個抽象的封裝,和實際應用中使用的網絡請求API有很大不同.兩種方式的差別,有點類似于是使用 字典等基本類型存儲數據,還是使用 自定義的Model來存儲數據.兩種方式,不過多置評,個人傾向于后一種,方便后續的代碼重構.
// TODO:Models Group包含了所有跟服務端API對應的Model,比如HBPComment
使用時,直接引用 YFAPI.h 即可,里面包含了所有的Class:
|- YFAPI.h|- Classes    |- YFAPIManager.h    |- YFAPIManager.m    |- Models        |- YFPostModel.h        |- YFPostModel.h           ...YFAPIManager包含了所有的跟服務端通信的方法,通過Category來區分:
////  YFAPIManager.h//  iOS122////  Created by 顏風 on 15/10/28.//  Copyright ? 2015年 iOS122. All rights reserved.//#import <Foundation/Foundation.h>#import <AFNetworking.h>@class RACSignal, YFUserModel;@interface YFAPIManager : AFHTTPRequestOperationManager@property (nonatomic, nonatomic) YFUserModel * user; //!< 當前登錄的用戶,可能為nil./** *  一個單例. * *  @return 共享的實例對象. */+ (instancetype) sharedInstance;@end/** *  私有擴展,其他網路請求的基礎. */@interface YFAPIManager (Private)/** *  內部統一使用這個方法來向服務端發送請求 * *  @param method       請求方式. *  @param relativePath 相對路徑. *  @param parameters   參數. *  @param resultClass  從服務端獲取到JSON數據后,使用哪個Class來將JSON轉換為OC的Model. * *  @return RACSignal 信號對象. */- (RACSignal *)requestWithMethod:(NSString *)method relativePath:(NSString *)relativePath parameters:(NSDictionary *)parameters resultClass:(Class)resultClass;@end/** *  用戶信息相關的操作. */@interface YFAPIManager (User)/** *  用戶登錄. * *  獲取到用戶數據后,會自動更新User屬性,所以僅需要在必要的地方觀察user屬性即可. * *  @param username 用戶名. *  @param password 用戶密碼. * *  @return RACSingal對象,sendNext的是此類的的單例實例. */- (RACSignal *)signInUsingUsername:(NSString *)username passowrd:(NSString *)password;/** *  登出. * *  登出,其實就是把 user 屬性設為nil. * *  @return sendNext為此類的單例實例. */- (RACSignal *) logout;@end/** *  文章相關操作. */@interface YFAPIManager (Post)//....@endModels Group包含了所有跟服務端API對應的Model,比如 YFPostModel:
////  YFPostModel.h//  iOS122////  Created by 顏風 on 15/10/28.//  Copyright ? 2015年 iOS122. All rights reserved.//#import <Foundation/Foundation.h>#import <Mantle.h>/** *  文章. */@interface YFPostModel : MTLModel <MTLJSONSerializing>@property (strong, nonatomic) NSString * postId; //!< 文章唯一標識.@property (copy, nonatomic) NSString * title; //!< 文章標題.@property (copy, nonatomic) NSString * desc; //!< 文章簡介.@property (copy, nonatomic) NSString * body; //!< 文章詳情.@end////  YFPostModel.m//  iOS122////  Created by 顏風 on 15/10/28.//  Copyright ? 2015年 iOS122. All rights reserved.//#import "YFPostModel.h"@implementation YFPostModel/** *  用于指定模型屬性與JSON數據字段的對應關系. * *  @return 模型屬性與JSON數據字段的對應關系:以模型屬性為鍵,JSON字段為值. */+ (NSDictionary *)JSONKeyPathsByPropertyKey {    NSDictionary * dictMap = @{                               @"postId": @"objectId",                               @"title": @"title",                               @"desc": @"desc",                               @"body": @"body"                               };        return dictMap;}@end可以使用類似下面的語句,來將JSON轉換為Model:
YFPostModel * model = [MTLJSONAdapter modelOfClass:[YFPostModel class] fromJSONDictionary:@{@"title": @"標題", @"desc": @"簡介", @"body": @"內容", @"objectId": @"id"} error: NULL];每一個Model都要支持Archive / UnArchive / Copy,也就是要實現#import <Mantle.h> 到自定義的Model中即可.
pod 'Mantle' # JSON <==> Model先來說說登錄,由于使用RAC,在構造API時,就不需要傳入Block了,隨之而來的一個問題就是需要在注釋中說明sendNext時會發送什么內容.LeanCloud用戶登錄接口會返回完整的用戶信息:
+ (RACSignal *)signInUsingUsername:(NSString *)username passowrd:(NSString *)password{    NSDictionary *parameters = @{                                 @"username": username,                                 @"password": password,                                 };        YFAPIManager *manager = [self sharedInstance];    // 需要配對使用@weakify 與 @strongify 宏,以防止block內的可能的循環引用問題.    @weakify(manager);        return [[[[manager rac_GET:@"login" parameters:parameters]               // reduceEach的作用是傳入多個參數,返回單個參數,是基于`map`的一種實現               reduceEach:^id(NSDictionary *response, AFHTTPRequestOperation *operation){                   @strongify(manager);                                      YFUserModel * user = [MTLJSONAdapter modelOfClass:[YFUserModel class] fromJSONDictionary: response error: NULL];                                      manager.user = user;                                      return manager;               }]              // 避免side effect,有點類似于 "懶加載".              replayLazily]            setNameWithFormat:@"+signInUsingUsername:%@ password:%@", username, password];}用戶的登出就簡單了,直接設置user為nil就行了:
+ (RACSignal *)logout{    YFAPIManager * manager = [YFAPIManager sharedInstance];    @weakify(manager);        return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {        @strongify(manager);                manager.user = nil;                        [subscriber sendNext: manager];                [subscriber sendCompleted];                return nil;    }];}"花瓣"采取的是重新定義 AFHTTPRequestSerializer 子類的方式,但其實用AOP,幾行代碼就夠了:
// 設置超時和緩存策略.[self.requestSerializer aspect_hookSelector:@selector(requestWithMethod:                                                      URLString:                                                      parameters:                                                      error:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo>info){    /* 在方法調用后,來獲取返回值,然后更改其屬性. */    // __autoreleasing 關鍵字是必須的,默認的 __strong,會引起后續代碼的野指針崩潰.    __autoreleasing NSMutableURLRequest *  request = nil;        NSInvocation *invocation = info.originalInvocation;    [invocation getReturnValue: &request];        if (nil != request) {        request.timeoutInterval = 30;        request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;                [invocation setReturnValue: &request];    }}error: NULL];使用了一個AOP庫,感興趣的戳這里: Aspects.
這個比較簡單些,直接在方法里面加上判斷屬性self.isAuthenticated 即可:
if (!self.isAuthenticated){  ....}其中 isAuthenticated 為基于self.user的推導屬性,其實現如下:
RAC(self, isAuthenticated) = [RACSignal combineLatest:@[RACObserve(self, user)] reduce:^id{    @strongify(self);        BOOL isLogin = YES;        if (nil == self.user || nil == self.user.token) {        isLogin = NO;    }        return [NSNumber numberWithBool: isLogin];}];這里我們要實現訪問某個具體的博客數據,以驗證上述各種基礎構件的可用性.為了使示例更具有典型性,我手動將博客數據設為僅指定測試用戶(測試用戶可以在LeanCloud后臺添加和指定)可以訪問:
需要先實現- (RACSignal *)requestWithMethod:(YFAPIManagerMethod)method relativePath:(NSString *)relativePath parameters:(NSDictionary *)parameters resultClass:(Class)resultClass;方法,這是所有網絡訪問的基礎,如下:
/** *  內部統一使用這個方法來向服務端發送請求 * *  @param method       請求方式. *  @param relativePath 相對路徑. *  @param parameters   參數. *  @param resultClass  從服務端獲取到JSON數據后,使用哪個Class來將JSON轉換為OC的Model. * *  @return RACSignal 信號對象.sendNext返回的是轉換后的Model. */- (RACSignal *)requestWithMethod:(YFAPIManagerMethod)method relativePath:(NSString *)relativePath parameters:(NSDictionary *)parameters resultClass:(Class)resultClass{    RACSignal * signal = nil;        if (method == YFAPIManagerMethodGet) {        signal = [self rac_GET:relativePath parameters:parameters];    }        if (method == YFAPIManagerMethodPut) {        signal = [self rac_PUT:relativePath parameters:parameters];    }        if (method == YFAPIManagerMethodPost) {        signal = [self rac_POST:relativePath parameters:parameters];    }        if (method == YFAPIManagerMethodPatch) {        signal = [self rac_PATCH:relativePath parameters:parameters];    }        if (method == YFAPIManagerMethodDelete) {        signal = [self rac_DELETE:relativePath parameters:parameters];    }        return [[signal reduceEach:^id(NSDictionary *response){        id responseModel = [MTLJSONAdapter modelOfClass:resultClass fromJSONDictionary:response error:NULL];                return responseModel;    }]replayLazily];}然后添加一個用戶博客詳情訪問的方法即可:
/** *  獲取文章詳情. * *  @param postId 文章id. * *  @return sendNext為獲取到的文章數據模型. */- (RACSignal *)fetchPostDetail:(NSString *)postId{    return [[self requestWithMethod:YFAPIManagerMethodGet relativePath:[NSString stringWithFormat:@"classes/Post/%@", postId] parameters:nil resultClass: [YFPostModel class]] setNameWithFormat: @"%@ -fetchPostDetail: %@", self.class, postId];}然后你就可以用類似下面的代碼訪問博客詳情了:
[[[YFAPIManager sharedInstance] fetchPostDetail: @"56308138e4b0feb4c8ba2a34"] subscribeNext:^(YFPostModel * x) {    NSLog(@"%@", x.body);        [self.webView loaDHTMLString:x.body baseURL:nil];}];LeanClodu Rest API 需要在本地對masterKey在本地做一次md5加密,我封裝了一個方法,可以直接用:
/** *  將字符串md5加密,并返回加密后的結果. * *  @param originalStr 原始字符串. *  @param lower       是否返回小寫形式: YES,返回全小寫形式;NO,返回全大寫形式. * *  @return md5 加密后的結果. */- (NSString *) md5Str: (NSString *) originalStr isLower: (BOOL) lower{    const char *original = [originalStr UTF8String];    unsigned char result[CC_MD5_DIGEST_LENGTH];    CC_MD5(original, (CC_LONG)strlen(original), result);    NSMutableString *hash = [NSMutableString string];    for (int i = 0; i < 16; i++)    {        [hash appendFormat:@"%02X", result[i]];    }        NSString * md5Result = [hash lowercaseString];        if (NO == lower) {        md5Result = [md5Result uppercaseString];    }        return md5Result;}因為LeanCloud的請求簽權和時間戳有掛,所以每次請求都需要重置部分請求頭,此處可以每個請求都手動設置,但是我是使用AOP,直接hook了一下(PS:強烈建議不知道AOP為何物的童鞋,學習下,真的很爽用起來):
// 每次發送請求前,都需要更新一下 請求頭中的 apiClientSecret,因為它是時間戳相關的.[self aspect_hookSelector:NSSelectorFromString(@"rac_requestPath:parameters:method:") withOptions:AspectPositionBefore usingBlock:^{    @strongify(self);        [self.requestSerializer setValue: self.apiClientSecret forHTTPHeaderField: @"X-LC-Sign"];    } error:NULL];這個其實算是RAC的基礎,讓token和user的變化綁定起來就行了,如果你想重寫user的setter方法,然后出發請求頭中token的變化,也是可以的(但我更喜歡RAC的寫法了):
// 每次用戶數據更新時,都需要重新設置下請求頭中的token值.[RACObserve(self, user) subscribeNext:^(YFUserModel * user) {    @strongify(self);        [self.requestSerializer setValue:user.token forHTTPHeaderField: @"X-LC-session"];}];所謂"推導屬性",就是那些附屬的,是依據其他屬性推斷出來的屬性,本身應該隨著核心屬性的變化而自動變化.實現方式有很多,可以重寫此屬性的getter方法,也可以像下面這樣:
// 設置isAuthenticated.RAC(self, isAuthenticated) = [RACSignal combineLatest:@[RACObserve(self, user)] reduce:^id{    @strongify(self);        BOOL isLogin = YES;        if (nil == self.user || nil == self.user.token) {        isLogin = NO;    }        return [NSNumber numberWithBool: isLogin];}];因為我們的服務器,是傳統的PHP服務器,所以本文對LeanCloud的分析,僅供大家作為技術實現上的一個參考.具體到自己的業務細節,可能有些地方,需要特殊處理.關于以上技術討論的問題,歡迎跟帖討論!
下一篇主題,會對單元測試的一些細節做一分析.邊摸索邊學習,總算真到了一個合適的重構我們已有工程的策略了.重構量不小,最核心的一點是必須保證原有的代碼不受影響.也就是說,接下來兩周我要邊寫單元測試用例,邊重構代碼.期間遇到的關于測試的問題與坑,會及時記錄下來,匯總交流.
新聞熱點
疑難解答