【IOS开发高级系列】MVVM—ReactiveCocoa架构设计专题(三)

1 代码开发实战

1.1 入门讲解

ReactiveCocoa入门教程:第一部分

http://www.cocoachina.com/ios/20150123/10994.html

1.1.1 事件流控制rac_textSignal

        ReactiveCocoa有很多操作来控制事件流。假设你只关心超过3个字符长度的用户名,那么你可以使用filter操作来实现这个目的。把之前加在viewDidLoad中的代码更新成下面的:

[[self.usernameTextField.rac_textSignal filter: ^BOOL(id value){

    NSString*text = value;

   return text.length > 3;

}] subscribeNext: ^(id x){

   NSLog(@"%@", x);

}];

        编译运行,在text field只能怪输入几个字,你会发现只有当输入超过3个字符时才会有log。

2013-12-26 08:17:51.335 RWReactivePlayground[9654:a0b] is t

2013-12-26 08:17:51.478 RWReactivePlayground[9654:a0b] is th

2013-12-26 08:17:51.526 RWReactivePlayground[9654:a0b] is thi

2013-12-26 08:17:51.548 RWReactivePlayground[9654:a0b] is this

2013-12-26 08:17:51.676 RWReactivePlayground[9654:a0b] is this

2013-12-26 08:17:51.798 RWReactivePlayground[9654:a0b] is thism

2013-12-26 08:17:51.926 RWReactivePlayground[9654:a0b] is thisma

2013-12-26 08:17:51.987 RWReactivePlayground[9654:a0b] is thismag

2013-12-26 08:17:52.141 RWReactivePlayground[9654:a0b] is thismagi

2013-12-26 08:17:52.229 RWReactivePlayground[9654:a0b] is thismagic

2013-12-26 08:17:52.486 RWReactivePlayground[9654:a0b] is thismagic?

        刚才所创建的只是一个很简单的管道。这就是响应式编程的本质,根据数据流来表达应用的功能。用图形来表达就是下面这样的:

        从上面的图中可以看到,rac_textSignal是起始事件。然后数据通过一个filter,如果这个事件包含一个长度超过3的字符串,那么该事件就可以通过。管道的最后一步就是subscribeNext:,block在这里打印出事件的值。

        filter操作的输出也是RACSignal,这点先放到一边。你可以像下面那样调整一下代码来展示每一步的操作。

RACSignal *usernameSourceSignal = self.usernameTextField.rac_textSignal;

RACSignal *filteredUsername = [usernameSourceSignal filter: ^BOOL(id value){

    NSString*text = value;

    return text.length > 3;

}];

[filteredUsername subscribeNext: ^(id x){

      NSLog(@"%@", x);

}];

        RACSignal的每个操作都会返回一个RACsignal,这在术语上叫做连贯接口(fluent interface)。这个功能可以让你直接构建管道,而不用每一步都使用本地变量。

        注意:ReactiveCocoa大量使用block。如果你是block新手,你可能想看看Apple官方的block编程指南。如果你熟悉block,但是觉得block的语法有些奇怪和难记,你可能会想看看这个有趣又实用的网页f*****gblocksyntax.com。

1.1.2 类型转换filter(数据过滤)

        如果你之前把代码分成了多个步骤,现在再把它改回来吧。

[[self.usernameTextField.rac_textSignal filter: ^BOOL(id value){

    NSString*text = value; // implicit cast

    return text.length > 3;

}] subscribeNext: ^(id x){

    NSLog(@"%@", x);

}];

        在上面的代码中,注释部分标记了将id隐式转换为NSString,这看起来不是很好看。幸运的是,传入block的值肯定是个NSString,所以你可以直接修改参数类型,把代码更新成下面的这样的:

[[self.usernameTextField.rac_textSignal filter: ^BOOL(NSString *text){

    return text.length > 3;

}] subscribeNext: ^(id x){

    NSLog(@"%@", x);

}];

        编译运行,确保没什么问题。

1.1.3 map(数据传递转换)

        什么是事件呢?

        到目前为止,本篇教程已经描述了不同的事件类型,但是还没有说明这些事件的结构。有意思的是(?),事件可以包括任何事情。下面来展示一下,在管道中添加另一个操作。把添加在viewDidLoad中的代码更新成下面的:

[[[self.usernameTextField.rac_textSignal map: ^id(NSString *text){

    return @(text.length);

}] filter: ^BOOL(NSNumber *length){

    return [length integerValue] > 3;

}] subscribeNext: ^(id x){

    NSLog(@"%@", x);

}];

        编译运行,你会发现log输出变成了文本的长度而不是内容。

2013-12-26 12:06:54.566 RWReactivePlayground[10079:a0b] 4

2013-12-26 12:06:54.725 RWReactivePlayground[10079:a0b] 5

2013-12-26 12:06:54.853 RWReactivePlayground[10079:a0b] 6

2013-12-26 12:06:55.061 RWReactivePlayground[10079:a0b] 7

2013-12-26 12:06:55.197 RWReactivePlayground[10079:a0b] 8

2013-12-26 12:06:55.300 RWReactivePlayground[10079:a0b] 9

2013-12-26 12:06:55.462 RWReactivePlayground[10079:a0b] 10

2013-12-26 12:06:55.558 RWReactivePlayground[10079:a0b] 11

2013-12-26 12:06:55.646 RWReactivePlayground[10079:a0b] 12

        新加的map操作通过block改变了事件的数据。map从上一个next事件接收数据,通过执行block把返回值传给下一个next事件。在上面的代码中,map以NSString为输入,取字符串的长度,返回一个NSNumber。

        来看下面的图片:

        能看到map操作之后的步骤收到的都是NSNumber实例。你可以使用map操作来把接收的数据转换成想要的类型,只要它是个对象。

        注意:在上面的例子中text.length返回一个NSUInteger,是一个基本类型。为了将它作为事件的内容,NSUInteger必须被封装。幸运的是Objective-C literal syntax提供了一种简单的方法来封装——@ (text.length)。

        现在差不多是时候用所学的内容来更新一下ReactivePlayground应用了。你可以把之前的添加代码都删除了。

1.1.4 创建有效状态信号RACSignal

        首先要做的就是创建一些信号,来表示用户名和密码输入框中的输入内容是否有效。把下面的代码添加到RWViewController.m中viewDidLoad的最后面:

RACSignal *validUsernameSignal = [self.usernameTextField.rac_textSignal map: ^id(NSString *text) {

    return @([self isValidUsername: text]);

}];

RACSignal *validPasswordSignal = [self.passwordTextField.rac_textSignal map: ^id(NSString *text) {

     return @([self isValidPassword: text]);

}];

        可以看到,上面的代码对每个输入框的rac_textSignal应用了一个map转换。输出是一个用NSNumber封装的布尔值。

        下一步是转换这些信号,从而能为输入框设置不同的背景颜色。基本上就是,你订阅这些信号,然后用接收到的值来更新输入框的背景颜色。下面有一种方法:

[[validPasswordSignal map: ^id(NSNumber *passwordValid){

    return [passwordValid boolValue] ? [UIColor clearColor]:[UIColor yellowColor];

}] subscribeNext: ^(UIColor *color){

    self.passwordTextField.backgroundColor = color;

}];

        (不要使用这段代码,下面有一种更好的写法!)

        从概念上来说,就是把之前信号的输出应用到输入框的backgroundColor属性上。但是上面的用法不是很好。

        幸运的是,ReactiveCocoa提供了一个宏来更好的完成上面的事情。把下面的代码直接加到viewDidLoad中两个信号的代码后面:

RAC(self.passwordTextField, backgroundColor) = [validPasswordSignal map: ^id(NSNumber *passwordValid){

      return [passwordValid boolValue] ? [UIColor clearColor]:[UIColor yellowColor];

}];

RAC(self.usernameTextField, backgroundColor) = [validUsernameSignal map: ^id(NSNumber *passwordValid){

     return [passwordValid boolValue] ? [UIColor clearColor]:[UIColor yellowColor];

}];

        RAC宏允许直接把信号的输出应用到对象的属性上。RAC宏有两个参数,第一个是需要设置属性值的对象,第二个是属性名。每次信号产生一个next事件,传递过来的值都会应用到该属性上。

        你不觉得这种方法很好吗?

        在编译运行之前,找到updateUIState方法,把头两行删掉。

self.usernameTextField.backgroundColor = self.usernameIsValid ? [UIColor clearColor] : [UIColor yellowColor];

self.passwordTextField.backgroundColor = self.passwordIsValid ? [UIColor clearColor] : [UIColor yellowColor];

        这样就把不相关的代码删掉了。

        编译运行,可以发现当输入内容无效时,输入框看起来高亮了,有效时又透明了。

        现在的逻辑用图形来表示就是下面这样的。能看到有两条简单的管道,两个文本信号,经过一个map转为表示是否有效的布尔值,再经过一个map转为UIColor,而这个UIColor已经和输入框的背景颜色绑定了。

        你是否好奇为什么要创建两个分开的validPasswordSignal和validUsernameSignal呢,而不是每个输入框一个单独的管道呢?(?)稍安勿躁,答案就在下面。

        原文:Are you wondering why you created separate validPasswordSignal and validUsernameSignal signals, as opposed to a single fluent pipeline for each text field? Patience dear reader, the method behind this madness will become clear shortly!

1.1.5 聚合信号combineLatest

        目前在应用中,登录按钮只有当用户名和密码输入框的输入都有效时才工作。现在要把这里改成响应式的。

        现在的代码中已经有可以产生用户名和密码输入框是否有效的信号了——validUsernameSignal和validPasswordSignal了。现在需要做的就是聚合这两个信号来决定登录按钮是否可用。

        把下面的代码添加到viewDidLoad的末尾:

RACSignal *signUpActiveSignal = [RACSignalcombineLatest: @[validUsernameSignal, validPasswordSignal] reduce: ^id(NSNumber *usernameValid, NSNumber *passwordValid){

    return @([usernameValid boolValue]&&[passwordValid boolValue]);

}];

        上面的代码使用combineLatest:reduce:方法把validUsernameSignal和validPasswordSignal产生的最新的值聚合在一起,并生成一个新的信号。每次这两个源信号的任何一个产生新值时,reduce

block都会执行,block的返回值会发给下一个信号。

        注意:RACsignal的这个方法可以聚合任意数量的信号,reduce block的参数和每个源信号相关。ReactiveCocoa有一个工具类RACBlockTrampoline,它在内部处理reduce block的可变参数。实际上在ReactiveCocoa的实现中有很多隐藏的技巧,值得你去看看。

        现在已经有了合适的信号,把下面的代码添加到viewDidLoad的末尾。这会把信号和按钮的enabled属性绑定。

[signUpActiveSignal subscribeNext: ^(NSNumber *signupActive){

    self.signInButton.enabled = [signupActive boolValue];

}];

        在运行之前,把以前的旧实现删掉。把下面这两个属性删掉。

@property (nonatomic) BOOL passwordIsValid;

@property (nonatomic) BOOL usernameIsValid;

        把viewDidLoad中的这些也删掉:

// handle text changes for both text fields

[self.usernameTextField addTarget: self action: @selector(usernameTextFieldChanged)

forControlEvents: UIControlEventEditingChanged];

[self.passwordTextField addTarget: self action: @selector(passwordTextFieldChanged)

forControlEvents: UIControlEventEditingChanged];

        同样把updateUIState、usernameTextFieldChanged和passwordTextFieldChanged方法删掉。

最后确保把viewDidLoad中updateUIState的调用删掉。

        编译运行,看看登录按钮。当用户名和密码输入有效时,按钮就是可用的,和以前一样。

        现在应用的逻辑就是下面这样的:

        上图展示了一些重要的概念,你可以使用ReactiveCocoa来完成一些重量级的任务。

• 分割——信号可以有很多subscriber,也就是作为很多后续步骤的源。注意上图中那个用来表示用户名和密码有效性的布尔信号,它被分割成多个,用于不同的地方。

• 聚合——多个信号可以聚合成一个新的信号,在上面的例子中,两个布尔信号聚合成了一个。实际上你可以聚合并产生任何类型的信号。

        这些改动的结果就是,代码中没有用来表示两个输入框有效状态的私有属性了。这就是用响应式编程的一个关键区别,你不需要使用实例变量来追踪瞬时状态。


1.1.6 应用实例——响应式的登录

1.1.6.1 创建界面

        应用目前使用上面图中展示的响应式管道来管理输入框和按钮的状态。但是按钮按下的处理用的还是action,所以下一步就是把剩下的逻辑都替换成响应式的。

        在storyboard中,登录按钮的Touch Up Inside事件和RWViewController.m中的signInButtonTouched方法是绑定的。下面会用响应的方法替换,所以首先要做的就是断开当前的storyboard action。

        打开Main.storyboard,找到登录按钮,按住ctrl键单击,打开outlet/action连接框,然后点击x来断开连接。如果你找不到的话,下图中红色箭头指示的就是删除按钮。

        你已经知道了ReactiveCocoa框架是如何给基本UIKit控件添加属性和方法的了。目前你已经使用了rac_textSignal,它会在文本发生变化时产生信号。为了处理按钮的事件,现在需要用到ReactiveCocoa为UIKit添加的另一个方法,rac_signalForControlEvents。

        现在回到RWViewController.m,把下面的代码添加到viewDidLoad的末尾:

[[self.signInButton rac_signalForControlEvents: UIControlEventTouchUpInside] subscribeNext: ^(id x) {

     NSLog(@"button clicked");

}];

        上面的代码从按钮的UIControlEventTouchUpInside事件创建了一个信号,然后添加了一个订阅,在每次事件发生时都会输出log。

        编译运行,确保的确有log输出。按钮只在用户名和密码框输入有效时可用,所以在点击按钮前需要在两个文本框中输入一些内容。

        可以看到Xcode控制台的输出和下面的类似:

2013-12-28 08:05:10.816 RWReactivePlayground[18203:a0b] button clicked

2013-12-28 08:05:11.675 RWReactivePlayground[18203:a0b] button clicked

2013-12-28 08:05:12.605 RWReactivePlayground[18203:a0b] button clicked

2013-12-28 08:05:12.766 RWReactivePlayground[18203:a0b] button clicked

2013-12-28 08:05:12.917 RWReactivePlayground[18203:a0b] button clicked

        现在按钮有了点击事件的信号,下一步就是把它和登录流程连接起来。那么问题就来了,打开RWDummySignInService.h,看一下接口:

typedef void (^RWSignInResponse)(BOOL);

@interface RWDummySignInService : NSObject

- (void) signInWithUsername: (NSString *)username password: (NSString *)password complete: (RWSignInResponse)completeBlock;

@end

        这个service有3个参数,用户名、密码和一个完成回调block。这个block会在登录成功或失败时执行。你可以在按钮点击事件的subscribeNext: blcok里直接调用这个方法,但是为什么你要这么做?

        注意:本教程为了简便使用了一个假的service,所以它不依赖任何外部API。但你现在的确遇到了一个问题,如何使用这些不是用信号表示的API呢?

1.1.6.2 创建信号

        幸运的是,把已有的异步API用信号的方式来表示相当简单。首先把RWViewController.m中的signInButtonTouched:删掉。你会用响应式的的方法来替换这段逻辑。

        还是在RWViewController.m中,添加下面的方法:

- (RACSignal *) signInSignal {

    return [RACSignal createSignal: ^RACDisposable *(id subscriber){

        [self.signInService signInWithUsername: self.usernameTextField.text password: self.passwordTextField.text complete: ^(BOOL success){

            [subscriber sendNext: @(success)];

            [subscriber sendCompleted];

        }];

       return nil;

    }];

}

        上面的方法创建了一个信号,使用用户名和密码登录。现在分解来看一下。

        上面的代码使用RACSignal的createSignal:方法来创建信号。方法的入参是一个block,这个block描述了这个信号。当这个信号有subscriber时,block里的代码就会执行。        

        block的入参是一个subscriber实例,它遵循RACSubscriber协议,协议里有一些方法来产生事件,你可以发送任意数量的next事件,或者用error\complete事件来终止。本例中,信号发送了一个next事件来表示登录是否成功,随后是一个complete事件。

        这个block的返回值是一个RACDisposable对象,它允许你在一个订阅被取消时执行一些清理工作。当前的信号不需要执行清理操作,所以返回nil就可以了。

        可以看到,把一个异步API用信号封装是多简单!

        现在就来使用这个新的信号。把之前添加在viewDidLoad中的代码更新成下面这样的:

[[[self.signInButton rac_signalForControlEvents: UIControlEventTouchUpInside] map: ^id(id x){

     return [self signInSignal];

}] subscribeNext: ^(id x){

     NSLog(@"Sign in result: %@", x);

}];

        上面的代码使用map方法,把按钮点击信号转换成了登录信号。subscriber输出log。

        编译运行,点击登录按钮,查看Xcode的控制台,等等,输出的这是个什么鬼?

2014-01-08 21:00:25.919 RWReactivePlayground[33818:a0b] Sign inresult:

name: +createSignal:

        没错,你已经给subscribeNext:的block传入了一个信号,但传入的不是登录结果的信号。

        下图展示了到底发生了什么:

        当点击按钮时,rac_signalForControlEvents发送了一个next事件(事件的data是UIButton)。map操作创建并返回了登录信号,这意味着后续步骤都会收到一个RACSignal。这就是你在subscribeNext:这步看到的。

        上面问题的解决方法,有时候叫做信号中的信号,换句话说就是一个外部信号里面还有一个内部信号。你可以在外部信号的subscribeNext:block里订阅内部信号。不过这样嵌套太混乱啦,还好ReactiveCocoa已经解决了这个问题。

1.1.6.3 信号中的信号flattenMap

        解决的方法很简单,只需要把map操作改成flattenMap就可以了:

[[[self.signInButton rac_signalForControlEvents: UIControlEventTouchUpInside] flattenMap: ^id(id x){

     return [self signInSignal];

}] subscribeNext: ^(id x){

     NSLog(@"Sign in result: %@", x);

}];

        这个操作把按钮点击事件转换为登录信号,同时还从内部信号发送事件到外部信号。

        编译运行,注意控制台,现在应该输出登录是否成功了。

2013-12-28 18:20:08.156 RWReactivePlayground[22993:a0b] Sign inresult: 0

2013-12-28 18:25:50.927 RWReactivePlayground[22993:a0b] Sign inresult: 1

        还不错。

        现在已经完成了大部分的内容,最后就是在subscribeNext步骤里添加登录成功后跳转的逻辑。把代码更新成下面的:

[[[self.signInButton rac_signalForControlEvents: UIControlEventTouchUpInside] flattenMap: ^id(id x){

   return [self signInSignal];

}] subscribeNext: ^(NSNumber*signedIn){

    BOOL success =[signedIn boolValue];

    self.signInFailureText.hidden = success;

   if (success){

        [self performSegueWithIdentifier: @"signInSuccess" sender: self];

    }

}];

        subscribeNext: block从登录信号中取得结果,相应地更新signInFailureText是否可见。如果登录成功执行导航跳转。

        编译运行,应该就能再看到可爱的小猫啦!喵~

        你注意到这个应用现在有一些用户体验上的小问题了吗?当登录service正在校验用户名和密码时,登录按钮应该是不可点击的。这会防止用户多次执行登录操作。还有,如果登录失败了,用户再次尝试登录时,应该隐藏错误信息。

        这个逻辑应该怎么添加呢?改变按钮的可用状态并不是转换(map)、过滤(filter)或者其他已经学过的概念。其实这个就叫做“副作用”,换句话说就是在一个next事件发生时执行的逻辑,而该逻辑并不改变事件本身。

1.1.6.4 添加附加操作(Adding side-effects)

        把代码更新成下面的:

[[[[self.signInButton rac_signalForControlEvents: UIControlEventTouchUpInside] doNext: ^(id x){

    self.signInButton.enabled =NO;

    self.signInFailureText.hidden =YES;

}] flattenMap: ^id(id x){

     return [self signInSignal];

}] subscribeNext: ^(NSNumber *signedIn){

    self.signInButton.enabled = YES;

    BOOL success = [signedIn boolValue];

    self.signInFailureText.hidden = success;

    if (success){

       [self performSegueWithIdentifier: @"signInSuccess" sender: self];

    }

}];

        你可以看到doNext:是直接跟在按钮点击事件的后面。而且doNext: block并没有返回值。因为它是附加操作,并不改变事件本身。

        上面的doNext: block把按钮置为不可点击,隐藏登录失败提示。然后在subscribeNext: block里重新把按钮置为可点击,并根据登录结果来决定是否显示失败提示。

        之前的管道图就更新成下面这样的:

        编译运行,确保登录按钮的可点击状态和预期的一样。现在所有的工作都已经完成了,这个应用已经是响应式的啦。你中途哪里出了问题,可以下载最终的工程(依赖库都有),或者在Github上找到这份代码,教程中的每一次编译运行都有对应的commit。

        注意:在异步操作执行的过程中禁用按钮是一个常见的问题,ReactiveCocoa也能很好的解决。RACCommand就包含这个概念,它有一个enabled信号,能让你把按钮的enabled属性和信号绑定起来。你也许想试试这个类。

1.1.6.5 总结

        希望本教程为你今后在自己的应用中使用ReactiveCocoa打下了一个好的基础。你可能需要一些练习来熟悉这些概念,但就像是语言或者编程,一旦你夯实基础,用起来也就很简单了。ReactiveCocoa的核心就是信号,而它不过就是事件流。还能再更简单点吗?

        在使用ReactiveCocoa后,我发现了一个有趣的事情,那就是你可以用很多种不同的方法来解决同一个问题。你可以用教程中的例子试试,调整一下信号,改改信号的分割和聚合。

        ReactiveCocoa的主旨是让你的代码更简洁易懂,这值得多想想。我个人认为,如果逻辑可以用清晰的管道、流式语法来表示,那就很好理解这个应用到底干了什么了。

        在本系列教程的第二部分,你将会学到诸如错误处理、在不同线程中执行代码等高级用法。

1.2 使用技巧

1.2.1 与界面控件绑定

1.2.1.1 简单属性绑定

- (void) viewDidLoad

{

    [super viewDidLoad];


    //Create the View Model

    self.viewModel = [CDWPlayerViewModel new];


    //using with @strongify(self) this makes sure that self isn't retained in the blocks

    //this is declared in RACEXTScope.h

    @weakify(self);


    //Start Binding our properties

    RAC(self.nameField,text) = [RACObserve(self.viewModel, playerName) distinctUntilChanged];

    [[self.nameField.rac_textSignal distinctUntilChanged] subscribeNext: ^(NSString*x) {

          //this creates a reference to self that when used with @weakify(self);

          //makes sure self isn't retained

          @strongify(self);

          self.viewModel.playerName= x;

    }];


    //the score property is a double, RC gives us updates as NSNumber which we just call

    //stringValue on and bind that to the score field text

    RAC(self.scoreField, text) = [RACObserve(self.viewModel, points) map:^id(NSNumber*value) {

          return [value stringValue];

    }];


    //Setup bind the steppers values

    self.scoreStepper.value = self.viewModel.points;

    RAC(self.scoreStepper, stepValue) = RACObserve(self.viewModel, stepAmount);

    RAC(self.scoreStepper, maximumValue) = RACObserve(self.viewModel, maxPoints);

    RAC(self.scoreStepper, minimumValue) = RACObserve(self.viewModel, minPoints);

    //bind the hidden field to a signal keeping track if

    //we've updated less than a certain number times as the view model specifies

    RAC(self.scoreStepper, hidden) = [RACObserve(self, scoreUpdates) map: ^id(NSNumber *x) {

          @strongify(self);

          return @(x.intValue >= self.viewModel.maxPointUpdates);

    }];


    //only take the maxPointUpdates number of score updates

    //skip 1 because we don't want the 1st value provided, only changes

    [[[RACObserve(self.scoreStepper,value) skip: 1] take: self.viewModel.maxPointUpdates] subscribeNext: ^(id newPoints) {

          @strongify(self);

          self.viewModel.points = [newPoints doubleValue];

          self.scoreUpdates++;

    }];


    //this signal should only trigger if we have "bad words" in our name

    [self.viewModel.forbiddenNameSignal subscribeNext: ^(NSString*name) {

          @strongify(self);

          UIAlertView *alert = [[UIAlertView alloc] initWithTitle: @"Forbidden Name!" message: [NSString stringWithFormat: @"The name %@ has been forbidden!",name] delegate: nil cancelButtonTitle:  @"Ok" otherButtonTitles: nil];

          [alert show];

          self.viewModel.playerName = @"";

    }];


    //let the upload(save) button only be enabled when the view model says its valid

    RAC(self.uploadButton, enabled) = self.viewModel.modelIsValidSignal;


    //set the control action for our button to be the ViewModels action method

    [self.uploadButton addTarget: self.viewModel action: @selector(uploadData:) forControlEvents: UIControlEventTouchUpInside];


    //we can subscribe to the same thing in multiple locations

    //here we skip the first 4 signals and take only 1 update

    //and then disable/hide certain UI elements as our app

    //only allows 5 updates

    [[[[self.uploadButton rac_signalForControlEvents: UIControlEventTouchUpInside] skip: (kMaxUploads - 1)] take: 1] subscribeNext: ^(idx) {

          @strongify(self);

          self.nameField.enabled = NO;

          self.scoreStepper.hidden = YES;

          self.uploadButton.hidden = YES;

    }];

}

1.2.1.2 UItableView绑定

(good)Binding to a UITableView from a ReactiveCocoa ViewModel

http://www.tuicool.com/articles/bYfmEjn


http://stackoverflow.com/questions/23203436/reactivecocoa-mvvm-with-uitableview


ReactiveCocoa Tutorial – The DefinitiveIntroduction: Part 1/2

http://www.raywenderlich.com/62699/reactivecocoa-tutorial-pt1


ReactiveCocoa Tutorial – The DefinitiveIntroduction: Part 2/2

http://www.raywenderlich.com/62796/reactivecocoa-tutorial-pt2


1.2.2 RAC宏

1.2.2.1 简介

·RAC(TARGET, [KEYPATH, [NIL_VALUE]])

RAC(self.outputLabel, text) = self.inputTextField.rac_textSignal;

RAC(self.outputLabel, text, @"收到nil时就显示我") = self.inputTextField.rac_textSignal;

        这个宏是最常用的,RAC()总是出现在等号左边,等号右边是一个RACSignal,表示的意义是将一个对象的一个属性和一个signal绑定,signal每产生一个value(id类型),都会自动执行:

[TARGET setValue: value ?: NIL_VALUE forKeyPath: KEYPATH];

        数字值会升级为NSNumber*,当setValue:forKeyPath时会自动降级成基本类型(int, float, BOOL等),所以RAC绑定一个基本类型的值是没有问题的。

        RAC 可以看作某个属性的值与一些信号的联动。

- RAC(TARGET, KEYPATH, NILVALUE) will bind the `KEYPATH` of `TARGET` to the given signal. If the signal ever sends a `nil` value, the property will be set to `NILVALUE` instead. `NILVALUE` may itself be `nil` for object properties, but an NSValue should be used for primitive properties, to avoid an exception if `nil` is sent (which might occur if an intermediate object is set to `nil`).

- RAC(TARGET, KEYPATH) is the same as the above, but `NILVALUE` defaults to `nil`.

 

1.2.2.2 示例一

- (void)bindDetail {

    @weakify(self);

    [RACObserve(_channelEntity, postCount) subscribeNext: ^(NSNumber*postCount) {

         @strongify(self);

         [self getDetailLabel].text = [NSString stringWithFormat: @"今日发帖%ld个,总发帖数%ld个", [self.channelEntity.postTodayCount longValue], [self.channelEntity.postCount longValue]];

    }];

}


RAC(self.submitButton.enabled) = [RACSignal combineLatest: @[self.usernameField.rac_textSignal, self.passwordField.rac_textSignal] reduce: ^id(NSString  *userName, NSString *password) {

    return @(userName.length=6 && password.length=6);

}];


1.2.2.3 示例二

RACSignal *photoSignal = [FRPPhotoImporter importPhotos];

RACSignal *photosLoaded = [photoSignal catch: ^RACSignal *(NSError *error) {

       NSLog(@"Couldn't fetch photos from 500px: %@", error);

       return [RACSignal empty];

}];

//将photosArray的变化绑定到self.collectionView

RAC(self, photosArray) = photosLoaded;

[photosLoaded subscribeCompleted: ^{

        @strongify(self);

        [self.collectionView reloadData];

}];


1.2.2.4 示例三

RAC(self, photosArray) = [[[[FRPPhotoImporter importPhotos] doCompleted: ^{

     @strongify(self);

     [self.collectionView reloadData];

 }] logError] catchTo: [RACSignal empty]];


1.2.3 RACObserve宏

    作用是观察TARGET的KEYPATH属性,相当于KVO,产生一个RACSignal。

    最常用的使用是和RAC宏绑定属性:

RAC(self.outputLabel, text) = RACObserve(self.model, name);

        上面的代码将label的输出和model的name属性绑定,实现联动,name但凡有变化都会使得label输出。


RACObserve 监听属性的改变,使用blockKVO

示例一:

[RACObserve(self.textField, text) subscribeNext: ^(NSString *newName){

    NSLog(@"%@",newName);

}];


示例二:

RAC(self.imageView, image) = [[RACObserve(self, photoModel.thumbnailData) ignore: nil] map: ^(NSData*data) {

        return [UIImage imageWithData: data];

}];


1.2.4 @weakify(Obj)  @strongify(Obj)

· @weakify(Obj)  @strongify(Obj)

        这对宏在 RACEXTScope.h中定义,RACFramework好像没有默认引入,需要单独import。他们的作用主要是在block内部管理对self的引用:

    @weakify(self);//定义了一个__weak的self_weak_变量

   [RACObserve(self, name) subscribeNext: ^(NSString *name) {

       @strongify(self);//局域定义了一个__strong的self指针指向self_weak

       self.outputLabel.text = name;

    }];

        这个宏其实就是一个啥都没干的@autoreleasepool {}前面的那个@,为了显眼罢了。这两个宏一定成对出现,先weak再strong


1.2.5 冷信号(Cold)和热信号(Hot)

    上面提到过这两个概念,冷信号默认什么也不干,比如下面这段代码

RACSignal *signal = [RACSignal createSignal: ^RACDisposable * (id subscriber) {

    NSLog(@"triggered");

    [subscriber sendNext: @"foobar"];

    [subscriber sendCompleted];

    return nil;

}];

    我们创建了一个Signal,但因为没有被subscribe,所以什么也不会发生。加了下面这段代码后,signal就处于Hot的状态了,block里的代码就会被执行。

[signal subscribeCompleted: ^{

    NSLog(@"subscription %u", subscriptions);

}];

        或许你会问,那如果这时又有一个新的subscriber了,signal的block还会被执行吗?这就牵扯到了另一个概念:Side Effect


1.2.6 简单信号创建实例

1.2.6.1 异步网络请求信号创建

+ (RACSignal*) rac_sendAsynchronousRequest: (NSURLRequest*)request {

     NSCParameterAssert(request != nil);


     return [[RACSignal createSignal: ^ RACDisposable * (id subscriber) {

             NSOperationQueue *queue = [[NSOperationQueue alloc] init];

             queue.name = @"com.github.ReactiveCocoa.NSURLConnectionRACSupport";


             [NSURLConnection sendAsynchronousRequest: request queue: queue completionHandler: ^(NSURLResponse *response, NSData *data, NSError*error) {

                   if (data == nil) {

                       [subscriber sendError: error];

                     }else{

                         [subscriber sendNext: RACTuplePack(response, data)];

                         [subscriber sendCompleted];

                     }

             }];


             return [RACDisposable disposableWithBlock: ^{

                 queue.suspended = YES;

                 [queue cancelAllOperations];

             }];

        }] setNameWithFormat: @"+rac_sendAsynchronousRequest: %@", request];

}

1.2.6.2 异步请求返回结果信号传递示例

+ (RACSignal*) requestPhotoData{

    NSURLRequest *request = [self popularURLRequest];


    return [[NSURLConnection rac_sendAsynchronousRequest: request] reduceEach: ^id(NSURLResponse *response, NSData *data){

        return data;

    }];

}

1.2.6.3 Image异步下载信号创建

// creates a signal that fetches an image in the background, delivering

// it on the UI thread. This signal 'cancels' itself if the cell is re-used before the

// image is downloaded.

- (RACSignal *) signalForImage: (NSURL*)imageUrl {

    RACScheduler *scheduler = [RACScheduler schedulerWithPriority: RACSchedulerPriorityBackground];


     RACSignal *imageDownloadSignal = [[RACSignal createSignal: ^RACDisposable *(id subscriber) {

         NSData *data = [NSData dataWithContentsOfURL: imageUrl];

         UIImage *image = [UIImage imageWithData: data];

         [subscriber sendNext: image];

         [subscriber sendCompleted];

         return nil;

    }] subscribeOn: scheduler];


     return [[imageDownloadSignal takeUntil: self.rac_prepareForReuseSignal] deliverOn:[RACScheduler mainThreadScheduler]];

}


1.2.7 高级信号创建方法示例

@implementationFRPPhotoImporter


+ (NSURLRequest*) popularURLRequest {

    return [[PXRequest apiHelper] urlRequestForPhotoFeature: PXAPIHelperPhotoFeaturePopular resultsPerPage: 100 page: 0 photoSizes: PXPhotoModelSizeThumbnail sortOrder: PXAPIHelperSortOrderRating except: PXPhotoModelCategoryNude];

}


+ (NSURLRequest*) photoURLRequest: (FRPPhotoModel*)photoModel {

    return [[PXRequest apiHelper] urlRequestForPhotoID: photoModel.identifier.integerValue];

}


+ (RACSignal*) requestPhotoData{

    NSURLRequest *request = [self popularURLRequest];


    return [[NSURLConnection rac_sendAsynchronousRequest: request] reduceEach: ^id(NSURLResponse *response, NSData*data){

        return data;

    }];

}


+ (RACSignal*) importPhotos {

    return [[[[[self requestPhotoData] deliverOn: [RACScheduler mainThreadScheduler]] map: ^id(NSData *data) {

        id results = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil];


        return [[[results[@"photos"] rac_sequence] map: ^id(NSDictionary *photoDictionary) {

            FRPPhotoModel *model = [FRPPhotoModel new];

            [self configurePhotoModel: model withDictionary: photoDictionary];

            [self downloadThumbnailForPhotoModel: model];

            return model;

        }] array];

    }] publish] autoconnect];

}


+ (RACSignal*) fetchPhotoDetails: (FRPPhotoModel*) photoModel {

    NSURLRequest *request = [self photoURLRequest: photoModel];

    return [[[[[[NSURLConnection rac_sendAsynchronousRequest: request] reduceEach: ^id(NSURLResponse *response, NSData *data){

        return data;

    }] deliverOn: [RACScheduler mainThreadScheduler]] map: ^id(NSData *data) {

        id results = [NSJSONSerialization JSONObjectWithData: data options:0 error: nil];

        [self configurePhotoModel: photoModel withDictionary: results];

        [self downloadFullsizedImageForPhotoModel: photoModel];


        return photoModel;

    }] publish] autoconnect];

}


+ (RACSignal*) logInWithUsername: (NSString *)username password: (NSString*)password {

    return [RACSignal createSignal: ^RACDisposable *(id subscriber) {

        [PXRequest authenticateWithUserName: username password: password completion: ^(BOOLsuccess) {

            if(success) {

                [subscriber sendCompleted];

            }else{

                [subscriber sendError: [NSError errorWithDomain: @"500px API" code: 0 userInfo: @{NSLocalizedDescriptionKey: @"Could not log in."}]];

            }

        }];


        // Cannot cancel request

        return nil;

    }];

}


+ (RACSignal*) voteForPhoto: (FRPPhotoModel*) photoModel {

    return [[[RACSignal createSignal: ^RACDisposable *(id subscriber) {

        PXRequest *voteRequest = [PXRequest requestToVoteForPhoto: [photoModel.identifier integerValue] completion: ^(NSDictionary *results, NSError *error) {

            if(error) {

                [subscriber sendError: error];

            }else{

                photoModel.votedFor = YES;

                [subscriber sendCompleted];

            }

        }];


        return [RACDisposable disposableWithBlock: ^{

            if (voteRequest.requestStatus == PXRequestStatusStarted) {

                [voteRequest cancel];

            }

        }];

    }] publish] autoconnect];

}


#pragma mark - Private Methods

+(void) configurePhotoModel: (FRPPhotoModel *)photoModel withDictionary: (NSDictionary*) dictionary {

    // Basics details fetched with the first, basic request

    photoModel.photoName = dictionary[@"name"];

    photoModel.identifier = dictionary[@"id"];

    photoModel.photographerName = dictionary[@"user"][@"username"];

    photoModel.rating = dictionary[@"rating"];

    photoModel.thumbnailURL = [self urlForImageSize: 3 inDictionary: dictionary[@"images"]];


    if (dictionary[@"voted"]) {

        photoModel.votedFor = [dictionary[@"voted"] boolValue];

    }


    // Extended attributes fetched with subsequent request

    if (dictionary[@"comments_count"]) {

        photoModel.fullsizedURL = [self urlForImageSize: 4 inDictionary: dictionary[@"images"]];

    }

}


+ (NSString*) urlForImageSize: (NSInteger)size inDictionary: (NSArray*) array {

    /*

    (

        {

            size = 3;

            url = "http://ppcdn.500px.org/49204370/b125a49d0863e0ba05d8196072b055876159f33e/3.jpg";

        }

     );

     */


    return [[[[[array rac_sequence] filter: ^BOOL(NSDictionary *value) {

        return [value[@"size"] integerValue] == size;

    }] map: ^id(id value) {

        return value[@"url"];

    }] array] firstObject];

}


+ (void) downloadThumbnailForPhotoModel: (FRPPhotoModel*) photoModel {

    RAC(photoModel, thumbnailData) = [self download: photoModel.thumbnailURL];

}


+(void) downloadFullsizedImageForPhotoModel: (FRPPhotoModel*) photoModel {

    RAC(photoModel, fullsizedData) = [self download: photoModel.fullsizedURL];

}


+(RACSignal*) download: (NSString*) urlString {

    NSAssert(urlString, @"URL must not be nil");

    NSURLRequest *request = [NSURLRequest requestWithURL: [NSURL URLWithString: urlString]];


    return [[[NSURLConnection rac_sendAsynchronousRequest: request] reduceEach: ^id(NSURLResponse *response, NSData *data){

        return data;

    }] deliverOn: [RACScheduler mainThreadScheduler]];

}


@end


1.3 常见使用问题

1.3.1 编译后报错_OBJC_CLASS_$_RVMViewModel

解决方法:

        看错误提示:你的项目设置里 other linker flags 选项覆盖了pod定义的设置,导致了问题的存在。解决办法:

        在other linker flags里添加一行 $(inherited).


升级AFNetworking 从2.4到2.5后编译报错

http://www.cocoachina.com/bbs/read.php?tid-279299.html


1.3.2 属性值绑定后无法监听到后续修改

        属性值的改动监听其实是基于KVO的,属性值改动时,一定要以self.***的形式赋值才能触发信号,如果仅以内部成员的形式来赋值,则无法触发信号。

WS(weakSelf);

    _isLoadingVMArray = YES;

    [HJUtilityInstance reloadEntityArrayWithCompleteBlock:^(HJResultData*reData){

        _isLoadingVMArray = NO;(无法触发信号)

        weakSelf.isLoadingVMArray = NO;(可以触发信号)

    }];


1.3.3 重复绑定处理Signalis already bound to key path

(Good)http://stackoverflow.com/questions/22869109/signal-is-already-bound-to-key-path


The problem was that I did not call [subscriber setCompleted] to terminate the subscription.

解决重复绑定方法:

/**

 *  加载频道图片

 */

- (void) bindIcon{

    WS(weakSelf);

    RACSignal*signal = RACObserve(_channelInfoVM, channelIconString);

    [signal subscribeNext: ^(NSString*imgUrl) {

        [[weakSelf getIconImageView] sd_setImageWithURL: [NSURL URLWithString: imgUrl] placeholderImage: [UIImage imageNamed: @"ChannelDefaultImage"]];

    }];

    [signal takeUntil: [self rac_signalForSelector: @selector(bindIcon)]];

}


2 最佳实践

2.1 设计原则

An Introduction to ReactiveCocoa: A BigNerd Ranch Tech Talk

https://vimeo.com/78749139

ReactiveCocoa入门教程:第一部分

http://www.cocoachina.com/ios/20150123/10994.html

Reactive Cocoa Tutorial [0] = "Overview"

http://www.cnblogs.com/sunnyxx/p/3543542.html

Reactive Cocoa Tutorial [1] = "神奇的Macros"

http://www.cnblogs.com/sunnyxx/p/3544703.html

Reactive Cocoa Tutorial [2] = "百变RACStream"

http://www.cnblogs.com/sunnyxx/p/3547754.html

Reactive Cocoa Tutorial [3] = "RACSignal的巧克力工厂

http://www.cnblogs.com/sunnyxx/p/3547763.html


Reactive Cocoa Tutorial [4] =只取所需的Filters

http://www.cnblogs.com/sunnyxx/p/3676689.html

2.2 Demo示例工程说明

ReactiveCocoaDemo-master

        此工程不错,里面有如何绑定TableViewCell的使用说明

2.3 MVVM理解

2.3.1 参考链接

Model-View-ViewModel for iOS

http://www.teehanlax.com/blog/model-view-viewmodel-for-ios/

(Good)用Model-View-ViewModel构建iOSApp

http://www.cocoachina.com/ios/20140716/9152.html

KVOController

https://github.com/facebook/KVOController

(Good)MVVMwithout ReactiveCocoa

http://www.cocoachina.com/ios/20151020/13795.html

一个MVVM架构的iOS工程

https://github.com/lizelu/MVVM

浅谈iOS中MVVM的架构设计与团队协作

http://www.cocoachina.com/ios/20150122/10987.html

MVVM之解

http://bbs.csdn.net/topics/390696674

第9讲:MVVM架构

http://www.cnblogs.com/cdts_change/archive/2010/11/28/1890584.html

MVVM

http://baike.baidu.com/link?url=8F5nFTnRJIZHbybQtR5ibq97nd-0aCgA89hRw9dHyPtWBGb_vfNdhrciM5-BiDs51hNQzYnq2gLVKuqEfX3_eK


3 参考链接

RAC中文资源列表

https://github.com/ReactiveCocoaChina/ReactiveCocoaChineseResources

春节研究ReactiveCocoa,写了一个面向初学者的入门介绍:

http://www.cocoachina.com/bbs/read.php?tid=183897

Model-View-ViewModel for iOS

http://www.teehanlax.com/blog/model-view-viewmodel-for-ios/

Model-View-ViewModel for iOS [译]

http://www.cnblogs.com/brycezhang/p/3840567.html

(good)ReactiveCocoa2实战

http://www.cocoachina.com/industry/20140609/8737.html

https://github.com/ReactiveCocoa/ReactiveCocoa/blob/master/Documentation/BasicOperators.md

(Good)ReactiveCocoa

http://ju.outofmemory.cn/entry/103472

IOS响应式编程框架ReactiveCocoa(RAC)使用示例

http://blog.csdn.net/dfqin/article/details/39164241

ReactiveCocoa函数式响应编程简介一

http://wenku.baidu.com/link?url=RtwVmjAop1mxsxGcqHQFDgEMtYca2KXLR5SQlN8cl9RSDz1i0VmGY0YnarffFPSnVaT7aXb1rmAtNWvRsVc4nwP3TbJBfZtxMS3ZVugKT9e

http://cocoadocs.org/docsets/ReactiveCocoa/2.3.1/Classes/RACTuple.html

使用ReactiveCocoa实现iOS平台响应式编程

http://blog.csdn.net/xdrt81y/article/details/30624469

ReactiveCocoa与Functional Reactive Programming

http://limboy.me/ios/2013/06/19/frp-reactivecocoa.html

说说ReactiveCocoa2

http://limboy.me/ios/2013/12/27/reactivecocoa-2.html

基于AFNetworking2.0和ReactiveCocoa2.1的iOSREST Client

http://limboy.me/ios/2014/01/05/ios-rest-client-implementation.html

An Introduction to ReactiveCocoa: A BigNerd Ranch Tech Talk

https://vimeo.com/78749139

Remove a ReactiveCocoa signal from a control

http://stackoverflow.com/questions/19650802/remove-a-reactivecocoa-signal-from-a-control

RAC and cell reuse: putting deliverOn: in the right place?

http://stackoverflow.com/questions/27172874/rac-and-cell-reuse-putting-deliveron-in-the-right-place

Reactive Cocoa and multiple AFNetworking requests in shortperiod of time

http://stackoverflow.com/questions/23059512/reactive-cocoa-and-multiple-afnetworking-requests-in-short-period-of-time

ReactiveCocoa binding “networkActivityIndicator” Crushes

http://stackoverflow.com/questions/25124722/reactivecocoa-binding-networkactivityindicator-crushes

http://stackoverflow.com/search?q=signal+is+already+bound+to+key+path

Replacing the Objective-C “Delegate Pattern” withReactiveCocoa

http://spin.atomicobject.com/2014/02/03/objective-c-delegate-pattern/

http://blog.bignerdranch.com/4549-data-driven-ios-development-reactivecocoa/

http://en.wikipedia.org/wiki/Functional_reactive_programming

http://www.teehanlax.com/blog/reactivecocoa/

http://www.teehanlax.com/blog/getting-started-with-reactivecocoa/

http://nshipster.com/reactivecocoa/

http://cocoasamurai.blogspot.com/2013/03/basic-mvvm-with-reactivecocoa.html

http://iiiyu.com/2013/09/11/learning-ios-notes-twenty-eight/

https://speakerdeck.com/andrewsardone/reactivecocoa-at-mobidevday-2013

http://msdn.microsoft.com/en-us/library/hh848246.aspx

http://www.itiger.me/?p=38

http://blog.leezhong.com/ios/2013/12/27/reactivecocoa-2.html

https://github.com/ReactiveCocoa/ReactiveCocoa/blob/master/Documentation/FrameworkOverview.md

http://www.haskell.org/haskellwiki/Functional_Reactive_Programming

http://blog.zhaojie.me/2009/09/functional-reactive-programming-for-csharp.html

https://github.com/Machx/MVVM-IOS-Example

https://github.com/ReactiveCocoa/RACiOSDemo

https://leanpub.com/iosfrp

ashfurrow/C-41

https://github.com/AshFurrow/C-41

http://vimeo.com/65637501

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容