I hate to say it but our view controllers have gotten fat, and it's our fault! We feed them protocol after protocol, business rule after business rule. They don't know any better and they just gobble them all up...
This will be a 3 part "Tune up your table view" series in which we will take an old fat view controller, slim it down with some refactoring, freshen it up with some MVVM and then make it fly with some better asynchronous operation management!
In part 1 using refactoring and a couple of Interface Builder tricks hopefully I can provide some motivation to start your journey towards getting your view controllers back in fighting shape!
UITableViews are pretty integral to most iOS apps so I'm going to use it as an example of how to change a 'fat' view controller into a 'slim' one.
Take a second to look over the example project here. Specifically we will target VCTable.
In the scheme of things it's a super simple table view, you could probably argue that it's not even that bloated.
But unfortunately in the real world our view controllers are rarely this simple. Sure they might start out that way but then slowly but surely new features get added and as they say... the best laid plans of mice and men often go astray.
So let's see what steps we can take to make this table a little nicer...
Let's have a look at what this view controller is currently doing...
- It's making an API call to get some pictures (complete with some thread juggling)
- It's registering the required table view cells
- It's managing the table's datasource
- It's handling the table delegate methods
Thats a lot of things that have nothing to do with the view! In fact looking through the code it seems like most of it is all delegate methods and business rules :(
Looking at this code it seems like the API call is going to be the easiest thing to break away first.
-(void)reloadData {
[self.refresh beginRefreshing];
//Do our work in the background
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
//Request our data from the server
NSError *err = nil;
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:kURL]];
//Check the response from the server
if (data == nil) {
err = [NSError errorWithDomain:NSStringFromClass([self class]) code:0
userInfo:@{NSLocalizedDescriptionKey: @"No data returned by server"}];
}
//Attempt to deserialize the response into JSON
NSArray *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
//Handle the result on the main thread
dispatch_async(dispatch_get_main_queue(), ^{
if (err) {
//Something went wrong either talking with the server
//or converting the received data into json
[self endRefreshing];
[[[UIAlertView alloc]
initWithTitle:@"Oops.."
message:[NSString stringWithFormat:@"An Error Occured\n\n%@", err.localizedDescription]
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil]
show];
} else {
//Array of items received from the server, convert them into our model objects
NSMutableArray *items = [NSMutableArray array];
for (NSDictionary *jsonItem in json) {
VCTableCellData *item = [[VCTableCellData alloc] initWithJSON:jsonItem];
[items addObject:item];
}
[self endRefreshing];
//Store the model objects and reload the table view
self.data = items;
[self.tableView reloadData];
}
});
});
}
This is kind of gross... At the moment, apart from being too long, this method mixes API call with logic relating to the refresh control as well as the success or failure of the request. We should be able to break that coupling up and make this more reusable.
//MyAPI.h
typedef void(^myAPIDidError)(NSError *error);
typedef void(^myAPIDidGetPhotos)(NSArray *json);
@interface MyAPI : NSObject
-(void)getPhotos:(myAPIDidGetPhotos)complete error:(myAPIDidError)error;
@end
//MyAPI.m
static NSString *kURL = @"http://jsonplaceholder.typicode.com/photos";
@implementation MyAPI
-(void)getPhotos:(myAPIDidGetPhotos)complete error:(myAPIDidError)error {
//Do our work in the background
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSError *err = nil;
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:kURL]];
//Check the response from the server
if (data == nil) {
err = [NSError errorWithDomain:NSStringFromClass([self class]) code:0
userInfo:@{NSLocalizedDescriptionKey: @"No data returned by server"}];
}
//Attempt to deserialize the response into JSON
NSArray *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
//Handle the result on the main thread
dispatch_async(dispatch_get_main_queue(), ^{
if (err && error) {
error(err); //Something went wrong
} else {
complete(json); //Success!
}
});
});
}
@end
Thats a bit better now we can also use an instance of MyAPI in any view controller that needs it, next we add a private property for MyApi
to our view controller
@property (nonatomic, strong) MyAPI *api;
and create an instance in viewDidLoad
self.api = [MyAPI new];
Finally we replace our reloadData
method
-(void)reloadData {
[self.refresh beginRefreshing];
[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];
}
//Store the items and reload the table view
[self endRefreshing];
self.data = items;
[self.tableView reloadData];
} error:^(NSError *error) {
[self endRefreshing];
//Report the error to the user
[[[UIAlertView alloc]
initWithTitle:@"Oops.."
message:[NSString stringWithFormat:@"An Error Occured\n\n%@", error.localizedDescription]
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil]
show];
}];
}
and we will come back to this later.
Possibly the biggest cause of view controller bloat would have to be the protocols we need to use for table views, text fields, date pickers etc... I'm certainly not bashing the delegate pattern, it's great for letting us control how things work but there's a bad habit of just loading it all into view controllers, we need to stop!
So how can we do it? Well theres a lesser know feature of Interface Builder which allows you to add any object to your nib/storyboard. These objects will be created when your view is, and they will be ready to go when viewDidLoad
is called.
Let's see how it works, we are going to create a seperate object for both our UITableViewDataSource
and UITableViewDelegate
.
Create the following:
//VCTableDataSource.h
#import <UIKit/UIKit.h>
@interface VCTableDataSource : NSObject <UITableViewDataSource>
@property (nonatomic, strong) NSArray *data;
@end
//VCTableDataSource.m
#import "TableCell.h"
@implementation VCTableDataSource
-(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return (self.data == nil ? 0 : 1);
}
-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.data.count;
}
-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
TableCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass([TableCell class]) forIndexPath:indexPath];
return cell;
}
@end
//VCTableDelegate.h
#import <UIKit/UIKit.h>
@interface VCTableDelegate : UIControl <UITableViewDelegate>
@property (nonatomic, strong) NSArray *data;
@property (readonly) id selectedData;
@end
//VCTableDelegate.m
#import "TableCell.h"
@implementation VCTableDelegate
-(void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
id data = self.data[indexPath.row];
[((TableCell *)cell) setup:data];
}
-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
_selectedData = self.data[indexPath.row];
[self sendActionsForControlEvents:UIControlEventValueChanged];
}
@end
And in addition to these new objects we will also create another object to coordinate data reloads using these new objects as well as register our cell for us.
//VCTableCoordinator.h
#import <UIKit/UIKit.h>
@interface VCTableCoordinator : NSObject
-(void)reloadData:(NSArray *)data;
@property (nonatomic, strong) IBOutlet UITableView *tableView;
@end
//VCTableCoordinator.m
#import "VCTableDataSource.h"
#import "VCTableDelegate.h"
#import "TableCell.h"
@interface VCTableCoordinator ()
@property (nonatomic, strong) IBOutlet VCTableDataSource *dataSource;
@property (nonatomic, strong) IBOutlet VCTableDelegate *delegate;
@end
@implementation VCTableCoordinator
#pragma mark - Lifecycle
-(void)awakeFromNib {
[super awakeFromNib];
[self registerCells];
}
#pragma mark - Public
-(void)reloadData:(NSArray *)data {
self.dataSource.data = data;
self.delegate.data = data;
[self.tableView reloadData];
}
#pragma mark - Private
-(void)registerCells {
UINib *nib = [UINib nibWithNibName:NSStringFromClass([TableCell class]) bundle:nil];
[self.tableView registerNib:nib forCellReuseIdentifier:NSStringFromClass([TableCell class])];
}
@end
Back in our view controller we add a private property for our shiny new VCTableCoordinator
@property (nonatomic, strong) IBOutlet VCTableCoordinator *tableViewCoordinator;
And also add an IBAction
for when a cell is selected.
#import "VCTableDelegate.h"
-(IBAction)itemPressed:(VCTableDelegate *)sender {
VCTableCellData *data = sender.selectedData;
NSLog(@"Selected ID: %@", data.id);
}
Lastly perform the following:
- in
viewDidLoad
changeFat
toSlim
in the title - You've earnt it!! - remove
#import "TableCell.h"
- remove
static NSString *kURL = @"http://jsonplaceholder.typicode.com/photos";
- remove the private properties for
NSArray *data
andUITableView *tableView
- remove the protocols
UITableViewDataSource
andUITableViewDelegate
from the declaration in the .m - remove all the code under the headings
UITableViewDataSource
andUITableViewDelegate
- remove the
registerCells
method and the call inviewDidLoad
- update
setupPullToRefresh
, changeself.tableView
toself.tableViewCoordinator.tableView
- find these lines in
reloadData
:
self.data = items;
[self.tableView reloadData];
change them to:
[self.tableViewCoordinator reloadData:items];
Observant followers will have noticed that there are a lot of items declared with IBOutlet
in these new objects, as well as that last IBAction
which lets us handle row selection just by hooking it up!
If we go back to our nib we should first disconnect all existing outlet links (except view). Next looking in the list where you would normally add controls from, find Object
and add 3 of them.
Click the first one you added then in the identity inspector change its type to VCTableDataSource
. Make the second one VCTableDelegate
and the third VCTableCoordinator
.
Next comes the cool part, hooking everything up! Now, take your time and make the following connections:
- File's Owner > Table Coordinator
- Table Coordinator > Table Delegate
- Table Coordinator > Table Data Source
- Table Coordinator > Table View
- Table View > Table Delegate
- Table View > Table Data Source
- Table Delegate > File's Owner (itemPressed:)
Phew, that was a lot of connections... but so far we have managed to break away a decent amount of code from our view controller!, nawww look how much weight he's lost!
Fire it up and take your new slim view controller for a spin!!, it behaves exactly the same way fattie did, tap on a cell and you'll get an awesome console log ;)
If you want the source for the slimmed down view controller, you can find it here.
Our table view still has a way to go... it's a little jumpy when scrolling but don't worry, we will address the performance in part 3 but coming up next, MVVM!