I have some mock code that performs a retain cycle (or at least in my mind it should). Here it is:
protocol MyService {
func perform(completion: @escaping () -> Void)
}
class MyServiceImpl: MyService {
func perform(completion: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: completion)
}
}
class MyObject {
let service: MyService
var didComplete = false
init(service: MyService) {
self.service = service
}
func doSomething(completion: @escaping () -> Void) {
service.perform {
self.didComplete = true
completion()
}
}
}
Notice in the MyObject.doSomething()
method we capture self
strongly in the service completion. This should result in a retain cycle, since MyObject
is holding a reference to service
and service
holds a reference to MyObject
. (Please enlighten me if I'm wrong.
Next, we write our test to catch this memory leak:
final class DemoTests: XCTestCase {
func test_demo() {
let service = MyServiceImpl()
let myObject = MyObject(service: service)
addTeardownBlock { [weak myObject, weak service] in
XCTAssertNil(myObject)
XCTAssertNil(service)
}
let exp = expectation(description: "wait for complete")
myObject.doSomething {
exp.fulfill()
}
wait(for: [exp], timeout: 1)
XCTAssertTrue(myObject.didComplete)
}
}
This test is passing. It shouldn't.
What am I doing wrong, or what is that I don't understand about retain cycles or the XCTest
framework?
Thank you for your help!
There is no retain cycle here. The closure indeed captures self
strongly, and self
also keeps a strong reference to MyServiceImpl
. However, when the closure is passed to MyServiceImpl
, MyServiceImpl
does not keep a strong reference of the closure. It simply passes it to DispatchQueue
, which will promptly discard the closure after it's done running. The graph looks like of like this:
DispatchQueue.main ---> closure ---> MyObject ----> MyServiceImpl
To get a retain cycle, MyService
can keep a reference to the closure:
class MyServiceImpl: MyService {
var closure: (() -> Void)?
func perform(completion: @escaping () -> Void) {
closure = completion // note this line
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: completion)
}
}
Now there is a retain cycle and your test fails. You can also see this in the memory graph debugger in Xcode.