Last time I showed you one strategy for trimming the fat from our view controller by seperating out protocols. In part 2 of this 3 part series we are going to use an architecture known as MVVM to dumb down our view controller. View controllers are usually filled with business rules, this is a problem for a number of reasons, two of which being:
- It makes the code harder to reason about; let the view controller worry about appearance and the business objects worry about the logic, we don't want them thinking for themselves ;)
- It makes your view controller bloated; business rules don't belong in your view controller
MVVM stands for Model View ViewModel and is just a way of describing how to break up your interface, business rules and data so that they flow together in a logical, but seperated, way.
This is what that flow looks like
A smart guy I know summed this up well...
"...data flows from the Model into the ViewModel which transforms it and pushes it to the View. User interactions and edits flow from the View to the ViewModel which transforms that into data which it sends to the Model"
I could ramble on and on about the benefits of adopting MVVM (testing, readability, etc..) but it's a little beyond the scope of this article. Fret not though! If you want to learn more (and you should!) the very smart guys over at objc.io already have a great article explaining it.
So we have a pretty good view controller at this point.. and in a perfect world it would never have to change, but then as developers we would also be out of a job pretty quickly.
Back in the real world your product manager has just come to you with a great new feature people want. Joe Public desperately wants the text in the navigation bar to change to the title of the item that was last tapped in the list AND reset when the dataset is reloaded.
That's super easy right? Yup! We'll just go and write that code in the... umm... view controller??... NO! BAD DEVELOPER! DROP THE KEYBOARD... DROP IIITTT...
Good developer, the goal today is to get those business rules out of our view controller. So where will they live? I'm glad you asked... we are going to put them into our ViewModel.
If you already know about MVVM or you've gone and read the article provided above you know all about ViewModels. They are the object passed to a view controller in MVVM that contains any logic and events specific to that view controller, it's basically the brains of the operation.
Ok, so back in our project the first thing we need to do is create a ViewModel, they are just a simple NSObject
so go ahead and create a new object called VCTableModel
. Before we get started with our new features our view controller has some existing business rules that we can pull right out into our ViewModel, can you see it?... it's the MyAPI
code! Let's handle that first by defining our ViewModel as follows:
//VCTableModel.h
@class MyAPI;
typedef void(^didReloadDataBlock)(NSArray *data);
typedef void(^didErrorBlock)(NSString *error);
@interface VCTableModel : NSObject
-(instancetype)initWithManager:(MyAPI *)api;
-(void)reloadData;
@property (nonatomic, copy) didReloadDataBlock didReloadData;
@property (nonatomic, copy) didErrorBlock didError;
@end
You can see here we are passing our MyAPI
object to VCTableModel
via its constructor. This technique is known as dependency injection and is a perfect match for MVVM. If you want to know more about dependency injection (again, you should!) the guys at objc.io come to the rescue again with their article on the subject.
One of the (many) huge benefits of MVVM is that, when used properly, you can make your view controller incredibly reactive to changes in the data it shows. This is accomplished by creating bindings between your view controller and the ViewModel.
There are a number of tools available for you to create these bindings however to keep things simple we are just going to use blocks. You can see we have defined two 'events' here, one to handle any errors we encounter and one when the data has been updated. Lastly we have a method to reload the data in the view controller.
Now that we have defined an interface let's start fleshing out the implementation, open up VCTableModel.m
and create a private property for our MyAPI
instance.
@property (nonatomic, strong) MyAPI *api;
Next add the constructor
-(instancetype)initWithManager:(MyAPI *)api {
if (!(self = [super init])) { return nil; }
self.api = api;
return self;
}
Next up let's add the methods that will 'push' our events and a small helper to change an NSError
into an NSString
-(NSString *)userMessageForError:(NSError *)error {
return [NSString stringWithFormat:@"An Error Occured\n\n%@", error.localizedDescription];
}
-(void)sendError:(NSError *)error {
if (self.didError == nil) { return; }
dispatch_async(dispatch_get_main_queue(), ^{
self.didError([self userMessageForError:error]);
});
}
-(void)sendData:(NSArray *)data {
if (self.didReloadData == nil) { return; }
dispatch_async(dispatch_get_main_queue(), ^{
self.didReloadData(data);
});
}
Nice work! The only thing left to do is bring in the code that uses MyAPI
we even get to thin it out because it's no longer directly coupled to the interface!
-(void)reloadData {
[self.api getPhotos:^(NSArray *json) {
//Convert JSON items into our model objects
NSMutableArray *items = [NSMutableArray array];
for (NSDictionary *jsonItem in json) {
VCTableCellData *item = [[VCTableCellData alloc] initWithJSON:jsonItem];
[items addObject:item];
}
//Raise didReloadData
[self sendData:items];
} error:^(NSError *error) {
//Something went wrong, raise didError
[self sendError:error];
}];
}
That's it! the current features are now in a ViewModel ready to be handed to your view controller!
Now that we have created our ViewModel we need to update our view controller to use it. We will start by stripping out the code we have now moved.
Perform the following:
- in
viewDidLoad
changeSlim
toMVVM
in the title and removeself.api = [MyAPI new];
- remove
#import "MyAPI.h"
- remove the private properties for
MyAPI *api
- remove the existing implementation of
reloadData
and replace it with:
-(void)reloadData {
[self.refresh beginRefreshing];
[self.model reloadData];
}
You can ignore the compiler error for now, we are about to fix it.
First thing we need to do is provide an instance of VCTableModel
to our view controller via the constructor:
//VCTable.h
@class VCTableModel;
@interface VCTable : UIViewController
-(instancetype)initWithModel:(VCTableModel *)model;
@end
Next in the implementation we add a private property for the ViewModel
#import "VCTableModel.h"
@property (nonatomic, strong) VCTableModel *model;
Then our new constructor
-(instancetype)initWithModel:(VCTableModel *)model {
if (!(self = [super initWithNibName:NSStringFromClass([self class]) bundle:nil])) { return nil; }
self.model = model;
return self;
}
Nearly there! Next we add methods to handle each of the ViewModel's events as well as a method to hook it all up
-(void)bindToModel {
self.model.didError = [self modelDidError];
self.model.didReloadData = [self modelDidReloadData];
}
-(didErrorBlock)modelDidError {
return ^(NSString *error) {
[self endRefreshing];
[[[UIAlertView alloc]
initWithTitle:@"Oops..." message:error
delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil]
show];
};
}
-(didReloadDataBlock)modelDidReloadData {
return ^(NSArray *data) {
[self endRefreshing];
[self.tableViewCoordinator reloadData:data];
};
}
Quick Note: You may be unfamiliar with the syntax of these event handling methods but blocks are actually objects so we can return them from a method. Writing them in this way just improves their readability.
Lastly make sure you call [self bindToModel];
in your viewDidLoad
Congratulations! You have converted your view controller to MVVM!
Now we can get busy adding our new features! The first thing we should do is move the title of the view controller to our ViewModel, this will make it easy to reset and it's good practice. Imagine you have a profile view that's backed by a User
object, you can easily set the title to the user's name while keeping your data hidden from the view controller (a key principle of MVVM).
So let's update our ViewModel:
//VCTableModel.h
@property (readonly) NSString *title;
//VCTableModel.m
-(NSString *)title {
return @"MVVM Table View Example";
}
and in our view controller update the viewDidLoad
with self.title = self.model.title;
We know our two new requirements both update the navigation bar title, we can accomplish that with a single new event:
//VCTableModel.h
typedef void(^didUpdateNavigationTitleBlock)(NSString *title);
@property (nonatomic, copy) didUpdateNavigationTitleBlock didUpdateNavigationTitle;
and we need a method we can call when the user has tapped a row in the table
//VCTableModel.h
@class VCTableCellData;
-(void)userSelectedCell:(VCTableCellData *)cellData;
//VCTableModel.m
-(void)userSelectedCell:(VCTableCellData *)cellData {
[self sendNavigationTitle:cellData.title];
}
The last change in our ViewModel is to update reloadData
to call [self sendNavigationTitle:self.title];
before making the api call.
Finally in our view controller we simply add a method to handle the new event:
-(didUpdateNavigationTitleBlock)modelDidUpdateNavigationTitle {
return ^(NSString *title) {
self.title = title;
};
}
Then add self.model.didUpdateNavigationTitle = [self modelDidUpdateNavigationTitle];
to bindToModel
Lastly add [self.model userSelectedCell:data];
to itemPressed:
so that our model can fire these new events and your new features are done!!
One last step I like to do is make a convenience method that makes it super easy to bring together the view controller, its ViewModel and the dependencies.
Make a new category/extension on VCTable
called VCTable+Factory
//VCTable+Factory.h
@interface VCTable (Factory)
+(instancetype)factoryInstance;
@end
//VCTable+Factory.m
#import "VCTableModel.h"
#import "MyAPI.h"
@implementation VCTable (Factory)
+(instancetype)factoryInstance {
VCTableModel *model = [[VCTableModel alloc] initWithManager:[MyAPI new]];
return [[VCTable alloc] initWithModel:model];
}
@end
In your AppDelegate
change #import "VCTable.h"
to #import "VCTable+Factory.h"
and update application:didFinishLaunchingWithOptions:
to:
-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
VCTable *rootViewController = [VCTable factoryInstance];
self.navigationController = [[UINavigationController alloc] initWithRootViewController:rootViewController];
self.window.rootViewController = self.navigationController;
[self.window makeKeyAndVisible];
return YES;
}
Now you can take it for a spin!
Nice work! Now our view controller doesn't need to worry about anything other than showing what it's told, when it's told!
I hope this intro into MVVM was fairly simple and I've shown you how easy it is to break your business rules out of your view controllers.
Grab the source for our MVVM view controller here.
In the final part of this series we will address that god awful scrolling performance! Stay tuned!