這一次咱們將要討論的是移動開發中比較重要的一環--網絡請求的封裝.鑑於我的經驗有限,本文將在必定程度上參考 基於AFNetworking2.0和ReactiveCocoa2.1的iOS REST Client,來以LeanCloud的Rest Api來練手.前兩節的示例,咱們都是使用自定義的PHP接口來做爲測試服務器,可是真實的服務器接口是涉及到許多細節的,好比一個基本的權限控制機制,用戶登陸登出等.爲了能更真實快速的開始網絡請求類的重構,本節選取一個國內較爲經常使用的後端開發平臺LeanCloud. 本文將實現一個擁有真實數據的博客App的Demo,數據源取自博客主站:ios122.com.html
完整代碼示例下載: githubios
首先,你是確定要先去它們官網註冊一個帳號,而後添加一個應用.這是我是添加了應用iOS122.而後新建一個名爲Post的Class,字段信息以下:git
iOS122是一個wordpress搭建的博客站點,導出的文章爲xml格式,須要處理成 LeanCloud 須要的JSON格式才能導入,主站文章很少,幾十篇,一個一個手動輸,也是能夠的.我將試着寫一小段代碼,來自動解析wp導出的文件,並根據須要生成對應的 JSON 文件.感興趣的,能夠本身試着弄下!github
這是原始的從wp中導出的主站的全部文章: http://ios122.bj.bcebos.com/Post.xml.web
這是經過iOS代碼解析處理後,生成的可直接導入進LeanCloud的JSON文件. http://ios122.bj.bcebos.com/Post.jsonjson
這是XML轉JSON核心代碼,完整代碼見文首github連接,XML解析用了一個第三方庫Ono:後端
/* 要實現的邏輯很簡單: 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];
導入後,LeanCloud控制檯顯示是這樣的:api
接下來的文字,思路上將在很大程度上參考 @limboy的文章,可是會相對更加完整.另外,其實 LeanCloud 實際上是有本身的iOS API的,可是是一個抽象的封裝,和實際應用中使用的網絡請求API有很大不一樣.兩種方式的差異,有點相似因而使用 字典等基本類型存儲數據,仍是使用 自定義的Model來存儲數據.兩種方式,不過多置評,我的傾向於後一種,方便後續的代碼重構.緩存
// TODO:Models Group包含了全部跟服務端API對應的Model,好比HBPCommentruby
使用時,直接引用 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) //.... @end
Models 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,也就是要實現<NSCoding>和<NSCopying>協議,這兩個協議的內容其實就是對Object的Property作些處理,因此若是能夠在基類裏把這些事都統一處理,就會方便許多。考慮到設計的穩定性和後期的可擴展性,咱們使用比較著名的第三方庫--Mantle 來處理.你可使用CocoaPods安裝這個庫,而後引入頭文件 #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的分析,僅供你們做爲技術實現上的一個參考.具體到本身的業務細節,可能有些地方,須要特殊處理.關於以上技術討論的問題,歡迎跟帖討論!
下一篇主題,會對單元測試的一些細節作一分析.邊摸索邊學習,總算真到了一個合適的重構咱們已有工程的策略了.重構量不小,最核心的一點是必須保證原有的代碼不受影響.也就是說,接下來兩週我要邊寫單元測試用例,邊重構代碼.期間遇到的關於測試的問題與坑,會及時記錄下來,彙總交流.