The Model View Controller (MVC) design pattern is no stranger to iOS developers. Every iOS application uses the MVC pattern to distribute the responsibilities of the application flow. Unfortunately, most of the time, this distribution ends up with a lot of code in the controllers of the application. These bloated controllers are responsible for creating technical debt and also create maintenance nightmares for the future. In this article, you will learn how to tame these monsters. I'll start with an app that consists of a massive controller and work my way toward a leaner implementation.
The basic principle behind lean controllers is the single responsibility principle. This means that every component of the application should have a single job and purpose. By isolating each component to perform a single task, you make sure that the code remains lean and easy to maintain.
Scenario
The iOS application under discussion is a simple user task-management app. The application consists of a single screen where the user can add tasks to a list. Users can delete the tasks by swiping right to left on the row and then pressing the delete button. The user can mark the tasks as complete by selecting the cell using a single tap gesture. All of the tasks are persisted in the SQLITE database using the FMDB wrapper. The screenshots of the app running in action is shown in Figure 1 and Figure 2.
In the first section, you'll inspect the TasksTableViewController
implementation, which orchestrates the complete flow of the application. You'll quickly realize that the implemented code is not reusable and is hard to maintain. You'll take the whole application apart and create reusable, single-responsibility components that protect the application for future changes.
The concepts and techniques learned in this article can be applied to any iOS application regardless of the data access layer used.
Implementing a Lean View Controller
The best place to start is the viewDidLoad
event of the TasksTableViewController
. The viewDidLoad
event is invoked each time the app runs. Inside the viewDidLoad
event, you trigger the initializeDatabase
function. The initializeDatabase
function is responsible for copying the database from the application bundle to the documents directory of the application. Because this procedure needs to be performed only once for each app, there's no need to call it from the viewDidLoad
event. You're going to move the initializeDatabase
function into AppDelegate
where it can be called from within the didFinishLaunchingWithOptions
event. By moving the initializeDatabase
call inside the didFinishLaunchingWithOptions
, you've made sure that initializeDatabase
is called only once during the lifecycle of the application. Listing 1 shows the implementation of the initializeDatabase
function.
Listing 1: InitializeDatabase function to setup the database
func application(application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[NSObject: AnyObject]?) -> Bool {
initializeDatabase()
return true
}
private func initializeDatabase() {
let documentPaths =
NSSearchPathForDirectoriesInDomains
(NSSearchPathDirectory.DocumentDirectory,
NSSearchPathDomainMask.UserDomainMask, true)
guard let documentDirectory = documentPaths.first else {
return }
self.databasePath = documentDirectory
.stringByAppendingPathComponent
(self.databaseName)
let fileManager = NSFileManager.defaultManager()
let success = fileManager.fileExistsAtPath(self.databasePath)
guard let databasePathFromApp = NSBundle
.mainBundle().resourcePath?
.stringByAppendingPathComponent
(self.databaseName) else {
return
}
print(self.databasePath)
if success {
return
}
try! fileManager.copyItemAtPath(databasePathFromApp,
toPath: self.databasePath)
}
Next, move to the cellForRowAtIndexPath
function. At first glance, the cellForRowAtIndexPath
implementation doesn't show any warning signs, but the devil's in the details. The properties associated with the cell are assigned inside the function. In the future, you might be interested in displaying some additional properties on the user interface. This means that you'll assign more cell properties inside the cellForRowAtIndexPath
function, hence polluting it with unnecessary code, as indicated in Listing 2.
Listing 2: Cell properties assigned inside the cellForRowAtIndexPath function
override func tableView(tableView: UITableView,
cellForRowAtIndexPath indexPath: NSIndexPath)
-> UITableViewCell {
guard let cell = tableView.dequeueReusableCellWithIdentifier
("TaskTableViewCell", forIndexPath: indexPath)
as? TaskTableViewCell else {
fatalError("TaskCell not found")
}
let task = self.tasks[indexPath.row]
cell.titleLabel.text = task.title;
cell.subTitleLabel.text = task.subTitle;
// Future properties polluting code
cell.imageURL = task.imageURL
// Future properties polluting code
return cell
}
At first glance, the cellForRowAtIndexPath implementation doesn't show any warning signs, but the devil is in the details
The best way to deal with this problem is to refactor the configuration of the cell into a separate function. Luckily, the TaskTableViewCell
is a perfect candidate to define such a function. The implementation of the configureCell
function is shown here:
func configure(task :Task) {
self.titleLabel.text = task.title
self.shortTitle.text = task.shortTitle
self.imageURL = task.imageURL
}
The configure
function accepts a Task
model object and then populates the cell properties based on the properties of the model. The next snippet shows the call to the configure
function inside the cellForRowAtIndexPath
event.
override func tableView(tableView:
UITableView, cellForRowAtIndexPath
indexPath: NSIndexPath)
-> UITableViewCell {
guard let cell = tableView.
dequeueReusableCellWithIdentifier
("TaskTableViewCell", forIndexPath: indexPath)
as? TaskTableViewCell else {
fatalError("TaskCell not found")
}
let task = self.tasks[indexPath.row]
cell.configure(task)
return cell
}
Implementing TasksDatasource
In the current implementation, the data source is tied to the TasksTableViewController
. This means that if you have to use the same data in a different controller, you have to manually copy and paste the code into a new controller. As a developer, you realize that copy/pasting is the root of all evil and that it contributes to the technical debt. Instead, you will refactor the data source into a separate TasksDataSource
class.
The TasksDataSource
class implements the UITableViewDataSource
protocol. The TasksDataSource
initializer consists of the following parameters:
- cellIdentifier: A string instance representing the
UITableViewCell
unique identifier - items: An array of task instances
- tableView: The
UITableView
instance
The TasksDataSource
class also provides the implementations for the cellForRowAtIndexPath and numberOfRowsInSection
functions. Now, the TasksTableViewController
can call the setupTableView
function from inside the viewDidLoad
event to initialize the TasksDataSource
, as shown in Listing 3.
Listing 3: Setting up UITableViewDataSource
func setupTableView() {
// get the data from the database
let db = FMDatabase(path: self.databasePath)
db.open()
let result = db.executeQuery("select * from tasks",
withArgumentsInArray: [])
while(result.next()) {
let task = Task(title: result.
stringForColumn("title"))
// add to the collection
self.tasks.append(task)
}
// close the connection
defer {
db.close()
}
// initialize the data source
self.dataSource = TasksDataSource
(cellIdentifier: "TaskTableViewCell", items:
self.tasks,tableView: self.tableView)
self.tableView.dataSource = self.dataSource
self.tableView.reloadData()
}
Apart from eliminating the unnecessary code implemented in TasksTableViewController
, TasksDataSource
also helps to create a reusable data source that can be unit tested and used in different parts of the application.
The setupTableView
also exposes another problem related to data access. Currently, the data access code is littered throughout the TasksTableViewController
, which not only makes it harder to test but also harder to reuse and maintain. In the next section, you'll refactor the data source into a separate service, which allows more flexibility in the future.
Implementing TasksDataService
TasksDataService
will be responsible for all of the CRUD (Create, Read, Update, Delete) operations related to the application. TasksDataService
maintains the communication bridge between the view controller and the persistent storage system, which, in this case, is SQLite3. The first step is to initialize the TasksDataService
, which also sets up the FMDatabase
instance, as shown in the next snippet.
var db :FMDatabase!
init() {
var token :dispatch_once_t = 0
dispatch_once(&token) {
let appDelegate = UIApplication.sharedApplication().delegate
as! AppDelegate
let databasePath = appDelegate.databasePath
self.db = FMDatabase(path: databasePath)
}
}
Next, you'll implement the GetAll
function that will be responsible for retrieving all the tasks from the database. The GetAll
implementation is shown in Listing 4.
Listing 4: GetAll function to retrieve all tasks
func getAll() -> [Task] {
var tasks = [Task]()
db.open()
let result = db.executeQuery("select * from tasks",
withArgumentsInArray: [])
while(result.next()) {
let task = Task(title: result.stringForColumn("title"))
// add to the collection
tasks.append(task)
}
defer {
db.close()
}
return tasks
}
The congested setupTableView
function can be replaced with the new leaner implementation, as shown in Listing 5.
Listing 5: The setupTableView function utilizing the TasksDataService
func setupTableView() {
self.tasksDataService = TasksDataService()
self.tasks = self.tasksDataService.getAll()
// initialize the data source
self.dataSource = TasksDataSource(cellIdentifier:
"TaskTableViewCell", items: self.tasks,
tableView: self.tableView)
self.tableView.dataSource = self.dataSource
self.tableView.reloadData()
}
You can apply the same approach to the save and delete operations and move the implementations in the TasksDataService
class. This eliminates a lot of code from the TasksTableViewController
and moves you one step closer toward Lean Controller implementation. In the next section, you're going to look at the tableView
delegate events and how they can be refactored into their own classes.
Extending TasksTableViewController
Swift 2.0 introduced the concept of Protocol Extensions, which allowed developers to provide default implementations to protocol functions. Currently, the TasksTableViewController
contains a lot of code that deals with the look and feel of the UITableView
control. This includes the implementations for didSelectRowAtIndexPath
and commitEditingStyle
events. By providing an extension to the TasksTableViewController
, you can move a lot of clutter from the TasksTableViewController
class to the extension.
The great thing about extending the TasksTableViewController
using protocol extensions is that it will have access to all of the properties defined in the TasksTableViewController
implementation. The implementation of the didSelectRowAtIndexPath
defined inside the TasksTableViewController
extension is shown in Listing 6.
Listing 6: The didSelectRowAtIndexPath implementation inside the TasksTableViewController extension
override func tableView(tableView:
UITableView, didSelectRowAtIndexPath
indexPath: NSIndexPath) {
let task = self.tasks[indexPath.row]
guard let cell = self.tableView.
cellForRowAtIndexPath(indexPath) as? TaskTableViewCell else {
fatalError("Cell does not exist")
}
if(!task.isCompleted) {
cell.titleLabel.attributedText = task.title.strikeThrough()
task.isCompleted = true
} else {
cell.titleLabel.attributedText = task.title.removeStrikeThrough()
task.isCompleted = false
}
}
You'll also notice that I've used an extension method for String type to create the strikeThrough effect. This allows you to reuse the strikeThrough
function in other parts of the application.
Run the application. On the surface, everything looks fine, but as you strikeThrough the top rows and scroll to the UITableView
to the bottom, you'll notice that that your strikeThrough rows are reverted to the default style. The reason is pretty simple: UITableView
reuses the cells that it displays. This helps to increase the performance dramatically, as new cells are never created.
In order to fix this issue, you need to edit the configure
function of the TaskTableViewCell
to include the logic for completed and uncompleted tasks. The implementation is shown in the following snippet.
func configure(task :Task) {
self.titleLabel.text = task.title
if(task.isCompleted) {
self.titleLabel.attributedText = task.title.strikeThrough()
} else {
self.titleLabel.attributedText = task.title.removeStrikeThrough()
}
}
Run the app again and now you'll notice that the strike through rows persists while scrolling the UITableView
control!
Conclusion
Implementing lean controllers takes more effort than writing massive controllers. It might be tempting to put all the code into one controller, but keep in mind that although you might move fast initially, you'll quickly hit a wall. It's always better to refactor and move toward a more solid design initially when things are in motion, rather than waiting until the components have been fixed into place.