這一次我們將要討論的是移動開發中比較重要的一環--網絡請求的封裝.鑒于個人經驗有限,本文將在一定程度上參考 基于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)//....@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,也就是要實現#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的分析,僅供大家作為技術實現上的一個參考.具體到自己的業務細節,可能有些地方,需要特殊處理.關于以上技術討論的問題,歡迎跟帖討論!
下一篇主題,會對單元測試的一些細節做一分析.邊摸索邊學習,總算真到了一個合適的重構我們已有工程的策略了.重構量不小,最核心的一點是必須保證原有的代碼不受影響.也就是說,接下來兩周我要邊寫單元測試用例,邊重構代碼.期間遇到的關于測試的問題與坑,會及時記錄下來,匯總交流.
新聞熱點
疑難解答