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.
If pinpointing root causes of problematic memory is interesting to you, check out our new blog post Examining Problematic Memory in C/C++ Applications with BPF, perf, and Memcheck for a detailed explanation of how memory works.
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 hold 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 about memory leaks?
Memory leaks increase the memory footprint incrementally in your app, and when it reaches a certain threshold the operating system (iOS) this triggers 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.
ex: (MainViewController(1))
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.
Stay Informed with Weekly Updates
Subscribe to our Engineering blog to get regular updates on all the coolest projects our team is working on
Please enter a valid email address.
Thank you for Subscribing!
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 0x7f85204227c0
under 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 explain the approach to using the memory graph debugger for debugging.
IV. The approach to 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 (1)
becomesMainViewController (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 with an 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
ObservableViewController
which 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:
https://gist.github.com/chauvincent/33cf83b0894d9bb12d38166c15dd84a5
We are registering an observer in viewDidLoad
and removing self as an observer in deinit
. However, there is one tricky usage of code here:
https://gist.github.com/chauvincent/b191414d54ba4cbb04614b1f85ac2e24
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 a 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 someModalVC
's actionHandler
captures a strong reference to someModalVC
. Meanwhile someModalVC
holds a strong reference to the actionHandler
To fix this:
We need to make sure the reference to someModalVC
is weak
by updating the capture list with [weak someModalVC] in
to break the retain cycle.
3. When you are 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 OrdersListPresenter
's view
property must be a weak reference or else we will have a strong circular reference from the View
-> Interacter
-> Presenter
-> View
. However when 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 the protocol!
Overall, I have found using Xcode Memory Graph Debugger to be 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!