The Most Common Issues I've Caught Reviewing iOS Apps

The Most Common Issues I've Caught Reviewing iOS Apps


About the author

Chris Griffith has been a game developer for over 19 years and a mobile developer since 2010. He’s produced dozens of apps and games for brands like Match.com, LEGO, Microsoft, Kraft, Anheuser-Busch and PepsiCo.

Chris has been a member of the PullRequest network since April 2018.


In my previous post, I covered issues I found reviewing any Swift code. They could have been in apps on any number of platforms: macOS, tvOS, etc. Here I’m going to dissect some common problems I run across specific to iOS and it’s primary framework, UIKit.

1. UITableView (and UICollectionView)

When you’ve got anywhere from a small to a seemingly infinite amount of content to display in a list, the efficient way to do that on iOS is with a table view. If you need something other than a vertically scrolling list of rows, a collection view covers those other cases. They are extremely memory and performance efficient (when set up properly) because the table will never create more cells than necessary to fill a screen. When a cell scrolls off-screen, it is put into a virtual holding tank, ready to be queued up at the opposite end of the table mere moments later.

Table and collection views can have extremely simple setups that only require a dozen lines or so to scaffold, or they can be nightmarishly complicated with custom layouts, dynamically resizing objects, etc. Because most people’s needs lie somewhere in the middle, I’ve seen all manner of implementations and all manner of bugs and quirks that get introduced. Let’s dissect a few. Note that I’m going to focus on table views for the scope of the rest of this section, but most of this applies either directly or indirectly to collection views as well.

Misunderstanding Section Headers

When you’re rendering a large amount of data, it’s likely you’ll have the table divided into sections. If this is the case, you’ll probably also have a header to visually delineate when a new section starts. The UITableViewDelegate method that allows you to specify a header often looks like this:

func tableView(_ tableView: UITableView,
     viewForHeaderInSection section: Int) -> UIView? {
  return SomeCustomView()
}

The problem here is that the views used for sections are meant to be recycled just like cells. By simply creating a new instance of a view for the section header every time, you’re unnecessarily creating lots of instances of a view that could be recycled. And because table views are a black box to us, it’s possible this could be creating a memory leak as well. So what’s the solution?

class MyCustomHeaderView: UITableViewHeaderFooterView {}

There’s a special recyclable type called UITableViewHeaderFooterView that, as the name suggests, can be used for either heads or footers. Simply extend this type to create your own custom view with as much or as little complexity as you need. It has all the mechanisms contained within to perform the requisite recycling with a little bit of additional setup, which could look something like this:

override func viewDidLoad() {
  super.viewDidLoad()
  tableView.register(MyCustomerHeaderView, forHeaderFooterViewReuseIdentifier identifier: String(describing: 
  MyCustomerHeaderView.self))
}

func tableView(_ tableView: UITableView,
     viewForHeaderInSection section: Int) -> UIView? {
  return tableView.dequeueReusableHeaderFooterView(withIdentifier: String(describing: MyCustomerHeaderView.self))
}

Using this approach, the table now manages how many of each section header it needs, and it works with any number of different headers for different sections.

Confronting CellForRowAtIndexPath

Probably the most dreaded data method to have to implement when dealing with tables is:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
  -> UITableViewCell

It tends to cause some folks a lot of consternation because of that non-optional return type. This method always has to return a valid cell if called. However, almost every table I’ve ever seen uses a custom table cell type (or many) that has properties that need to be set. On top of that, the method you’re supposed to call to retrieve a recycled cell returns the vanilla UITableViewCell type, not a generic. So you have to cast the cell to the custom class to make your changes and then return the cell. This leads to a few different approaches I’ve seen people do:

let cell = tableView.dequeueReusableCell(withIdentifier: "cellTypeIdentifier", for: indexPath) as! MyCustomCellType
cell.someProperty = someValue
return cell

This is the most straightforward. The force cast may not be pretty, but it’s one of the few places I deem it reasonable. There’s no option to return nil here, so at least if this cast fails you’ll be able to ascertain what happened from the crash logs. However, if you have a lot of different cell types to consider here, that could be a lot of force casting. Also, it’s just really verbose. Let’s look at another method I’ve seen.

if let cell = tableView.dequeueReusableCell(withIdentifier: "cellTypeIdentifier", for: indexPath) as! MyCustomCellType
  cell.someProperty = someValue
  return cell
}
return UITableViewCell()

These folks probably give themselves credit for figuring a way around the force cast, and yet still always returning a value. However, this method is also fraught. In the edge case event that line 5 runs, the table view will be thrown into an illegal state, because it will have a view in its recycler that it doesn’t own and wasn’t dequeued properly. I’ve seen this problem manifest itself and it is a terrible stack trace to have to discern.

So what do you do if you like the merits of the first example’s approach but don’t want to have to litter your code with force unwraps and casts? You do it once… in an extension.

extension UITableView {
  func registerCell(ofType cellType: UITableViewCell.Type) {
    let name = String(describing: cellType.self)
    register(UINib(nibName: name, bundle: nil), forCellReuseIdentifier: name)
  }

  func dequeueTypedCell<T: UITableViewCell>(forIndexPath indexPath: IndexPath) -> T {
    return dequeueReusableCell(withIdentifier: String(describing: T.self),
      for: indexPath) as! T
  }

}

Now we’ve isolated all the ugly code to a couple of methods we never have to touch again, and through the use of generics we’ve made casting an automatic breeze.

//Wherever cell registration occurs
tableView.registerCell(ofType: MyCustomCellType.self)

// Inside CellForRowAt
let cell: MyCustomCellType = tableView.dequeueTypedCell(forIndexPath: indexPath)
cell.someProperty = someValue
return cell

Note how much cleaner that syntax is. No super long lines, no floating string identifiers. You can use variations on these methods to do the same thing for the header/footer views I mentioned above.

Let Cells Do Their Job

Another issue I see related to cellForRowAtIndexPath is that sometimes developers simply expose a custom cell’s various UI components and let that method populate everything. For instance:

let cell = tableView.dequeueReusableCell(withIdentifier: "cellTypeIdentifier", for: indexPath) as! MyCustomCellType
cell.nameLabel.text = data.name
cell.dateLabel.text = data.date
cell.avatarImageView.image = data.image
return cell

The purpose of cellForRowAtIndexPath is to dequeue a cell and hand it data. That’s it. Full stop. The cell should take care of all of its state internally from this data. Simply pass the model object (or view model, depending on your architecture) and let the cell work its magic.

let cell: MyCustomCellType = tableView.dequeueTypedCell(forIndexPath: indexPath)
cell.data = data
return cell

// Inside the cell class
var data: SomeDataType? {
  didSet {
    nameLabel.text = data?.name
    dateLabel.text = data?.date
    avatarImageView.image = data?.image
  }
}

You could also perform a guard in the didSet block to only assign those values if the data value exists. Leaving it like this will blank all those properties out when data is nil, which can help with rendering performance. The other benefit from this approach is code reuse. If you use the same cells on multiple screens, you’ll only ever have to provide the data instead of copying and pasting the UI code all over the place.

2. Use UIStackView More

Introduced all the way back in iOS 9, UIStackView was Apple’s long-overdue answer to Android’s LinearLayout; a convenient, easy way to vertically or horizontally display a series of elements with minimal constraints or fuss. Combined with a UIScrollView they make supporting multiple device sizes a cinch. I use them exhaustively in my visual layout files. However, I regularly come across two scenarios where folks are making more work for themselves by not using this incredible tool.

More Containers, Less Constraints

If you compose a layout entirely of constraints between various elements on screen, it becomes quite the chore to change the layout in the event certain elements need to be hidden. You not only have to track the visibility of the view you need to hide, but also at least one constraint that allows you to collapse the empty space left by that view. UIStackView solves this elegantly by acknowledging the isHidden property of views. When a view is hidden, the stack view factors it out of the layout measurements. It acts as though the view wasn’t there to begin with, akin to Android’s View.GONE. This is a huge win, as you only need to consider a single boolean flag to update your layout. Also, because screens in apps are rarely static, stack views make the process of adding new elements to a layout as easy as drag-and-drop. When creating a new storyboard view, always consider how much of it you can compose with stack views. It will save so much time and effort down the road.

Layout in Code

I have some pretty strong opinions about this topic that I won’t get into here in this post, but I will acknowledge that there are some times when building a layout from code is the more flexible solution for a particular problem. Setting up NSLayoutConstraints in code is a cumbersome, repetitive experience. I always recommend that folks set on doing this look into OSS frameworks like Anchorage to simplify and reduce typing. However, an even better way of addressing this problem is through the use of stack views.

The syntax for adding a view into the stack from code is equally straightforward.

stackView.addArrangedSubview(view)
stackView.insertArrangedSubview(view, atIndex: Int)

No messy constraints. No rigid, forgettable commands and parameters. Configure your stack view, add it to the main view either with constraints or without (using an autoresize mask), and then slot your content into the stack view one at a time. If you’re going to do your layout in code instead of storyboards or XIBs, UIStackView will save you time and effort.

3. Understanding Threads

In iOS (and similarly in other Apple-based OS’s like macOS, tvOS, etc), there are a few ways to execute larger chunks of work that are processor or memory-intensive. Sometimes I see developers doing heavy operations like this in ways that will negatively impact the user experience.

The UI Thread

The OS uses the primary/main thread for updating the UI. This is where all animation and visual updates are performed. It’s the baseline thread - basically all code you write in a standard iOS app will run on the main thread. Though modern iPhones and iPads are very powerful, it’s still possible to bring UI responsiveness to a crawl by mistakenly executing intensive operations on the main thread. This might be something as seemingly common as parsing a large JSON file or as heavy as compressing a movie file into the H.265 codec. Either way, this work should be done on a background thread.

GCD

The first common way to handle threading is to use Apple’s Grand Central Dispatch (or GCD) framework. It provides very simple tools for executing one-off operations on various threads, but it also provides a robust basis for building a custom threading/queuing solution if your project calls for it. One of the most common places to see it used is in a basic network call.

if let url = URL(string: "http://www.somedomain.com/api") {
  let session = URLSession.shared
  let task = session.dataTask(with: url) { (data, response, error) in
    // This is not on the main thread
  }
  task.resume()
}

When you spin up a task to load data from a URL, the block that is called when it is complete is not run on the main thread. A common mistake developers make is to load some data to display in a UI element and assign it from within this block. Accessing UI elements from any thread but the main one will at best cause erratic behavior and delayed UI updates or at worst crash the app completely. So how does one get back to the main thread from within this block?

let task = session.dataTask(with: url) { (data, response, error) in
  DispatchQueue.main.async {
    // This code will run on the main thread
  }
}

A common pattern to follow is to decode the data received from the network call (be it JSON, XML, an image, etc) while still on the background thread, and then deliver the processed result to the UI via GCD.

Operations and OperationQueue

One of the limitations of basic GCD calls is that there’s no way to easily interrupt their life-cycle or cancel the work they’re doing. When you’re doing something that’s time-intensive or user-driven, like encoding images or movies, you usually want to offer the user the option to stop it. In cases like these, the Operations and the OperationQueue are the best solution. There’s more boilerplate to setting them up, which is why for simple, quick situations GCD can fit the bill nicely. In addition to being cancelable, you can set up a link of dependencies between operations. Imagine a scenario where you were converting a bunch of images to a specific format, compressing them into a single archive, and then uploading them to a server. This requires a specific order, but you also want the system to maximize its resources and convert as many of the images simultaneously as it can, then perform the final operation.

Consider the following code. This is a very simplified example designed just to illustrate how dependencies work. You can define two operations, make the first a dependency of the second, and regardless of the order you add them into the queue, the op1 will always run first.

class MyOperation: BlockOperation {}

let op1 = MyOperation {
    print("Op1 complete")
}

let op2 = MyOperation {
    print("Op2 complete")
}

op2.addDependency(op1)
let queue = OperationQueue()
queue.qualityOfService = .userInitiated // Not the main thread
queue.addOperations([op2, op1], waitUntilFinished: false)

As your operations get more complex and you start to manipulate queues, you’ll want to safeguard against deadlocking your app. Properly implemented, however, these tools provide the basis for performing work as efficiently as possible. If you want to read more about setting up Operations, Apple’s documentation is quite thorough and useful: https://developer.apple.com/documentation/foundation/operation

Wrap Up

Out of all of Apple’s operating systems, iOS continues to be the most popular for building apps, and in 12 years it has grown increasingly feature-rich and complex. Understanding the intricacies of the system will help us all avoid inexplicable bugs and crashes, and make even better user experiences.

Preview photo by Jason Howie on Flickr


About PullRequest

PullRequest is a platform for code review, built for teams of all sizes. We have the world's largest network of on-demand reviewers, backed by best-in-class automation tools. Because code quality is important.

Learn more about PullRequest

Chris Griffith headshot
by Chris Griffith

March 20, 2020