How to detect iOS memory leaks and retain cycles using Xcode’s memory graph debugger
At DoorDash we are consistently making an effort to increase our user experience by increasing our app’s stability. A major part of this effort is to prevent, fix and remove any retain cycles and memory leaks in our large codebase. In order to detect and fix these issues, we have found the Memory Graph Debugger to be quick and easy to use. After significantly increasing our OOM-free session rate on our Dasher iOS app, we would like to share some tips on avoiding and fixing retain cycles as well as a quick introduction using Xcode’s memory graph debugger for those who are not familiar.
I. What are Retain Cycles and Memory Leaks?
A memory leak in iOS is when an amount of allocated space in memory cannot be deallocated due to retain cycles. Since Swift uses Automatic Reference Counting (ARC), a retain cycle occurs when two or more objects holds strong references to each other. As a result these objects retain each other in memory because their retain count would never decrement to 0, which would prevent deinit from ever being called and memory from being freed.
II. Why Should We Care?
Memory leaks increases memory footprint incrementally for your app, and when it reaches a certain threshold the operating system (iOS) would trigger a memory warning. If that memory warning is not handled, your app would be force-killed, which is an OOM (Out of memory) crash. As you can see, memory leaks can be very problematic if a substantial leak occurs because after using your app for a period of time, the app would crash.
In addition, memory leaks can introduce side effects in your app. Typically this happens when observers are retained in memory when they should have been deallocated. These leaked observers would still listen to notifications and when triggered the app would be prone to unpredictable behaviors or crashes. In the next section we will go over an introduction to Xcode’s memory graph debugger and later use it find memory leaks in a sample app.
III. Introduction to Xcode’s Memory Graph Debugger
To open, run your app (In this case I am running a demo app) and then tap on the 3-node button in between the visual debugger and location simulator button. This will take a memory snapshot of the current state of your app.
The left panel shows you the objects in memory for this snapshot followed by the number of instances of each class next to it’s name.
Signifies that there is only one
MainViewController in memory at the time of the snapshot, followed by the address of that instance in memory below.
If you select an object on the left panel, you will see the chain of references that keep the selected object in memory. For example, selecting
MainViewController would show us a graph like this:
- The bold lines mean there is a strong reference to the object it points to.
- The light gray lines mean there is an unknown reference (could be weak or strong) to the object it points to.
- Tapping an instance from the left panel will only show you the chain of references that is keeping the selected object in memory. But it will not show you what references that the selected object has references to.
For example, to verify that there is no retain cycle in the objects which
MainViewController has a strong reference to, you would need to look at your codebase to identify the referenced objects, and then individually select each of the object graphs to check if there is a retain cycle.
In addition, the memory graph debugger can auto-detect simple memory leaks and prompt you warnings such as this purple
! mark. Tapping it would show you the leaked instances on the left panel.
Please note that the Xcode’s auto-detection does not always catch every memory leak, and oftentimes you will have to find them yourself. In the next section, I will the approach to using the memory graph debugger for debugging.
IV. Approach on Using the Memory Graph Debugger
A useful approach for catching memory leaks is running the app through some core flows and taking a memory snapshot for the first and subsequent iterations.
- Run through a core flow/feature and leave it, then repeat this several times and take a memory snapshot of the app. Take a look at what objects are in-memory and how much of each instance exists per object.
- Check for these signs of a retain cycle/memory leak:
- In the left panel do you see any objects/classes/views and etc on the list that should not be there or should have been deallocated?
- Are there increasingly more of the same instance of a class that is kept in memory? ex:
MainViewController (5)after going through the flow 4 more iterations?
- Look at the Debug Navigator on the left panel, do you notice an increase in Memory? Is the app now consuming a greater amount of megabytes (MB) than before despite returning to the original state
- If you have found an instance that shouldn’t be in memory anymore, you have found a leaked instance of an object.
- Tap on that leaked instance and use the object graph to track down the object that is retaining it in memory.
- You may need to keep navigating the object graphs as you track down what is the parent node that is keeping the chain of objects in memory.
- Once you believe you found the parent node, look at the code for that object and figure out where the circular strong referencing is coming from and fix it.
In the next section, I will go through an example of common use cases of code that I’ve personally seen that causes retain cycles. To follow along, please download this sample project called LeakyApp.
V. Fixing Memory Leaks by Example
Once you have downloaded the same Xcode project, run the app. We will go through one example using the memory graph debugger.
- Once the app is running you will see three buttons. We will go through one example so tap on “Leaky Controller”
- This will present the
ObservableViewControllerwhich is just an empty view with a navigation bar.
- Tap on the back navigation item.
- Repeat this a few times.
- Now take a memory snapshot.
After taking a memory snapshot, you will see something like this:
Since we repeated this flow several times, once we return back to the main screen
MainViewController the observable view controller should have been deallocated if there were no memory leaks. However, we see
ObservableViewController (25) in the left panel, which means we have 25 instances of that view controller still in memory! Also note that Xcode did not recognize this as a memory leak!
Now, tap on
ObservableViewController (25). You will see the object graph and it would look similar to this:
As you can see, it shows a
Swift closure context, retaining
ObservableViewController in memory. This closure is retained in memory by
__NSObserver. Now let’s go to the code and fix this leak.
Now we go to the file
ObservableViewController.swift. At first glance, we have a pretty common use case:
We are registering an observer in
viewDidLoad and removing self as an observer in
deinit. However, there is one tricky usage of code here:
We are passing a function as a closure! Doing this captures
self strongly by default. You may refer back to the object graph as proof that this is the case.
NotificationCenter seems to keep a strong reference to the closure, and the
handleNotification function holds a strong reference to
self, keeping this
UIViewController and objects it holds strong references to in memory!
We can simply fix this by not passing a function as a closure and adding
weak self to the capture list:
Now rebuild the app and re-run that flow several times and verify that the object has now been deallocated by taking a memory snapshot.
You should see something like this where
ObservableViewController is nowhere on the list after you have exited the flow!
The memory leak has been fixed! 🎉 Feel free to test out the other examples in the LeakyApp repo, and read through the comments. I have included comments in each file explaining the causes of each retain cycle/memory leak.
VI. Additional Tips To Avoid Retain Cycles
- Keep in mind that using a function as closure keeps a strong reference by default. If you have to pass in a function as a closure and it causes a retain cycle, you can make an extension or operator overload to break strong reference. I won’t be going over this topic but there are many resources online for this.
- When using views that have action handlers through closures, be careful to not reference the view inside its own closure! And if you do, you must use the capture list to keep a weak reference to that view, with the closure that the view has a strong reference to.
For example, we may have some reusable view like this:
In the caller, we have some presentation code like this:
This is a retain cycle here because
actionHandler captures a strong reference to
someModalVC holds a strong reference to the
To fix this:
We need to make sure the reference to
weak by updating the capture list with
[weak someModalVC] in to break the retain cycle.
3. When declaring properties on your objects and you have a variable that is a protocol type, be sure to add a class constraint and declare it as
weak if needed! This is because the compiler will give you an error by default if you do not add a class constraint. Although It is pretty well known that the
delegate in the delegation pattern is supposed to be
weak, but keep in mind that this rule still applies for other abstractions and design patterns, or any protocol variables you declare.
For example, here we a stubbed out clean swift pattern:
Here, we need the
view property must be a weak reference or else we will have a strong circular reference from the
View. However updating that property to
weak var view: OrdersListDisplayLogic we will get a compiler error.
This compiler error may look discouraging to some when declaring a protocol-typed variable as weak! But in this case, you have to fix this by adding a class constraint to protocol!
Overall, I have found using Xcode Memory Graph Debugger a quick and easy way to find and fix retain cycles and memory leaks! I hope you find this information useful and keep these tips in mind regularly as you develop! Thanks!