这一次咱们将要讨论的是移动开发中比较重要的一环--网络请求的封装.鉴于我的经验有限,本文将在必定程度上参考 基于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的分析,仅供你们做为技术实现上的一个参考.具体到本身的业务细节,可能有些地方,须要特殊处理.关于以上技术讨论的问题,欢迎跟帖讨论!
下一篇主题,会对单元测试的一些细节作一分析.边摸索边学习,总算真到了一个合适的重构咱们已有工程的策略了.重构量不小,最核心的一点是必须保证原有的代码不受影响.也就是说,接下来两周我要边写单元测试用例,边重构代码.期间遇到的关于测试的问题与坑,会及时记录下来,汇总交流.