如何在RAC MVVM中正确分离ViewModel和ViewController

时间:2014-04-18 19:19:51

标签: ios mvvm reactive-cocoa

我刚刚开始更新我的ReactiveCocoa应用程序以使用MVVM模式,并且有一些关于ViewController和ViewModel之间边界的问题以及ViewController应该是多么愚蠢。

我正在更新的应用程序的第一部分是登录流程,其行为如下。

  • 用户输入电子邮件地址,密码并触摸登录按钮
  • 成功的回复包含一个或多个User模型
  • 显示这些User模型以及退出按钮
  • 在关闭登录视图并显示主视图之前,必须为会话选择User模型。

在MVVM之前

  • LoginViewController直接处理LoginButton命令
  • LoginButton命令直接与SessionManager
  • 对话
  • LoginViewController显示UIActionSheet,用于选择User模型或退出
  • LoginViewController的用户选择和退出功能直接与SessionManager
  • 对话

MVVM之后

  • LoginViewModel公开登录命令以及用户选择和注销方法
  • LoginViewModel用户选择和注销方法直接与SessionManager
  • 对话
  • LoginViewControllerLoginViewModel
  • 的登录命令作出反应
  • LoginViewController显示UIActionSheet,用于选择User模型或退出
  • LoginViewControllerLoginViewModel
  • 对话的用户选择和退出功能

LoginViewModel.h

@interface LoginViewModel : RVMViewModel

@property (strong, nonatomic, readonly) RACCommand *loginCommand;
@property (strong, nonatomic, readonly) RACSignal *checkingSessionSignal;
@property (strong, nonatomic, readonly) NSArray *users;
@property (strong, nonatomic) NSString *email;
@property (strong, nonatomic) NSString *password;

- (void)logout;
- (void)switchToUserAtIndex:(NSUInteger)index;

@end

LoginViewModel.m

@implementation LoginViewModel

- (instancetype)init {
    self = [super init];
    if (self) {
        @weakify(self);

        // Set up the login command
        self.loginCommand = [[RACCommand alloc] initWithEnabled:[self loginEnabled]
                                                    signalBlock:^RACSignal *(id input) {
            @strongify(self);
            [[[SessionManager sharedInstance] loginWithEmail:self.email
                                                    password:self.password]
             subscribeNext:^(NSArray *users) {
                 self.users = users;
             }];

            return [RACSignal empty];
        }];

        // Observe the execution state of the login command
        self.loggingIn = [[self.loginCommand.executing first] boolValue];
    }
    return self;
}

- (void)logout {
    [[SessionManager sharedInstance] logout];
}

- (void)switchToUserAtIndex:(NSUInteger)index {
    if (index < [self.users count]) {
        [[SessionManager sharedInstance] switchToUser:self.users[index]];
    }
}

- (RACSignal *)loginEnabled {
    return [RACSignal
            combineLatest:@[
                RACObserve(self, email),
                RACObserve(self, password),
                RACObserve(self, loggingIn)
            ]
            reduce:^(NSString *email, NSString *password, NSNumber *loggingIn) {
                return @([email length] > 0 &&
                         [password length] > 0 &&
                         ![loggingIn boolValue]);
            }];
}

@end

LoginViewController.m

- (void)viewDidLoad {
    [super viewDidLoad];

    @weakify(self);

    // Bind to the view model
    RAC(self.controlsContainerView, hidden) = self.viewModel.checkingSessionSignal;
    RAC(self.viewModel, email) = self.emailField.rac_textSignal;
    RAC(self.viewModel, password) = self.passwordField.rac_textSignal;
    self.loginButton.rac_command = self.viewModel.loginCommand;
    self.forgotPasswordButton.rac_command = self.viewModel.forgotPasswordCommand;

    // Respond to the login command execution
    [[RACObserve(self.viewModel, users)
     skip:1]
     subscribeNext:^(NSArray *users) {
         @strongify(self);

         if ([users count] == 0) {
             [Utils presentMessage:@"Sorry, there appears to be a problem with your account."
                         withTitle:@"Login Error"
                             level:MessageLevelError];
         } else if ([users count] == 1) {
             [self.viewModel switchToUserAtIndex:0];
         } else {
             [self showUsersList:users];
         }
     }];

    // Respond to errors from the login command
    [self.viewModel.loginCommand.errors
     subscribeNext:^(id x) {
         [Utils presentMessage:@"Sorry, your login credentials are incorrect."
                     withTitle:@"Login Error"
                         level:MessageLevelError];
     }];
}

- (void)showUsersList:(NSArray *)users {
    CCActionSheet *sheet = [[CCActionSheet alloc] initWithTitle:@"Select Organization"];

    // Add buttons for each of the users
    [users eachWithIndex:^(User *user, NSUInteger index) {
        [sheet addButtonWithTitle:user.organisationName block:^{
            [self.viewModel switchToUserAtIndex:index];
        }];
    }];

    // Add a button for cancelling/logging out
    [sheet addCancelButtonWithTitle:@"Logout" block:^{
        [self.viewModel logout];
    }];

    // Display the action sheet
    [sheet showInView:self.view];
}

@end

问题

  1. 创建其他ViewModel层意味着我需要代理SessionManager次调用。我想将LoginViewControllerSessionManager分离的好处超过了ViewModel层的额外代码和函数调用?
  2. LoginViewController了解User模型,以便显示可以选择的用户列表。这打破了MVVM模式,当然感觉不对。 LoginViewModel是否应仅提取User所需的LoginViewController模型的必要属性并将其添加到字典中,其数组将返回到LoginViewController?或者在LoginViewModel上有一个方法会更好,它返回给定索引的用户名,允许LoginViewController显示此名称?我知道ViewModel负责弥合模型和视图之间的差距,但这确实感觉像是双重处理。根据我在第一个问题中的预感,我认为分离这些问题的好处远远超过了稍微费力的映射过程。
  3. 如果LoginViewModel调用SessionManager中包含的所有功能,仅仅针对LoginViewModel编写测试就足够了,还是应该针对SessionManager专门编写测试?

1 个答案:

答案 0 :(得分:1)

现在已经很晚了,我相信你已经离开了。

1)从视图/控件中移出程序逻辑总是值得你需要写入代理的额外几行锥形。 MVVM的目的是鼓励关注点分离,并通过ViewModel在View / Controller和Model之间提供清晰的数据通道。

从View / Controller的角度来看,View Models应该执行以下功能:

充当您的视图/控制器可以在不执行任何业务规则的情况下利用的黑匣子数据,并始终假设数据是正确的。

充当用户输入处理的渠道,无需执行任何业务规则即可获取用户输入。

2)在我的MVVM实现中,我尝试遵循这个范例:包含CollectionView / TableView的视图/控制器是父视图,单元格是子视图。因此,您应该拥有一个父ViewModel,其职责是初始化和管理子ViewModel。

在您的情况下,您没有使用Collection / Table视图,但概念是相同的。您应该向父视图模型询问子ViewModel的列表,您可以将其传递到另一个视图以进行利用。根据答案#1中的要点,父视图模型应确保正确初始化子视图模型,以便子视图不需要担心任何数据验证。

3)在测试View Model的数据验证/规则时,您可以完全存根会话管理器并仅测试视图模型。我所做的是创建断言,在我的单元测试中适当地调用存根/模拟的会话管理器函数。