Memory leaks often happen without notice. Although best practices like using a weak reference to self inside closures help a lot, it’s usually not enough to prevent you from finding a certain memory peak during the development of a project.
We can use memory graph debugging or Xcode Instruments to help find and fix leaked and abandoned memory. However, using tools manually requires us to make it part of our regular workflow to prevent memory leaks from sneaking in.
Making it easy to test your app for memory leaks prevents us from introducing them without notice. We will not avoid all leaks, but it will decrease the chance. A good practice is to write a unit test reproducing a memory leak you encountered to ensure the memory leak does not return in the future. Let’s see how this works.
Avoiding memory leaks using unit tests
Writing a unit test by combining a weak reference with an autoreleasepool makes it easy to verify deallocation. You can see this technique as ascertaining that the deinit of a class executes and the memory releases.
In the following example, we’re verifying that a view controller is released:
/// Ensures that the OwnedBucketViewController gets deallocated after being added to the navigation stack, then popped.
func testDeallocation() {
assertDeallocation { () -> UIViewController in
let bucket = Bucket()
let viewModel = OwnedBucketViewModel(bucket: bucket)
return OwnedBucketViewController(viewModel: viewModel)
}
}
This test will fail if any logic within the testing view controller retains the given view controller.
The extension method makes it effortless to add deallocation tests to your view controller test classes. It’s a great way to verify that your view controllers get released correctly during development.
The assert deallocation method is an extension method on XCTestCase
and looks as follows:
extension XCTestCase {
/// Verifies whether the given constructed UIViewController gets deallocated after being presented and dismissed.
///
/// - Parameter testingViewController: The view controller constructor to use for creating the view controller.
func assertDeallocation(of testedViewController: () -> UIViewController) {
weak var weakReferenceViewController: UIViewController?
let autoreleasepoolExpectation = expectation(description: "Autoreleasepool should drain")
autoreleasepool {
let rootViewController = UIViewController()
// Make sure that the view is active and we can use it for presenting views.
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
window.rootViewController = rootViewController
window.makeKeyAndVisible()
/// Present and dismiss the view after which the view controller should be released.
rootViewController.present(testedViewController(), animated: false, completion: {
weakReferenceViewController = rootViewController.presentedViewController
XCTAssertNotNil(weakReferenceViewController)
rootViewController.dismiss(animated: false, completion: {
autoreleasepoolExpectation.fulfill()
})
})
}
wait(for: [autoreleasepoolExpectation], timeout: 10.0)
wait(for: weakReferenceViewController == nil, timeout: 3.0, description: "The view controller should be deallocated since no strong reference points to it.")
}
}
The extension creates a weak reference to the view controller produced by the closure. After that, we present and dismiss the created view controller to ensure all lifecycle events execute correctly and the logic represents an actual in-app usage. Finally, we’re verifying that the weak reference resets to nil
.
To validate that our weak reference resets to nil
we’re using another extension method on XCTestCase
which is a convenient method to check for a particular condition periodically:
extension XCTestCase {
/// Checks for the callback to be the expected value within the given timeout.
///
/// - Parameters:
/// - condition: The condition to check for.
/// - timeout: The timeout in which the callback should return true.
/// - description: A string to display in the test log for this expectation, to help diagnose failures.
func wait(for condition: @autoclosure @escaping () -> Bool, timeout: TimeInterval, description: String, file: StaticString = #file, line: UInt = #line) {
let end = Date().addingTimeInterval(timeout)
var value: Bool = false
let closure: () -> Void = {
value = condition()
}
while !value && 0 < end.timeIntervalSinceNow {
if RunLoop.current.run(mode: RunLoop.Mode.default, before: Date(timeIntervalSinceNow: 0.002)) {
Thread.sleep(forTimeInterval: 0.002)
}
closure()
}
closure()
XCTAssertTrue(value, "Timed out waiting for condition to be true: \"\(description)\"", file: file, line: line)
}
}
An alternative would be to use a block-based predicate, but these only check conditions once a second and make your tests much slower.
The XCTest
API allows creating expectations for notifications, predicates, or key-value observation, but none of these make it possible to validate that the weak reference eventually resets to nil
. Our new extension method does make this possible and checks for the given amount of time whether the condition matches.
The use of an autoreleasepool
Without the autoreleasepool, we would’ve not been able to verify the release of weak references. All the references within the autoreleasepool closure should be released when the pool drains.
You can see an autoreleasepool as a local context or a local container. Everything defined within this container should release once the container goes out of context. Any strong references will prevent an object from releasing, causing the memory to leak.
Catching memory leaks for regular objects
For this article, I teamed up with Vincent Pradeilles, who covered a similar topic in one of his videos. Using a similar approach as demonstrated in this article for view controllers, we can test for the deallocation of regular objects. Structs are excluded in this case since they’re value types. If you’re new to value types, I encourage you to read Struct vs classes in Swift: The differences explained.
The following extension defines another assertDeallocation
method, but this time it takes a closure returning a generic AnyObject
:
extension XCTestCase {
func assertDeallocation<T: AnyObject>(of object: () -> T) {
weak var weakReferenceToObject: T?
let autoreleasepoolExpectation = expectation(description: "Autoreleasepool should drain")
autoreleasepool {
let object = object()
weakReferenceToObject = object
XCTAssertNotNil(weakReferenceToObject)
autoreleasepoolExpectation.fulfill()
}
wait(for: [autoreleasepoolExpectation], timeout: 10.0)
wait(for: weakReferenceToObject == nil, timeout: 3.0, description: "The object should be deallocated since no strong reference points to it.")
}
}
The technique is similar to our view controller extension method. Only the presenting logic is missing which is why you can’t use it for view controllers. Combining the two deallocations assert methods will allow you to catch many retain issues through unit tests.
More on memory debugging
WWDC dedicated quite a few sessions to memory debugging. For example, the iOS Memory Deep Dive session of WWDC 2018 helps you understand the actual memory cost of an image and gives you tips and tricks for reducing an app’s memory footprint.
Conclusion
Memory leaks and retain cycles can be a pain to debug, but you’ll be better at detecting them early on with proper techniques. We can use unit tests to assert deallocations for objects and view controllers to ensure not to introduce any new retain cycles during development.
If you like to improve your Swift knowledge, check out the Swift category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.
Thanks!