Шаблон ViewModel для приложений iOS с ReactiveCocoa
Я работаю над интеграцией RAC в свой проект с целью создания слоя ViewModel, который позволит легко кэшировать/предварительно загружать из сети (плюс все другие преимущества MVVM). Я еще не знаком с MVVM или FRP, и я пытаюсь разработать хороший, многоразовый шаблон для разработки iOS. У меня есть пара вопросов об этом.
Во-первых, это то, как я добавил ViewModel в один из моих представлений, просто чтобы попробовать. (Я хочу, чтобы это здесь упоминалось позже).
В представлении ViewControllerDidLoad:
@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];
}
}];
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;
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);
}];
}
Я правильно понимаю, как я использую сигналы? В частности, имеет ли смысл иметь bioSignal
для обновления данных, а также hiddenBioSignal
для прямого привязки к скрытому свойству textView?
Мой основной вопрос связан с проблемами, которые были бы решены делегатами в ViewModel (надеюсь). Делегаты настолько распространены в мире iOS, что я хотел бы найти лучшее или даже просто умеренно выполнимое решение для этого.
Для UITableView, например, нам необходимо предоставить как делегат, так и источник данных. Должен ли я иметь свойство на контроллере NSUInteger numberOfRowsInTable
и привязывать его к сигналу на ViewModel? И я действительно не понимаю, как использовать RAC для обеспечения моего TableView ячейками в tableView: cellForRowAtIndexPath:
. Нужно ли мне просто делать это "традиционным" способом или возможно иметь какой-то провайдер сигналов для ячеек? Или, может быть, лучше оставить его как есть, потому что ViewModel не должен действительно заботиться о создании представлений, просто изменяя источник представлений?
Кроме того, есть ли лучший подход, чем мое использование предмета (fetchDoctorSubject)?
Любые другие комментарии также будут оценены. Целью этой работы является создание уровня preMetching/кэширования ViewModel, который может сигнализироваться при необходимости для загрузки данных в фоновом режиме и, таким образом, сокращения времени ожидания на устройстве. Если из этого выйдет что-нибудь многоразовое (кроме шаблона), оно, конечно, будет открытым исходным кодом.
Изменить: И еще один вопрос: похоже, согласно документации, я должен использовать свойства для всех сигналов в моей модели ViewModel вместо методов? Думаю, я должен настроить их в init? Или я должен оставить его как есть, чтобы геттеры возвращали новые сигналы?
Должен ли я иметь свойство active
, как в примере ViewModel в учетной записи gigub ReactiveCocoa?
Ответы
Ответ 1
Модель представления должна моделировать представление. Иными словами, он не должен диктовать внешний вид самого вида, но логика любого вида внешнего вида. Он не должен знать ничего о представлении напрямую. Это общий руководящий принцип.
Включение в некоторые особенности.
Похоже, согласно документации, я должен использовать свойства для всех сигналов в моей модели ViewModel вместо методов? Думаю, я должен настроить их в init? Или я должен оставить его как есть, чтобы геттеры возвращали новые сигналы?
Да, мы обычно просто используем свойства, которые отражают их свойства модели. Мы сконфигурировали их в -init
вроде:
- (id)init {
self = [super init];
if (self == nil) return nil;
RAC(self.title) = RACAbleWithStart(self.model.title);
return self;
}
Помните, что модели просмотра - это всего лишь модели для конкретного использования. Обычные старые объекты с простыми старыми свойствами.
Я правильно понимаю, как я использую сигналы? В частности, имеет ли смысл иметь bioSignal
для обновления данных, а также hiddenBioSignal
для прямого привязки к скрытому свойству textView?
Если скрытность биосигнала обусловлена определенной логикой модели, было бы разумно разоблачить ее как свойство в модели представления. Но постарайтесь не думать об этом с точки зрения таких понятий, как скрытность. Возможно, это больше касается действительности, загрузки и т.д. Что-то не связано конкретно с тем, как оно представлено.
Для UITableView, например, нам необходимо предоставить как делегат, так и источник данных. Должен ли я иметь свойство на контроллере NSUInteger numberOfRowsInTable и привязывать его к сигналу на ViewModel? И я действительно не понимаю, как использовать RAC для обеспечения моего TableView ячейками в tableView: cellForRowAtIndexPath:. Нужно ли мне просто делать это "традиционным" способом или возможно иметь какой-то провайдер сигналов для ячеек? Или, может быть, лучше оставить его как есть, потому что ViewModel не должен действительно заботиться о создании представлений, просто изменяя источник представлений?
Эта последняя строка в точности верна. Ваша модель представления должна предоставить контроллеру представления данные для отображения (массив, набор, независимо), но ваш контроллер представления по-прежнему является делегатом представления таблиц и источником данных. Контроллер представления создает ячейки, но ячейки заполняются данными из модели представления. У вас может быть даже модель ячейки, если ваши клетки относительно сложны.
Кроме того, есть ли лучший подход, чем мое использование предмета (fetchDoctorSubject)?
Рассмотрим вместо этого RACCommand
. Это даст вам более удобный способ обработки одновременных запросов, ошибок и безопасности потоков. Команды являются довольно типичным способом передачи информации из представления в модель представления.
Должен ли я иметь активное свойство, как в примере ViewModel в учетной записи gigum ReactiveCocoa?
Это зависит от того, нужно ли вам это. На iOS это, вероятно, менее необходимо, чем OS X, где вы можете иметь несколько видов и просматривать модели, но не "активно" сразу.
Надеюсь, это было полезно. Похоже, вы вообще направляетесь в правильном направлении!
Ответ 2
Для UITableView, например, нам необходимо предоставить как делегат, так и источник данных. Должен ли я иметь свойство на моем контроллере NSUInteger numberOfRowsInTable и привязать его к сигналу на ViewModel?
Стандартный подход, описанный joshaber выше, заключается в том, чтобы вручную реализовать источник данных и делегировать его в вашем контроллере вида, при этом модель представления просто выставляет массив элементов каждый из которых представляет собой модель представления, которая поддерживает ячейку представления таблицы.
Однако, это приводит к большому количеству плиты котла в вашем элегантном контроллере.
Я создал простой помощник привязки, который позволяет привязать NSArray моделей вида к представлению таблицы всего несколькими строками код:
// create a cell template
UINib *nib = [UINib nibWithNibName:@"CETweetTableViewCell" bundle:nil];
// bind the ViewModels 'searchResults' property to a table view
[CETableViewBindingHelper bindingHelperForTableView:self.searchResultsTable
sourceSignal:RACObserve(self.viewModel, searchResults)
templateCell:nib];
Он также обрабатывает выбор, выполняя команду при выборе строки. Полный код в моем блоге. Надеюсь, это поможет!