具有ReactiveCocoa的iOS应用程序的ViewModel模式

Eva*_*ell 28 mvvm ios reactive-cocoa

我正在努力将RAC集成到我的项目中,目标是创建一个ViewModel层,允许从网络轻松缓存/预取(以及MVVM的所有其他好处).我对MVVM或FRP还不是特别熟悉,我正在尝试为iOS开发开发一个漂亮的,可重用的模式.我有几个问题.

首先,这就是我将ViewModel添加到我的一个视图中的方式,只是为了尝试一下.(我希望这里稍后参考).

在ViewController viewDidLoad中:

@weakify(self)

//Setup signals
RAC(self.navigationItem.title) = self.viewModel.nameSignal;
RAC(self.specialtyLabel.text) = self.viewModel.specialtySignal;
RAC(self.bioButton.hidden) = self.viewModel.hiddenBioSignal;
RAC(self.bioTextView.text) = self.viewModel.bioSignal;

RAC(self.profileImageView.hidden) = self.viewModel.hiddenProfileImageSignal;    

[self.profileImageView rac_liftSelector:@selector(setImageWithContentsOfURL:placeholderImage:) withObjectsFromArray:@[self.viewModel.profileImageSignal, [RACTupleNil tupleNil]]];

[self.viewModel.hasOfficesSignal subscribeNext:^(NSArray *offices) {
    self.callActionSheet = [[UIActionSheet alloc] initWithTitle:@"Choose Office" delegate:self cancelButtonTitle:nil destructiveButtonTitle:nil otherButtonTitles:nil];
    self.directionsActionSheet = [[UIActionSheet alloc] initWithTitle:@"Choose Office" delegate:self cancelButtonTitle:nil destructiveButtonTitle:nil otherButtonTitles:nil];
    self.callActionSheet.delegate = self;
    self.directionsActionSheet.delegate = self;
}];

[self.viewModel.officesSignal subscribeNext:^(NSArray *offices){
    @strongify(self)
    for (LMOffice *office in offices) {
        [self.callActionSheet addButtonWithTitle: office.name ? office.name : office.address1];
        [self.directionsActionSheet addButtonWithTitle: office.name ? office.name : office.address1];

        //add offices to maps
        CLLocationCoordinate2D coordinate = {office.latitude.doubleValue, office.longitude.doubleValue};
        MKPointAnnotation *point = [[MKPointAnnotation alloc] init];
        point.coordinate = coordinate;
        [self.mapView addAnnotation:point];
    }

    //zoom to include all offices
    MKMapRect zoomRect = MKMapRectNull;
    for (id <MKAnnotation> annotation in self.mapView.annotations)
    {
        MKMapPoint annotationPoint = MKMapPointForCoordinate(annotation.coordinate);
        MKMapRect pointRect = MKMapRectMake(annotationPoint.x, annotationPoint.y, 0.2, 0.2);
        zoomRect = MKMapRectUnion(zoomRect, pointRect);
    }
    [self.mapView setVisibleMapRect:zoomRect animated:YES];
}];

[self.viewModel.openingsSignal subscribeNext:^(NSArray *openings) {
    @strongify(self)
    if (openings && openings.count > 0) {
        [self.openingsTable reloadData];
    }
}];
Run Code Online (Sandbox Code Playgroud)

ViewModel.h

@property (nonatomic, strong) LMProvider *doctor;
@property (nonatomic, strong) RACSubject *fetchDoctorSubject;

- (RACSignal *)nameSignal;
- (RACSignal *)specialtySignal;
- (RACSignal *)bioSignal;
- (RACSignal *)profileImageSignal;
- (RACSignal *)openingsSignal;
- (RACSignal *)officesSignal;

- (RACSignal *)hiddenBioSignal;
- (RACSignal *)hiddenProfileImageSignal;
- (RACSignal *)hasOfficesSignal;
Run Code Online (Sandbox Code Playgroud)

ViewModel.m

- (id)init {
    self = [super init];
    if (self) {
        _fetchDoctorSubject = [RACSubject subject];

        //fetch doctor details when signalled
        @weakify(self)
        [self.fetchDoctorSubject subscribeNext:^(id shouldFetch) {
            @strongify(self)
            if ([shouldFetch boolValue]) {
                [self.doctor fetchWithCompletion:^(NSError *error){
                    if (error) {
                        //TODO: display error message
                        NSLog(@"Error fetching single doctor info: %@", error);
                    }
                }];
            }
        }];
    }
    return self;
}

- (RACSignal *)nameSignal {
    return [RACAbleWithStart(self.doctor.displayName) distinctUntilChanged];
}

- (RACSignal *)specialtySignal {
    return [RACAbleWithStart(self.doctor.primarySpecialty.name) distinctUntilChanged];
}

- (RACSignal *)bioSignal {
    return [RACAbleWithStart(self.doctor.bio) distinctUntilChanged];
}

- (RACSignal *)profileImageSignal {
    return [[[RACAbleWithStart(self.doctor.profilePhotoURL) distinctUntilChanged]
            map:^id(NSURL *url){
                if (url && ![url.absoluteString hasPrefix:@"https:"]) {
                    url = [NSURL URLWithString:[NSString stringWithFormat:@"https:%@", url.absoluteString]];
                }
                return url;
            }]
            filter:^BOOL(NSURL *url){
                return (url != nil && ![url.absoluteString isEqualToString:@""]);
            }];
}

- (RACSignal *)openingsSignal {
    return [RACAbleWithStart(self.doctor.openings) distinctUntilChanged];
}

- (RACSignal *)officesSignal {
    return [RACAbleWithStart(self.doctor.offices) distinctUntilChanged];
}

- (RACSignal *)hiddenBioSignal {
    return [[self bioSignal] map:^id(NSString *bioString) {
        return @(bioString == nil || [bioString isEqualToString:@""]);
    }];
}

- (RACSignal *)hiddenProfileImageSignal {
    return [[self profileImageSignal] map:^id(NSURL *url) {
        return @(url == nil || [url.absoluteString isEqualToString:@""]);
    }];
}

- (RACSignal *)hasOfficesSignal {
    return [[self officesSignal] map:^id(NSArray *array) {
        return @(array.count > 0);
    }];
}
Run Code Online (Sandbox Code Playgroud)

我是否正确使用信号?具体来说,必须bioSignal更新数据以及hiddenBioSignal直接绑定到textView的隐藏属性是否有意义?

我的主要问题是将代理人处理的问题转移到ViewModel(希望如此).代表们在iOS世界中非常普遍,我想找出最好的,甚至只是一个适度可行的解决方案.

例如,对于UITableView,我们需要提供委托和dataSource.我应该在控制器上有属性NSUInteger numberOfRowsInTable并将其绑定到ViewModel上的信号吗?我真的不清楚如何使用RAC为我的TableView提供单元格tableView: cellForRowAtIndexPath:.我只需要以"传统"的方式进行这些操作,还是可以为细胞提供某种信号提供者?或者也许最好留下它是怎么回事,因为ViewModel不应该真的关心构建视图,只是修改视图的来源?

此外,有没有比我使用主题更好的方法(fetchDoctorSubject)?

任何其他意见也将受到赞赏.这项工作的目标是制作一个预取/缓存ViewModel层,可以在需要时在后台加载数据时发出信号,从而减少设备上的等待时间.如果有任何可重用的东西(除了模式之外),它当然是开源的.

编辑:另一个问题:看起来根据文档,我应该使用我的ViewModel中的所有信号的属性而不是方法?我想我应该在init中配置它们?或者我应该保持原样让吸气剂返回新信号?

我应该active在ReactiveCocoa的github帐户中的ViewModel示例中拥有属性吗?

jos*_*ber 36

视图模型应该为视图建模.也就是说,它不应该决定任何视图外观本身,而是视图外观背后的逻辑.它不应该直接了解视图.这是一般指导原则.

关于一些细节.

看起来根据文档,我应该使用ViewModel中所有信号的属性而不是方法?我想我应该在init中配置它们?或者我应该保持原样让吸气剂返回新信号?

是的,我们通常只使用镜像其模型属性的属性.我们将它们配置为-init:

- (id)init {
    self = [super init];
    if (self == nil) return nil;

    RAC(self.title) = RACAbleWithStart(self.model.title);

    return self;    
}
Run Code Online (Sandbox Code Playgroud)

请记住,视图模型只是特定用途的模型.具有普通旧属性的普通旧对象.

我是否正确使用信号?具体来说,必须bioSignal更新数据以及hiddenBioSignal直接绑定到textView的隐藏属性是否有意义?

如果生物信号的隐藏性是由某些特定的模型逻辑驱动的,那么将它作为视图模型上的属性公开是有意义的.但是,尽量不要将其视为隐藏的视角.也许它更多的是关于有效性,加载等等.某些东西与具体如何呈现无关.

例如,对于UITableView,我们需要提供委托和dataSource.我应该在我的控制器NSUInteger numberOfRowsInTable上有一个属性并将其绑定到ViewModel上的信号吗?我真的不清楚如何使用RAC在tableView中为我的TableView提供单元格:cellForRowAtIndexPath:.我只需要以"传统"的方式进行这些操作,还是可以为细胞提供某种信号提供者?或者也许最好留下它是怎么回事,因为ViewModel不应该真的关心构建视图,只是修改视图的来源?

最后一行是完全正确的.您的视图模型应该为视图控制器提供要显示的数据(数组,集,等等),但您的视图控制器仍然是表视图的委托和数据源.视图控制器创建单元格,但单元格由视图模型中的数据填充.如果您的单元格相对复杂,您甚至可以使用单元格视图模型.

此外,有没有比我使用主题更好的方法(fetchDoctorSubject)?

考虑在RACCommand这里使用.它将为您提供处理并发请求,错误和线程安全的更好方法.命令是从视图到视图模型进行通信的一种非常典型的方式.

我应该在ReactiveCocoa的github帐户中的ViewModel示例中有活动属性吗?

这取决于你是否需要它.在iOS上,它可能不太常见于OS X,在那里你可以有多个视图和分配的视图模型,但不能同时"激活".

希望这有用.看起来你一般都朝着正确的方向前进!