ReactiveCocoa 详解
http://www.jianshu.com/p/73f9d719cee4 Designer News.png 前段时间在design+code购买了一个学习iOS设计和编码在线课程,使用Sketch设计App,然后使用Swift语言实现Designer News客户端。作者Meng To已经开源到Github:MengTo/DesignerNewsApp · GitHub。虽然实现整个Designer News客户端基本功能,但是采用臃肿MVC(Model-View-Controller)架构,不易于代码的测试和复用,于是使用ReactiveCocoa实现MVVM(Model-View-View Model)架构,加上一个用Objective-C实现的BDD测试框架Kiwi来单元测试,就可以行为驱动开发iOS App。 ReactiveCocoaReactiveCocoa是一个用Objective-C编写,具有函数式和响应式特性的编程框架。大多数的开发者他们解决问题的思考方式都是如何完成任务,通常的做法就是编写很多指令,然后修改重要数据结构的状态,这种编程范式叫做命令式编程(Imperative Programming)。与命令式编程不同的是函数式编程(Functional Programming),思考问题的方式是完成什么任务,怎样描述这个任务。关于对函数式编程入门概念的理解,可以参考酷壳《函数式编程》这篇文章,深入浅出对函数式编程的思考方式、特性和技术通过一些示例来讲解。 ReactiveCocoa解决哪些问题?
- (void)viewDidLoad {
[super viewDidLoad];
@weakify(self);
RAC(self.logInButton,enabled) = [RACSignal
combineLatest:@[
self.usernameTextField.rac_textSignal,self.passwordTextField.rac_textSignal,RACObserve(LoginManager.sharedManager,loggingIn),RACObserve(self,loggedIn)
] reduce:^(NSString *username,NSString *password,NSNumber *loggingIn,NSNumber *loggedIn) {
return @(username.length > 0 && password.length > 0 && !loggingIn.boolValue && !loggedIn.boolValue);
}];
[[self.logInButton rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(UIButton *sender) {
@strongify(self);
RACSignal *loginSignal = [LoginManager.sharedManager
logInWithUsername:self.usernameTextField.text
password:self.passwordTextField.text];
[loginSignal subscribeError:^(NSError *error) {
@strongify(self);
[self presentError:error];
} completed:^{
@strongify(self);
self.loggedIn = YES;
}];
}];
RAC(self,loggedIn) = [[NSNotificationCenter.defaultCenter
rac_addObserverForName:UserDidLogOutNotification object:nil]
mapReplace:@NO];
}
Typical MVC paradigm.png
正如你所见,View Controller隐式承担很多责任:数据验证、映射数据模型到View和操作View层次结构。MVVM将很多逻辑从View Controller移走到View-Model,等介绍完ReactiveCocoa后会介绍MVVM架构。还有一些关于如何减负View Controller好文章请参阅objc中国更轻量的View Controllers系列:
ReactiveCocoa核心类设计关于RAC核心类设计,官方文档有详细的解释:Framework Overview Sequence和Signal基本操作了解完整个RAC核心类设计之后,要学会对Sequence和Signal基本操作,比如:用signal执行side effects,转换streams,合并stream和合并signal。详情请查阅官方文档:Basic Operators MVVM架构
MVVM high level.png
RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){
return [[ViewModel dateFormatter] stringFromDate:date];
}];
ViewModel调用dateFormatter进行数据转换,且方法dateFormatter可以复用到其他地方。然后view controller监听view model的dateAdded属性且绑定到label的text属性。 RAC(self.label,text) = RACObserve(self.viewModel,dateAdded);
现在我们抽象出日期转换到字符串的逻辑到view model,使得代码可以测试和复用,并且帮view controller瘦身。 KiwiKiwi是一个iOS行为驱动开发(Behavior Driven Development)的库。相比于Xcode提供单元测试的XCTest是从测试的角度思考问题,而Kiwi是从行为的角度思考问题,测试用例都遵循三段式Given-When-Then的描述,清晰地表达测试用例是测试什么样的对象或数据结构,在基于什么上下文或情景,然后做出什么响应。 describe(@"Team",^{ context(@"when newly created",^{ it(@"has a name",^{ id team = [Team team]; [[team.name should] equal:@"Black Hawks"]; }); it(@"has 11 players",^{ id team = [Team team]; [[[team should] have:11] players]; }); }); });
我们很容易根据上下文将其提取为Given..When..Then的三段式自然语言 Given a Team,when be newly created,it should have a name,it should have 11 player
用Xcode自带的XCTest测试框架写过测试代码的朋友可能体会到,以上代码更加易于阅读和理解。就算以后有新的开发者加入或修护代码时,不需要太大的成本去阅读和理解代码。具体如何使用Kiwi,请参考两篇文章:
Designer News UI在编写Designer News客户端代码之前,首先通过UI来了解整个App的概况。设计Designer News UI的工具是Sketch,想获得Designer News UI,请点击下载Designer New UI。
Designer News Design.png
登陆界面由于这个项目简单并且只有一个人开发(多人开发的话,采用Storyboard不易于代码合并),加上Storyboard可以可视化的添加UI组件和Auto Layout的约束,并且可以同时预览多个不同分辨率iPhone的效果,极大地提高开发界面效率。
Login.png
登陆交互登陆界面有Email输入框和密码输入框,当用户选中其他一个输入框时,左边对应的图标变成蓝色,同时会有pop动画表示用户准备要输入内容。
Login.gif
我们可以使用RAC通过监听Text Field的UITextFieldTextDidBeginEditingNotification和UITextFieldTextDidEndEditingNotification的通知来处理用户选中Email输入框和密码输入框时改变图标和显示的动画。 #pragma mark - Text Field notification
- (void)textFieldStartEndEditing
{
// Respond to when email text start and end editing
[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.emailTextField] subscribeNext:^(id x) {
[self.emailImageView animate];
self.emailImageView.image = [UIImage imageNamed:@"icon-mail-active"];
self.emailTextField.background = [UIImage imageNamed:@"input-outline-active"];
}];
[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.emailTextField] subscribeNext:^(id x) {
self.emailTextField.background = [UIImage imageNamed:@"input-outline"];
self.emailImageView.image = [UIImage imageNamed:@"icon-mail"];
}];
// Respond to when password text start and end editing
[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.passwordTextField] subscribeNext:^(id x) {
[self.passwordImageView animate];
self.passwordTextField.background = [UIImage imageNamed:@"input-outline-active"];
self.passwordImageView.image = [UIImage imageNamed:@"icon-password-active"];
}];
[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.passwordTextField] subscribeNext:^(id x) {
self.passwordTextField.background = [UIImage imageNamed:@"input-outline"];
self.passwordImageView.image = [UIImage imageNamed:@"icon-password"];
}];
}
当点击登陆按钮后,客户端向服务端发送验证请求,服务端验证完账户和密码后,用户便可以成功登陆。所以,接下来要了解RESTful API的基本概念和Designer News提供的RESTful API。 Designer News APIRESTful API基本概念和设计REST全称是Representational State Transfer,翻译过来就是表现层状态转化。要想真正理解它的含义,从几个关键字入手:Resource,Representation,State Transfer
理解RESTful核心概念后,我们来简单了解RESTful API设计以便可以看懂Designer News提供API。就拿Designer News获取Stories对应URL的一个例子来说明: 服务端返回结果(部分结果) {
"stories": [ { "id": 46826,"title": "A Year of DuckDuckGo","comment": "","comment_html": null,"comment_count": 4,"vote_count": 17,"created_at": "2015-03-28T14:05:38Z","pinned_at": null,"url": "https://news.layervault.com/click/stories/46826","site_url": "https://api-news.layervault.com/stories/46826-a-year-of-duckduckgo","user_id": 3334,"user_display_name": "Thomas W.","user_portrait_url": "https://designer-news.s3.amazonaws.com/rendered_portraits/3334/original/portrait-2014-09-16_13_25_43__0000-333420140916-9599-7pse94.png?AWSAccessKeyId=AKIAI4OKHYH7JRMFZMUA&Expires=1459149709&Signature=%2FqqLAgqpOet6fckn4TD7vnJQbGw%3D","hostname": "designwithtom.com","user_url": "http://news.layervault.com/u/3334/thomas-wood","badge": null,"user_job": "Online Designer at IDG UK","sponsored": false,"comments": [ { "id": 142530,"body": "Had no idea it had those customization settings — finally making the switch.","body_html": "<p>Had no idea it had those customization settings — finally making the switch.</p>n","created_at": "2015-03-28T18:41:37Z","depth": 0,"vote_count": 0,"url": "https://api-news.layervault.com/comments/142530","user_url": "http://news.layervault.com/u/3826/matt-soria","user_id": 3826,"user_display_name": "Matt S.","user_portrait_url": "https://designer-news.s3.amazonaws.com/rendered_portraits/3826/original/portrait-2014-04-12_11_08_21__0000-382620140412-5896-1udai4f.png?AWSAccessKeyId=AKIAI4OKHYH7JRMFZMUA&Expires=1459125745&Signature=%2BDdWMtto3Q10dd677sUOjfvQO3g%3D","user_job": "Web Dood @ mattsoria.com","comments": [] },
Designer News提供APIDesigner News API Reference提供基于HTTP协议遵循RESTful设计的API,并且允许应用程序通过 oAuth 2授权协议来获取授权权限来访问用户信息。 访问API工具一般来说,在写访问服务端代码之前,我都会用Paw(下载地址)工具来测试API是否可行;另一方面,用JSON文件保存服务端返回的数据,用于moco模拟服务端的服务。至于为什么需要moco模拟服务端,后面会讲解,现在通过用户登录Designer News这个例子介绍如何使用Paw来测试API。
Designer News Login API.png
根据以上提供的信息,API的路径是
New Send Request.png
Moco模拟服务端Moco是一个可以轻松搭建测试服务器的工具。 为什么需要模拟服务端作为一个移动开发人员,有时由于服务端开发进度慢,空有一个iPhone应用但发挥不出作用。幸好有了Moco,只需配置一下请求和返回数据,很快就可以搭建一个模拟服务,无需等待服务端开发完成才能继续开发。当服务端完成后,修改访问地址即可。 有时服务端API应该是什么样子都还没清楚,由于有了moco模拟服务,在开发过程中,可以不断调整API设计,搞清楚真正自己想要的API是什么样子的。就这样,在服务端代码还没真正动手之前,已经提供一份真正满足自己需要的API文档,剩下的就交给服务端照着API去实现就行了。 还有一种情况就是,服务端已经写好了,剩下客户端还没完成。由于moco是本地服务,访问速度比较快,所以通过使用moco来模拟服务端,这样不仅可以提高客户端的访问速度,还提高网络层测试代码访问速度的稳定性,Designer News就是这样情况。 如何使用Moco模拟服务安装如果你是使用Mac或Linux,可以尝试一下步骤:
现在你可以运行一下命令测试安装是否成功
配置服务由于有时候服务端返回的数据比较多,所以将服务端响应的数据独立在一个JSON文件中。以登陆为例,将数据存放在login_response.json {
"access_token": "4422ea7f05750e93a101cb77ff76dffd3d65d46ebf6ed5b94d211e5d9b3b80bc","token_type": "bearer","scope": "user","created_at": 1428040414 }
而将请求uri路径,方法(method)和参数(queries)等配置放在login_conf.json文件中 [
{
"request" :
{ "uri" : "/oauth/token","method" : "post","queries" : { "grant_type" : "password","username" : "liuyaozhu13hao@163.com","password" : "freedom13","client_secret" : "53e3822c49287190768e009a8f8e55d09041c5bf26d0ef982693f215c72d87da","client_id" : "750ab22aac78be1c6d4bbe584f0e3477064f646720f327c5464bc127100a1a6d" } },"response" :
{ "file" : "./Login/login_response.json" } }
]
不知道有没有留意到上面uri路径不是全路径 [
{
"include" : "./Story/stories_conf.json" },{
"include" : "./Login/login_conf.json" },{
"include" : "./Story/story_upvote_conf.json" }
]
启动服务将路径跳转到DesignerNewsForObjc/DesignerNewsForObjcTests/JSON目录,找到settings.json文件,使用命令行来启动服务: 使用Paw验证是否配置成功
Send request to Local Server.png
行为驱动开发(BDD)为什么需要BDD不知道各位在编写测试的时候,有没有思考过一个问题:我应该测试什么?要回答这个问题并不是那么简单,在没得到答案之前,你还是继续按照你的想法编写测试。 BDD过程行为驱动开发大概三个步骤:
如果暂时不理解其中步骤细节,没有关系,继续向下阅读,后面有例子介绍来帮助你理解三个步骤的含义。 登陆验证网络访问层DesignerNewsURL
#import <Foundation/Foundation.h>
extern NSString* const baseURL;
extern NSString* const clientID;
extern NSString* const clientSecret;
@interface DesignerNewsURL : NSObject
+ (NSString*)loginURLString;
+ (NSString*)stroiesURLString;
+ (NSString*)storyIdURLStringWithId:(NSInteger)storyId;
+ (NSString*)storyUpvoteWithId:(NSInteger)storyId;
+ (NSString*)storyReplyWithId:(NSInteger)storyId;
+ (NSString*)commentUpvoteWithId:(NSInteger)commentId;
+ (NSString*)commentReplyWithId:(NSInteger)commentId;
@end
这里还有个技巧就是在 #ifndef TEST
NSString* const baseURL = @"https://api-news.layervault.com";
#else
NSString* const baseURL = @"http://localhost:12306";
#endif
NSString* const clientID = @"750ab22aac78be1c6d4bbe584f0e3477064f646720f327c5464bc127100a1a6d";
NSString* const clientSecret = @"53e3822c49287190768e009a8f8e55d09041c5bf26d0ef982693f215c72d87da";
行为驱动开发LoginClient在编写代码之前,我们应该先想想如何设计
Create LoginClient 1.png
Create LoginClient 2.png
SPEC_BEGIN(LoginClientSpec)
describe(@"LoginClient",^{
context(@"when user input correct username and password",^{
__block RACSignal *loginSignal;
beforeEach(^{
NSString *username = @"liuyaozhu13hao@163.com";
NSString *password = @"freedom13";
loginSignal = [LoginClient loginWithUsername:username password:password];
});
it(@"should return login signal that can't be nil",^{
[[loginSignal shouldNot] beNil];
});
it(@"should login successfully",^{
__block NSString *accessToken = nil;
[loginSignal subscribeNext:^(NSString *x) {
accessToken = x;
NSLog(@"accessToken = %@",accessToken);
}error:^(NSError *error) {
[[accessToken shouldNot] beNil];
} completed:^{
[[accessToken shouldNot] beNil];
} ];
});
});
});
SPEC_END
根据三段式Given-When-Then描述,上面代码我们可以理解为:在给定LoginClient对象,当用户输入正确的用户名和密码时,应该登录成功。
LoginClient.h.png
LoginClient.m.png
LoginClient Failed.png
LoginClient.m .png
LoginClient Pass Test.png
Model层由于这次登陆请求服务端返回数据比较简单,只是获取 Controller与ViewModel层
创建 SPEC_BEGIN(LoginViewControllerSpec)
describe(@"LoginViewController",^{
__block LoginViewController *controller;
beforeEach(^{
controller = [UIViewController loadViewControllerWithIdentifierForMainStoryboard:@"LoginViewController"];
[controller view];
});
afterEach(^{
controller = nil;
});
describe(@"Email Text Field",^{
context(@"when touch text field",^{
it(@"should not be nil",^{
[[controller.emailTextField shouldNot] beNil];
});
});
context(@"when text field's text is hello",^{
it(@"shoud euqal view model's email property",^{
controller.emailTextField.text = @"hello";
[controller.emailTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[[controller.viewModel.email should] equal:@"hello"];
});
});
});
describe(@"Password Text Field",^{
[[controller.passwordTextField shouldNot] beNil];
});
});
context(@"when text field' text is hello",^{
it(@"should equal view model's password property",^{
controller.passwordTextField.text = @"hello";
[controller.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[[controller.viewModel.password should] equal:@"hello"];
});
});
});
});
SPEC_END
这里有两个关键点,一个是从 编译失败后,在 RAC(self.viewModel,email) = self.emailTextField.rac_textSignal;
RAC(self.viewModel,password) = self.passwordTextField.rac_textSignal;
实现完数据绑定行为后,接下来要数据校验,交给 SPEC_BEGIN(LoginViewModelSpec)
describe(@"LoginViewModel",^{
// Initialize
__block LoginViewModel *viewModel;
beforeEach(^{
viewModel = [[LoginViewModel alloc] init];
});
afterEach(^{
viewModel = nil;
});
context(@"when email and password is valid",^{
it(@"should get valid signal",^{
viewModel.email = @"liuyaozhu13hao@163.com";
viewModel.password = @"123456";
__block BOOL result;
[[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) {
result = [x boolValue];
} completed:^{
[[theValue(result) should] beYes];
}];
});
});
context(@"when email is valid,but password is invalid",^{
it(@"should get invalid signal",^{
viewModel.email = @"liuyaozhu13hao@163.com";
viewModel.password = @"1";
__block BOOL result;
[[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) {
result = [x boolValue];
} completed:^{
[[theValue(result) shouldNot] beYes];
}];
});
});
context(@"when password is valid,but email is invalid",^{
viewModel.email = @"liuyaozhu";
viewModel.password = @"123456";
__block BOOL result;
[[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) {
result = [x boolValue];
} completed:^{
[[theValue(result) shouldNot] beYes];
}];
});
});
});
SPEC_END
编译失败后(已经创建 - (RACSignal*)checkEmailPasswordSignal
{
RACSignal* emailSignal = RACObserve(self,email);
RACSignal* passwordSignal = RACObserve(self,password);
return [RACSignal combineLatest:@[ emailSignal,passwordSignal ] reduce:^(NSString* email,NSString* password) {
BOOL result = [email isValidEmail] && [password isValidPassword];
return @(result);
}];
}
最后需要在 describe(@"Login Button",^{ context(@"when load view",^{ it(@"should be not nil",^{ [[controller.loginButton shouldNot] beNil]; }); it(@"should have rac command that not be nil",^{ [[controller.loginButton.rac_command shouldNot] beNil]; }); }); });
测试失败,在 self.loginButton.rac_command = self.viewModel.loginButtonCommand;
在 #pragma mark - Lazy initialization
- (RACCommand*)loginButtonCommand
{
if (!_loginButtonCommand) {
_loginButtonCommand = [[RACCommand alloc] initWithEnabled:[self checkEmailPasswordSignal] signalBlock:^RACSignal * (id input) {
self.active = YES;
return [[LoginClient loginWithUsername:self.email password:self.password] doNext:^(NSString *token) {
self.active = NO;
// Save the token
[LocalStore saveToken:token];
// Dismiss view controller and fetch data,reload
self.dismissBlock();
}];
}];
}
return _loginButtonCommand;
}
通过测试,完成登陆基本流程,至于登陆成功后如何返回故事列表页面,这里不详细介绍,各位可以通过阅读工程代码便可以得到答案。 总结最近一段时间都再看关于敏捷开发的书籍(用户故事与敏捷方法,硝烟中的Scrum和XP, 解析极限编程),对敏捷开发很感兴趣,但发觉很少公司或博客介绍如何实践敏捷开发iOS,所以在网上搜集一些资料,发现有很多优秀的实践(测试驱动开发,重构,持续集成测试,增量设计,增量计划)值得去学习,通过自己对敏捷开发中各种实践的理解来重写这个Designer News,这个Designer News功能还没全部完成,希望各位看完这篇文章尝试以这样方式来完成整个app。如果我有些观点或实践理解有误,请各位多多指点。 扩展阅读
文/Sam_Lau(简书作者) 原文链接:http://www.jianshu.com/p/73f9d719cee4 著作权归作者所有,转载请联系作者获得授权,并标注“简书作者”。
(编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |