Detecting Leaky View Controllers

For iOS developers, avoiding retain cycles is one of those things that is conceptually easy but can be deceptively subtle in practice. For the uninitiated, Swift manages memory by checking how many objects reference a given variable. When all references to an object are removed, the object can be deallocated. You can however mark a variable as “weak” to say that you want to reference that object, but you don’t explicitly need it to stick around. Weak variables also have a nice property, in that when the object they refer to is deallocated, the variable automatically becomes nil. Using weak variables is a common pattern for when you simply want to inform some object that you’ve updated your state. A retain cycle occurs when you have an object that either directly or indirectly holds a reference to itself. This means that the object will never be deallocated and causes a memory leak.

A good code review process should help to catch most of these, but it’s still not 100% effective. The best way to catch these is by using the instruments tool and profiling your app, however this can be very time intensive and cannot be run continuously. Ideally we’d like to create a pro-active, always on method of detecting these leaks in some way. There are libraries for doing this, such as FBRetainCycleDetector, however they generally muck around with the objective-c run-time and aren’t compatible with Swift. At Thumbtack, we have a 100% Swift codebase so such a library isn’t useful for us.

When profiling, we noticed that some of our most commonly leaked objects are UIViewControllers, which often causes several of their dependencies to be leaked as well (views, view models, etc.). View controllers have a nice property in that, generally, they should only exist in memory when they are part of a navigation stack that is somehow tied back to the application’s root view controller. There are exceptions, which we’ll get to that in a bit. So in general, for any given view controller, we should be able to say whether or not it should still be in memory. This is the basis for our solution to automatically detect when view controllers are leaking.

Whenever we create a new view controller, we’d like to add it to a list of view controllers to “watch” to see if they are deallocated. The challenge is ensuring all view controllers get added to the list. We had a few options for this. When we first implemented this retain cycle detection we used a commonly used internal subclass of UINavigationController to override the push/present methods to add the view controllers passed to it. However, we’ve since decided to deprecate this internal subclass. So we’ve instead moved to using method swizzling (for internal builds only) on UINavigationController directly. This allows us to capture all of the view controllers pushed or presented within the app.

In order to ensure that the view controllers in our list aren’t retained simply from being in the list, we can of course use a weak reference. Additionally, if our weak reference becomes nil, it tells us it has been deallocated. If it is non-nil, it tells us the view controller is still in memory. This is the way we can “monitor” to see that it is deallocated. As we monitor our view controllers, there may be several occurrences where it is entirely valid for a view controller to be temporarily still allocated, but not part of the navigation stack (created but not pushed yet, popped but not deallocated yet, etc). So we need to build in some leniency to our code. The logic we ended up going with is to only alert when a view controller is found not to be part of the navigation stack, but still in memory two times in a row. This gets us to a container for our list something like this:

privateclass LeakContainer {
  weakvar viewController: UIViewController?
  var failcount: Int = 0

So putting this all together our retain cycle detection logic looks something like this.

  • Start a timer that runs every X seconds (only on debug and internal builds)
  • When the timer fires, first clean up all of the leak containers which have their “viewController” property cleared out via the auto-nilling behavior of Swift weak vars.
  • Iterate through the list of all candidate view controllers
  • For each view controller, create a queue of all the view controllers up the hierarchy to check
  • Check if the view controller has a parent view controller, if so add it to the queue
  • Check if the view controller has a navigation controller, if so add it to the queue
  • Check if the view controller has a presenting view controller, if so add it to the queue
  • Continue to process the queue until you find the root view controller or the queue is empty
  • If you never find the root view controller, increase the failure count on the leak container.
  • If the failure count is greater than 2, ASSERT if a debug build, log a failure if an internal build

At Thumbtack, we’ve found 7 or 8 distinct retain cycles using this methodology over a two month period. There are of course some caveats. The most obvious is that it only catches leaking view controllers and not other classes, but as I mentioned earlier, this is one of our larger sources of leaks. Another issue we see is that some system level view controllers seem to be cached so that they are used in subsequent calls. This will cause them to fail our check, so we have a list of “ignored” system level view controllers. Another issue we see is that there can be some amount of false positives in cases where you intentionally keep a view controller around generally to load it onscreen quickly later. In most cases we’ve discovered that our false positives are due to code which is keeping a view controller around when it really shouldn’t, but there can be legitimate use cases in which we need to add special logic to ignore these scenarios or validate them in other ways.

Hopefully this method will help you find some leaky view controllers in your app going forward, even when you don’t always have time for active profiling.

Source: Thumbtack

Leave a Reply

Your email address will not be published.