Skip to content

An exploration of improving UITableView scrolling performance by using asynchronous cell population

License

Notifications You must be signed in to change notification settings

pepaslabs/GlitchyTable

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Populating UITableViewCells Asynchronously to Maintain Scrolling Performance

Abstract

In general, the key to keeping the iPhone UI responsive is to avoid blocking the main thread. In the case of UITableView, this boils down to keeping tableView(_:cellForRowAtIndexPath:) as performant as possible.

However, sometimes it is simply not possible to marshall all of the data needed to populate a complex UITableViewCell without causing a drop in framerate. In these cases, it is necessary to switch to an asynchronous strategy for populating UITableViewCells. In this article we explore a trivial example of using this technique.

Demonstrating the Problem

Our first task is to create a simple Xcode project which demonstrates the problem of a slow model leading to laggy scrolling performance.

We start with a model which blocks for 100ms. This simulates the lag induced by excessive disk access, complex CoreData interactions, etc:

class GlitchyModel
{
    func textForIndexPath(indexPath: NSIndexPath) -> String
    {
        NSThread.sleepForTimeInterval(0.1)
        return "\(indexPath.row)"
    }
}

We then hook that model up with some typical boilerplate code in our GlitchyTableViewController implementation:

override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath)
{
    if let cell = cell as? GlitchyTableCell
    {
        _configureCell(cell, atIndexPath: indexPath)
    }
}
    
private func _configureCell(cell: GlitchyTableCell, atIndexPath indexPath: NSIndexPath)
{
    cell.textLabel?.text = model.textForIndexPath(indexPath)
}

(For additional context, see the Xcode project and source code of this solution).

The result is a UITableView with terrible scrolling performance, as shown in this video:

gif 1

(Note: It is difficult to demonstrate the problem in the above in-line gif due to its low framerate. Please click the gif or follow the video link to see a full framerate HTML5 video demonstration of the problem.)

Populating UITableViewCell Asynchronously

Our first attempt at solving this problem is to rewrite _configureCell(_,atIndexPath) such that it dispatches to a background thread to grab the data from the model, then jumps back to the main thread to populate the UITableViewCell:

private func _configureCell(cell: GlitchyTableCell, atIndexPath indexPath: NSIndexPath)
{
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in
        
        let text = self.model.textForIndexPath(indexPath)
        
        dispatch_async(dispatch_get_main_queue(), { () -> Void in
            
            cell.textLabel?.text = text
            
        })
    })
}

(See also the Xcode project and source code of this solution).

This change is enough to solve the laggy scrolling performance (as seen in this video):

gif 2

However, we have introduced a bug. If a UITableViewCell scrolls all the way off-screen and gets re-used before the first asynchronous _configureCell call has completed, a second _configureCell call will be queued up in the distpatch queue.

In the case where you have both an extremely laggy model and a user whom is scrolling very aggressively, this can result in many such calls getting queued up. The result is that when the UITableView stops scrolling, the user will see the content of the cells cycle through all of the queued populate operations.

To demonstrate this, we increase the simulated lag in GlitchyModel to 1000ms and scroll very quickly, as seen in this video:

gif 3

Fixing the Queued UITableViewCell Population Bug

To solve this problem, we need to ensure that multiple populate operations aren't allowed to queue up. We can accomplish this by using a serial queue to manage our UITableViewCell populate operations, and ensuring that any outstanding operations are cancelled before we queue up the next operation.

We create a trivial serial queue:

class SerialOperationQueue: NSOperationQueue
{
    override init()
    {
        super.init()
        maxConcurrentOperationCount = 1
    }
}

We then add such a queue to each of our UITableViewCell instances:

class GlitchyTableCell: UITableViewCell
{
    let queue = SerialOperationQueue()
}

Finally, we update our _configureCell implementation to use the operation queue:

private func _configureCell(cell: GlitchyTableCell, atIndexPath indexPath: NSIndexPath)
{
    cell.queue.cancelAllOperations()
    
    let operation: NSBlockOperation = NSBlockOperation()
    operation.addExecutionBlock { [weak operation] () -> Void in
        
        let text = self.model.textForIndexPath(indexPath)
        
        dispatch_sync(dispatch_get_main_queue(), { [weak operation] () -> Void in
            
            if let operation = operation where operation.cancelled { return }
            
            cell.textLabel?.text = text
        })
    }
    
    cell.queue.addOperation(operation)
}

(See also the Xcode project and source code of this solution).

Now, we revisit our extremely problematic model (which simulates 1000ms lag) and verify that it behaves correctly (as seen in this video):

gif 4

Finally, we dial back the simulated lag to 100ms to get a sense of what this would look like in a real-world scenario (as seen in this video):

gif 5

The result is a UITableView UX which is tollerant of 100ms of data model lag.

Conclusion

Populating UITableViewCells asynchronously ensures that your UITableView scrolling performance is decoupled from your data model performance.

About

An exploration of improving UITableView scrolling performance by using asynchronous cell population

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published