Tracking Memory Leaks in iOS App with SwiftUI at Runtime

Hello everyone! My name is Farid, I am an iOS developer at Banki.ru.

Sooner or later, every project faces the problem of memory leaks: its use increases, in some scenarios the application behaves strangely or even crashes. A long and painful search for the causes of the leak and debugging of the code begins.

In our project the bet is made to use SwiftUI, which complicates the solution of the problem: due to the declarative approach and the lack of an explicit life cycle in the UI, it is more difficult to detect the cause of the memory leak.

In this article we:

  • Let's go over the main approaches to leak detection;

  • let's try to find a way to make his leak detection less painful;

  • Let's find out if it's possible to somehow insure against leaks in the future development of the project.

Available tools

Xcode has some good tools for finding memory leaks. Let's go over them briefly and highlight their advantages and disadvantages.

An experimental approach

During testing, you can monitor the memory usage and, based on changes in this indicator, hypothesize about the presence of a leak. For example, after entering the screen and immediately exiting, we expect that the memory usage will increase and then return to an indicator close to the starting one.

If there is a leak, when repeating these manipulations multiple times, we will observe the following “ladder” on the graph:

In the absence of a leak, the picture is different:

The advantage of this approach is that we can roughly say where the leak is.

Cons:

  • you have to check each screen separately;

  • there are no signals that this procedure needs to be performed at all;

  • after using the application for a long time, we only see a large amount of memory usage. We also have no information about which object was not released.

Memory Graph

More information can be provided Xcode Memory Graph.

Here it is already clearly visible which object has not been released.

However, one of the disadvantages of the previous approach remains: the leak must be specifically searched for.

But there is also a nice bonus: in the list of memory allocations, you can filter out leaks and use Memory Graph to determine their cause.

That is, using Memory Graph we can detect a leak during debugging and identify its sources. However, we still do not receive any clear signal about the presence of a leak: we need to stop the application execution and check the list of memory allocations.

Other methods

The Leaks tool in Instruments provides functionality roughly similar to that described above. We will not dwell on it in detail.

Xcode's static analysis tool can find leaks, but it doesn't work with Swift code.

You can also use symbolic breakpoints:

In this case, we will receive messages like

--- -[UIViewController dealloc] @"<MemoryTest.LeakingViewController: 0x7f88acc69260>"

if the object was released. If we don't see the message, it probably didn't happen and there was a leak.

But this approach is not applicable everywhere and requires setting breakpoints for calling each specific dealloc or deinit.

You can also add a leak check step to your unit test:

addTeardownBlock { [weak viewController] in
  XCTAssertNil(viewController, "Expected deallocation of \(viewController)")
}

But this technique is also limited: it only works in cases of leakage in the specific scenario that is checked by this test.

I will summarize the consideration of existing methods and highlight some common disadvantages of the approaches:

  • We must either assume in advance that a leak exists, or monitor them continuously while debugging or testing the application.

  • All approaches involve Xcode. If a leak occurs during testing on a real device in a specific scenario and without Xcode, testing will not detect the leak.

  • At the same time, we would like the application to crash in the event of a leak, and for the QA engineer to be able to create a report describing the scenario that led to the crash. After that, when reproducing this scenario, the developer would be able to identify the causes of the leak using the tools described earlier.

Software leak detection

Further in the article we will create software tools for searching for leaks by application execution time.

Let's look at the most common case: when a ViewController screen is released, and for some reason the ViewModel of this screen remains in memory. Let's try to cause an abnormal termination of the application for this case, indicating which object was not released, although we expected it.

Let's describe this behavior:

enum LeakDetection {

    static func expectDeallocation(_ object: AnyObject, in timeInterval: TimeInterval = 1) {
        DispatchQueue.main.asyncAfter(deadline: .now() + timeInterval) { [weak object] in
            if let object {
                fatalError("Expected deallocation of \(object)")
            }
        }
    }
}

Now, if we call this function in our ViewController's deinit and pass a reference to our ViewModel, we'll get a crash if the ViewModel isn't freed after one second.

Let's try the function and describe such a “leaking” model:

final class LeakingViewModel: ObservableObject {
    var leak: AnyObject? = nil

    init() {
        leak = self
    }
}

And we apply our function in the controller:

final class LeakingViewController: UIViewController {
    let viewModel = LeakingViewModel()

    deinit {
        LeakDetection.expectDeallocation(viewModel)
    }
}

We launch and get the expected crash:

For the UIKit-using variant, this is a completely working scheme. An important feature is that we know the moment when we expect the model to be released — when the controller is released, that is, in the deinit of this controller. The call to LeakDetection.expectDeallocation(:) will need to be placed in the deinit of all our controllers.

However, if we use SwiftUI, we don't have a controller object. View is a structure that doesn't have deinit. That is, we simply don't have a function that would be called when View and its related objects are finally released. At the same time, even if in some cases we can bind to the moment when View is definitely removed from the screen – for example, if there is a “Close” button – then when View is inside the navigation stack and is removed from the screen by the “Back” button or even by swiping, we won't be able to track it easily. The onDisappear method is also not suitable, since it will be triggered in many other cases not related to the final removal of View from the screen. For example, when showing some modal screen.

We can add a property object to the View, which when released we will wait for the model to be released:

struct LeakingView: View {
    @StateObject var viewModel = LeakingViewModel()
    @State private var leakWatcher = LeakWatcher()

    var body: some View {
        Text("Hello world!")
            .onAppear {
                leakWatcher.expectDeallocation(viewModel)
            }
    }
}

final class LeakWatcher {

    private struct WatchObject {
        weak var object: AnyObject?
        let timeInterval: TimeInterval
    }

    private var watches: [WatchObject] = []

    func expectDeallocation(_ object: AnyObject, in timeInterval: TimeInterval = 1) {
        watches.append(.init(object: object, timeInterval: timeInterval))
    }

    deinit {
        for watch in watches {
            if let object = watch.object {
                LeakDetection.expectDeallocation(object, in: watch.timeInterval)
            }
        }
    }
}

The scheme also works. The obvious downside is too much boilerplate code in the View. We would like to somehow mark the properties, the objects in which should be released. Some kind of property wrapper suggests itself, so that our View looks something like this:

struct LeakingView: View {
    @StateObject @Deallocating var viewModel = LeakingViewModel()

    var body: some View {
        Text("Hello world!")
    }
}

At the same time, it must correspond to ObservableObject so that View can subscribe to its changes, as well as to changes in the properties of this object. The result is a class like this:

@propertyWrapper @dynamicMemberLookup
final class Deallocating<Value: AnyObject> {
    var wrappedValue: Value
    private let timeInterval: TimeInterval
 
    init(wrappedValue: Value, timeInterval: TimeInterval = 1) {
        self.wrappedValue = wrappedValue
        self.timeInterval = timeInterval
    }
 
    subscript<Member>(dynamicMember keyPath: WritableKeyPath<Value, Member>) -> Member {
        get {
            wrappedValue[keyPath: keyPath]
        }
        set {
            wrappedValue[keyPath: keyPath] = newValue
        }
    }
 
    deinit {
        LeakDetection.expectDeallocation(wrappedValue, in: timeInterval)
    }
}

extension Deallocating: ObservableObject where Value: ObservableObject {

    var objectWillChange: Value.ObjectWillChangePublisher {
        wrappedValue.objectWillChange
    }
}

We retained all the functionality of @StateObject and ensured tracking of the model release when the View is released. Or rather, the values ​​of its properties in the attribute graph.

It is also characteristic that this property wrapper is applicable to any object properties that are not ObservableObject.

I would like to point out that the described method works great in combination with code generation. We can add the @Deallocating annotation to the View template and all new application screens will be protected from model leaks out of the box.

So, we have created a runtime memory leak tracking tool. If a leak occurs during development or testing of the application, it will crash. We will receive a signal about the leak, the sequence of actions in the application that leads to it. We will also be able to quickly fix the leak using the tools described above.

Preventing problems in the long term is more effective than reacting to them after the fact. Having insured critical areas of the application against leaks using the described method, we did not detect any memory leaks at the moment. At the same time, we can be sure that this problem will not appear during further development of the project and we will not have to spend testing and development resources.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *