medium ios mvvm reactive-cocoa

medium - Un patrón de ViewModel para aplicaciones iOS con ReactiveCocoa



mvvm swift medium (2)

Estoy trabajando en la integración de RAC en mi proyecto con el objetivo de crear una capa de ViewModel que permita un fácil almacenamiento en caché / recuperación previa de la red (además de todas las demás ventajas de MVVM). Todavía no estoy especialmente familiarizado con MVVM o FRP, y estoy tratando de desarrollar un patrón agradable y reutilizable para el desarrollo de iOS. Tengo un par de preguntas sobre esto.

En primer lugar, esta es una especie de cómo he agregado un ViewModel a una de mis vistas, solo para probarlo. (Quiero que esto aquí para referencia más adelante).

En 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]; } }];

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); }]; }

¿Estoy en lo cierto en el uso de señales? Específicamente, ¿tiene sentido tener bioSignal para actualizar los datos, así como un hiddenBioSignal para enlazar directamente a la propiedad oculta de un textView?

Mi pregunta principal viene con inquietudes en movimiento que los delegados manejarían en el ViewModel (con suerte). Los delegados son tan comunes en el mundo iOS que me gustaría encontrar la mejor solución, o incluso solo una solución moderadamente viable.

Para una UITableView, por ejemplo, debemos proporcionar tanto un delegado como un dataSource. ¿Debo tener una propiedad en mi controlador NSUInteger numberOfRowsInTable y vincularla a una señal en ViewModel? Y no tengo muy claro cómo usar RAC para proporcionar mi TableView con celdas en tableView: cellForRowAtIndexPath: ¿Tengo que hacer esto de la manera "tradicional" o es posible tener algún tipo de proveedor de señal para las células? ¿O tal vez es mejor dejarlo como está, porque un ViewModel realmente no debería preocuparse por construir vistas, simplemente modificando el origen de las vistas?

Además, ¿hay un mejor enfoque que mi uso de un tema (fetchDoctorSubject)?

Cualquier otro comentario sería apreciado también. El objetivo de este trabajo es crear una capa de vista previa / almacenamiento en caché de ViewModel que se pueda señalar siempre que sea necesario para cargar datos en segundo plano, y así reducir los tiempos de espera en el dispositivo. Si algo sale reutilizable de esto (que no sea un patrón) será, por supuesto, de código abierto.

Editar: Y otra pregunta: parece que de acuerdo con la documentación, ¿debería usar propiedades para todas las señales en mi ViewModel en lugar de usar métodos? Creo que debo configurarlos en init? ¿O debería dejarlo tal como está para que los captadores devuelvan nuevas señales?

¿Debería tener una propiedad active como en el ejemplo de ViewModel en la cuenta github de ReactiveCocoa?


Para una UITableView, por ejemplo, debemos proporcionar tanto un delegado como un dataSource. ¿Debo tener una propiedad en mi controlador NSUInteger numberOfRowsInTable y vincularla a una señal en ViewModel?

El enfoque estándar, como lo describe joshaber arriba, es implementar manualmente la fuente de datos y delegar dentro de su controlador de vista, con el modelo de vista simplemente exponiendo una matriz de elementos, cada uno de los cuales representa un modelo de vista que respalda una celda de vista de tabla.

Sin embargo, esto da como resultado una gran cantidad de placa de caldera en su controlador de vista de lo contrario elegante.

He creado un auxiliar de enlace simple que le permite vincular un NSArray de modelos de vista a una vista de tabla con solo unas pocas líneas de código:

// 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];

También maneja la selección, ejecutando un comando cuando se selecciona una fila. El código completo ha terminado en mi blog . ¡Espero que esto ayude!


El modelo de vista debe modelar la vista. Lo que quiere decir que no debe dictar ninguna apariencia de vista en sí misma, sino la lógica detrás de la apariencia de la vista. No debería saber nada sobre la vista directamente. Ese es el principio rector general.

En algunos detalles.

Parece que de acuerdo con la documentación, ¿debería usar propiedades para todas las señales en mi ViewModel en lugar de usar métodos? Creo que debo configurarlos en init? ¿O debería dejarlo tal como está para que los captadores devuelvan nuevas señales?

Sí, normalmente solo usamos propiedades que reflejan las propiedades de su modelo. Los configuraríamos en -init like:

- (id)init { self = [super init]; if (self == nil) return nil; RAC(self.title) = RACAbleWithStart(self.model.title); return self; }

Recuerde que los modelos de vista son solo modelos para un uso específico. Viejos objetos simples con propiedades antiguas simples.

¿Estoy en lo cierto en el uso de señales? Específicamente, ¿tiene sentido tener bioSignal para actualizar los datos, así como un hiddenBioSignal para enlazar directamente a la propiedad oculta de un textView?

Si la ocultación de la señal de la biografía es impulsada por alguna lógica de modelo específica, tendría sentido exponerla como una propiedad en el modelo de visualización. Pero trate de no pensar en términos de ocultamiento. Tal vez se trata más de validez, carga, etc. Algo no relacionado específicamente con la forma en que se presenta.

Para una UITableView, por ejemplo, debemos proporcionar tanto un delegado como un dataSource. ¿Debo tener una propiedad en mi controlador NSUInteger numberOfRowsInTable y vincularla a una señal en ViewModel? Y no tengo muy claro cómo usar RAC para proporcionar mi TableView con celdas en tableView: cellForRowAtIndexPath :. ¿Tengo que hacer esto de la manera "tradicional" o es posible tener algún tipo de proveedor de señal para las células? ¿O tal vez es mejor dejarlo como está, porque un ViewModel realmente no debería preocuparse por construir vistas, simplemente modificando el origen de las vistas?

Esa última línea es exactamente correcta. Su modelo de vista debe dar al controlador de vista los datos para mostrar (una matriz, conjunto, lo que sea), pero su controlador de vista sigue siendo el delegado y fuente de datos de la vista de tabla. El controlador de vista crea celdas, pero las celdas están pobladas por datos del modelo de vista. Incluso podría tener un modelo de vista de celda si sus celdas son relativamente complejas.

Además, ¿hay un mejor enfoque que mi uso de un tema (fetchDoctorSubject)?

Considere usar un RACCommand aquí en su lugar. Le dará una mejor manera de manejar solicitudes concurrentes, errores y seguridad de hilos. Los comandos son una forma bastante típica de comunicación desde la vista hasta el modelo de vista.

¿Debería tener una propiedad activa como en el ejemplo de ViewModel en la cuenta github de ReactiveCocoa?

Solo depende de si lo necesitas. En iOS, probablemente sea menos común que OS X, donde podría tener múltiples vistas y ver los modelos asignados pero no "activos" a la vez.

Espero que esto haya sido útil. ¡Parece que te estás dirigiendo en la dirección correcta en general!