A detailed explanation of memory leaks in iOS apps

Memory leaks in iOS can be extremely frustrating. Not only can they cause unintended and non-obvious consequences, vague crashes for example, but they can also be difficult to track down. While memory leaks alone won't necessarily cause an application to crash, keeping instances of objects around longer than they should can lead to unintended behaviors. The crash log below is indirectly the result of a memory leak caused by a retain cycle in an iOS app.

Example crash log indirectly caused by a memory leak

A memory leak occurs when the system is unable to determine if allocated space in memory is in use or not. Both Swift and Objective-C use Automatic Reference Counting (ARC) to manage space in memory.

ARC is a memory-management system that keeps track of how many references there are to a given object. When there are no longer any references to an object, the system knows that object is no longer needed and can be deallocated from memory. For a deeper dive into how Automatic Reference Counting (ARC) works, head over to the docs on Swift.org.

The most frequent culprit of memory leaks in iOS is a retain cycle. A retain cycle prevents an object from being deallocated even after its creator has been deallocated. Here a brief example:

class Dog {
    var name: String
    var owner: Person?
  
    init(name: String) {
        self.name = name
    }
}

class Person {
    var name: String
    var dog: Dog?
  
    init(name: String) {
        self.name = name
    }
}

let myles = Dog(name: "Myles")
let tim = Person(name: "Tim")

myles.owner = tim
tim.dog = myles

In this example, both objects contain references to each other. Thus, even after their creator has been deallocated, both objects will remain in memory because their reference counts are both still greater than zero.

The solution to this problem is a weak reference that does not increment an object's counter and therefore does not affect an whether or not an object can be deallocated. Here is an updated version of the example above that does not cause a retain cycle:

class Dog {
    var name: String
    weak var owner: Person?
  
    init(name: String) {
        self.name = name
    }
}

class Person {
    var name: String
    weak var dog: Dog?
  
    init(name: String) {
        self.name = name
    }
}

let myles = Dog(name: "Myles")
let tim = Person(name: "Tim")

myles.owner = tim
tim.dog = myles

While retain cycles can occur between any objects, I have found the most frequent occurrence in iOS to be leaked instances of UIViewController given the long list of responsibilities delegated to it in a typical iOS app.

Asynchronous tasks such as network requests, database calls, image processing, etc. are easy candidates for unintentional retain cycles given that their interfaces typically involve a closure. Here is an example of a view controller that has a cycle:

class Worker {
    func performLongRunningTask(_ completion: () -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
            completion()
        }
    }
}

class ViewControllerA: UIViewController {

    private let worker = Worker()
    private var isTaskFinished = false

    override func viewDidLoad() {
        super.viewDiDLoad()
        worker.performLongRunningTask {
            self.markTaskFinished()
        }
    }
    
    func markTaskFinished() {
        isTaskFinished = true
    }
}

ViewControllerA has a reference to a Worker object that performs a task that takes some time to complete. As a result, the worker object uses a closure to notify the call of its completion. In this example, ViewControllerA implicitly creates a strong reference from Worker to ViewControllerA by referencing self inside the closure passed to Worker as a parameter in the function performLongRunningTask.

The solution to this problem is to use a weak reference to self like so:

class Worker {
    func performLongRunningTask(_ completion: () -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
            completion()
        }
    }
}

class ViewControllerB: UIViewController {
  
    private let worker = Worker()
    private var isTaskFinished = false

    override func viewDidLoad() {
        super.viewDiDLoad()
        worker.performLongRunningTask { [weak self] in
            self?.markTaskFinished()
        }
    }
    
    func markTaskFinished() {
        isTaskFinished = true
    }
}

In this example, ViewControllerB is still referencing self inside the closure passed to Worker, but it declared the reference weak by add [weak self] to the closure's parameters. This approach successfully avoids introducing a retain cycle if ViewControllerB is dismissed before Worker has finished its task.

When designing interfaces that require references to communicate, such as Worker in this example, it is important to use empathy and think about how other engineers will use your code.

Interacting with closures is easy, but it creates an easy opportunity to introduce retain cycles. The responsibility of avoiding a retain cycle now falls on the engineer interacting with Worker, who may not be the engineer who wrote the code for Worker and may not have a solid understanding of retain cycles.

Empathy is key here and we can use a common design pattern to remove this responsibility from the engineer interacting with Worker. Let's take a look at this example:

protocol WorkerDelegate {
    func taskDidComplete()
}

class Worker {
    weak var delegate: WorkerDelegate?

    func performLongRunningTask() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
            self.delegate?.taskDidComplete()
        }
    }
}

class ViewControllerC: UIViewController {
  
    private let worker = Worker()
    private var isTaskFinished = false

    override func viewDidLoad() {
        super.viewDiDLoad()
        worker.delegate = self
        worker.performLongRunningTask()
    }
    
    func markTaskFinished() {
        isTaskFinished = true
    }
}

extension ViewControllerC: WorkerDelegate {
    func taskDidComplete() {
        markTaskFinished()
    }
}

ViewControllerC has the same exact output as ViewControllerB. However, the interface for Worker is updated in this example to explicitly avoid introducing a retain cycle because the worker's delegate will always be a weak reference.

This common delegate pattern is just one example of how thoughtful design patterns can be used to write stronger, more reliable code. I encourage all engineers to use more empathy when writing code.

Thanks for reading and stay tuned for more!

Show Comments