Upgrade to Pro — share decks privately, control downloads, hide ads and more …

MVVM Architecture at Timehop

MVVM Architecture at Timehop

In this talk, I discuss how we've used MVVM in the Timehop iOS app to create a robust and modular app architecture.

I start by walking through the basics of MVVM in a very simple example app called Userlist.

Next, I detail how the image loading subsystem works in the Timehop app.

Finally, I answer some questions about how Timehop works behind the scenes.

Chris Trott

March 10, 2015
Tweet

More Decks by Chris Trott

Other Decks in Programming

Transcript

  1. Ownership Data Flow VIEW VIEW MODEL CONTROLLER DATA SOURCE VIEW

    VIEW MODEL CONTROLLER DATA SOURCE RAW MODEL MODEL VIEW MODEL
  2. Controllers • Not UIViewControllers! • PONSOs (plain old NSObjects) •

    Convert Raw Models to Models • Can’t manipulate View Models
  3. // User.h @interface User : NSObject @property (nonatomic, readonly) NSString

    *name; @property (nonatomic, readonly) NSURL *avatarURL; - (instancetype)initWithName:(NSString *)name avatarURL: (NSURL *)avatarURL; @end
  4. // UserController.h @interface UserController : NSObject /// Sends an array

    of fabricated User objects then completes. - (RACSignal *)fetchRandomUsers:(NSUInteger)numberOfUsers; @end
  5. View Models • PONSOs or helper superclass • Convert Models

    to View Models • Expose data as observable properties • Accept events from owners as methods or blocks • Can’t manipulate Views
  6. // UserViewModel.h @interface UserViewModel : NSObject @property (nonatomic, readonly) NSString

    *name; @property (nonatomic, readonly) UIImage *avatarImage; /// Initiate the avatarImage fetch. @property (nonatomic, getter=isActive) BOOL active; - (instancetype)initWithUser:(User *)user; @end
  7. // UsersViewModel.h @interface UsersViewModel : NSObject /// Array of UserViewModel

    objects. /// NSArray<UserViewModel> @property (nonatomic, readonly) NSArray *userViewModels; @property (nonatomic, readonly, getter=isLoading) BOOL loading; @property (nonatomic, getter=isActive) BOOL active; /// Initiate a fetch. - (void)fetchRandomUsers; @end
  8. Views • UIViews and UIViewControllers • Final conversion to UI

    • Bind to View Models and native properties • Pass events to View Models • Can’t see Models
  9. // UserCell.m - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier: (NSString *)reuseIdentifier { self =

    [super initWithStyle:style reuseIdentifier:reuseIdentifier]; if (self != nil) { RAC(self.imageView, image) = RACObserve(self, viewModel.avatarImage); RAC(self.textLabel, text) = RACObserve(self, viewModel.name); } return self; }
  10. // UsersViewController.m - (void)viewDidLoad { [super viewDidLoad]; [self.tableView registerClass:[UserCell class]

    forCellReuseIdentifier:NSStringFromClass([UserCell class])]; @weakify(self); [[RACObserve(self.viewModel, userViewModels) ignore:nil] subscribeNext:^(id _) { @strongify(self); [self.tableView reloadData]; }]; }
  11. // UsersViewController.m # pragma mark UITableViewDataSource - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section

    { return [self.viewModel.userViewModels count]; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UserCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([UserCell class]) forIndexPath:indexPath]; UserViewModel *viewModel = self.viewModel.userViewModels[indexPath.row]; cell.viewModel = viewModel; return cell; }
  12. // UsersViewController.m # pragma mark UITableViewDelegate - (void)tableView:(UITableView *)tableView willDisplayCell:

    (UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { UserCell *userCell = (UserCell *)cell; userCell.viewModel.active = YES; } - (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { UserCell *userCell = (UserCell *)cell; userCell.viewModel.active = NO; }
  13. Ownership Data Flow VIEW VIEW MODEL CONTROLLER DATA SOURCE VIEW

    VIEW MODEL CONTROLLER DATA SOURCE RAW MODEL MODEL VIEW MODEL
  14. // Raw Model @{ @“name” = @“Taylor Switch”, @“avatarURL” =

    @“http://example.com/taylor_switch.jpg” } // Model @interface User : NSObject @property (nonatomic, readonly) NSString *name; @property (nonatomic, readonly) NSURL *avatarImageURL; @end // View Model @interface UserViewModel : NSObject @property (nonatomic, readonly) NSString *name; @property (nonatomic, readonly) UIImage *avatarImage; @end // View @interface UserCell () @property (nonatomic, readonly) UILabel *textLabel; @property (nonatomic, readonly) UIImageView *imageView; @end
  15. // ImageController.h @interface ImageController : NSObject - (instancetype)initWithWebImageManager:(SDWebImageManager *)webImageManager imageManager:

    (PHImageManager *)imageManager cachingImageManager:(PHCachingImageManager *)cachingImageManager; /// Sends RACTuple(UIImage *downloadedImage, NSNumber *progress) /// at unspecified intervals, where progress is a double == 0..1, /// and downloadedImage in nil for 0..0.99 and /// the complete image for progress == 1. /// /// Sends RACTuple(nil, @1) if imageURL is nil. /// /// Sends complete when the final representation of the image has /// been returned. - (RACSignal *)imageAndProgressWithURL:(NSURL *)imageURL;
  16. // ImageViewModel.h @interface ImageViewModel : ViewModel /// The latest image

    downloaded from imageURL. May be nil. @property (nonatomic, readonly) UIImage *image; /// The progress of the current download. @property (nonatomic, readonly) CGFloat progress; /// ErrorViewModel encapsulates any download error. @property (nonatomic, readonly) BOOL hasError; @property (nonatomic, readonly) ErrorViewModel *errorViewModel; /// Whether any download operation is in progress. @property (nonatomic, readonly, getter=isLoading) BOOL loading; /// The View layer should call this block when a user taps the image view. /// If an image exists and there is no error, it will call `openImageURLBlock`. /// If an error exists, it will try to fetch the image again. @property (nonatomic, readonly) void (^selectBlock)(void); /// Exists on ViewModel superclass. /// Setting active to YES triggers the image load unless the image /// has already been fetched. @property (nonatomic, getter=isActive) BOOL active; @property (nonatomic, readonly) NSURL *imageURL; - (instancetype)initWithImageURL:(NSURL *)imageURL openImageURLBlock:(void (^) (NSURL *))openImageURLBlock imageController:(ImageController *)imageController;
  17. // ImageProgressView.h /// * Updates the image to the latest

    value of viewModel.image. /// * Shows progress before first image is loaded. /// * Shows error view on error. /// * Handles taps. @interface ImageProgressView : UIView @property (nonatomic) ImageViewModel *viewModel; @end /// ImageView.h /// * Updates the image to the latest value of viewModel.image. @interface ImageView : UIImageView @property (nonatomic) ImageViewModel *viewModel; @end
  18. // SDWebImageManager+RACSignalSupport.m - (RACSignal *)rac_imageAndProgressWithURL:(NSURL *)url options: (SDWebImageOptions)options { return

    [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) { id<SDWebImageOperation> operation = [self downloadImageWithURL:url options:options progress:^(NSInteger receivedSize, NSInteger expectedSize) { if (expectedSize > 0 && receivedSize < expectedSize) { [subscriber sendNext:RACTuplePack(nil, @(receivedSize/ (CGFloat)expectedSize))]; } } completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) { if (error != nil) { [subscriber sendError:error]; } [subscriber sendNext:RACTuplePack(image, @1)]; if (finished) { [subscriber sendCompleted]; } }]; return [RACDisposable disposableWithBlock:^{ [operation cancel]; }]; }]; }
  19. • IssueViewModel • AlbumViewModel[] • UserContentViewModel[] • ContentMetadataViewModel • AlbumMetadataViewModel

    • PhotoAlbumViewModel • ImageViewModel[] • VideoViewModel • ImageViewModel • TextLinksViewModel • EventViewModel • LocationViewModel • LinkViewModel • ImageViewModel • InteractionsViewModel • SourceActionViewModel • ShareViewModel
  20. How does a Timehop Issue get loaded? VIEW VIEW MODEL

    CONTROLLER DATA SOURCE NSDICTIONARY ISSUE VIEWMODELS[] ACTIVE @YES DATEKEY “20150309” URL “/ISSUES/20150309”
  21. What happens when you scroll the table view? UserContentCell TextLinksSlimView

    … UserContentViewModel TextLinksViewModel … UserContentViewModel TextLinksViewModel … UserContentViewModel TextLinksViewModel …
  22. What happens when you scroll the table view? - (void)tableView:(UITableView

    *)tableView didEndDisplayingCell: (UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { UserContentCell *userContentCell = (UserContentCell *)cell; userContentCell.viewModel.active = NO; } UserContentCell TextLinksSlimView … UserContentViewModel TextLinksViewModel … UserContentViewModel TextLinksViewModel …
  23. What happens when you scroll the table view? - (UITableViewCell

    *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UserContentCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([UserContentCell class]) forIndexPath:indexPath]; AlbumViewModel *albumViewModel = self.viewModel.viewModels[indexPath.section]; UserContentViewModel *userContentViewModel = albumViewModel.userContentViewModels[indexPath.row]; cell.viewModel = userContentViewModel; return cell; } UserContentCell TextLinksSlimView … UserContentViewModel TextLinksViewModel … UserContentViewModel TextLinksViewModel …
  24. What happens when you scroll the table view? - (void)tableView:(UITableView

    *)tableView willDisplayCell: (UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { UserContentCell *userContentCell = (UserContentCell *)cell; userContentCell.viewModel.active = YES; } UserContentCell TextLinksSlimView … UserContentViewModel TextLinksViewModel … UserContentViewModel TextLinksViewModel …
  25. IssueViewModel What happens when you tap a location view? IssueViewController

    UserContentCell LocationView AlbumViewModel UserContentViewModel LocationViewModel *tap* Location View Controller
  26. IssueViewModel What happens when you tap a location view? IssueViewController

    UserContentCell LocationView AlbumViewModel UserContentViewModel LocationViewModel *tap* Location LocationViewModel LocationViewModel Location View Model Location View Controller
  27. IssueViewModel What happens when you tap a link view? IssueViewController

    UserContentCell LinkSlimView AlbumViewModel UserContentViewModel LinkViewModel *tap* WebView Controller
  28. IssueViewModel What happens when you tap a link view? IssueViewController

    UserContentCell LinkSlimView AlbumViewModel UserContentViewModel LinkViewModel *tap* Link WebViewModel WebViewModel WebView Model WebView Controller
  29. // IssueViewController.m - (void)viewDidLoad { [self.viewModel.presentableViewModelSignal subscribeNext:^(ViewModel *viewModel) { if

    ([viewModel isKindOfClass:[LocationViewModel class]]) { LocationViewController *locationViewController = [[LocationViewController alloc] initWithViewModel:viewModel]; [self.navigationController pushViewController:locationViewController animated:YES]; } else if ([viewModel isKindOfClass:[InteractionsDetailViewModel class]]) { InteractionsDetailViewController *interactionsDetailViewController = [[InteractionsDetailViewController alloc] initWithViewModel:viewModel]; [self.navigationController pushViewController:interactionsDetailViewController animated:YES]; } else if ([viewModel isKindOfClass:[WebViewModel class]]) { WebViewController *webViewController = [[WebViewController alloc] initWithViewModel:viewModel]; [self.navigationController pushViewController:webViewController animated:YES]; } else if ([viewModel isKindOfClass:[EventDetailViewModel class]]) { EventDetailViewController *eventViewController = [[EventDetailViewController alloc] initWithViewModel:viewModel]; [self.navigationController pushViewController:eventViewController animated:YES]; } }]; }