Hi -
I'm working with Combine and SwiftUI. Recently I've come across an issue with Combine and assign(to published: inout Published<Self.Output>.Publisher) which is producing some results that are confusing to me and I'm hoping someone in the community can help explain if I doing something wrong or if there is possible a memory leak.
FYI I came across this topic Does ‘assign(to:)’ produce memory leaks? which is similar but different (as you'll see further on).
Here is a very simplified version of what I'm doing:
import Combine
class ChildViewModel {
@Published var done: Void = ()
init(){
print("init ChildViewModel - \(Unmanaged.passUnretained(self).toOpaque())")
}
deinit { print("deinit ChildViewModel - \(Unmanaged.passUnretained(self).toOpaque())") }
}
class ParentViewModel {
@Published var childViewModel: ChildViewModel? = nil
private var cancellableSet = Set<AnyCancellable>()
init(useAssign: Bool) {
if useAssign {
$childViewModel
.compactMap { $0 }
.eraseToAnyPublisher()
.flatMap { $0.$done }
.map { _ in nil }
.assign(to: &$childViewModel)
} else {
$childViewModel
.compactMap { $0 }
.eraseToAnyPublisher()
.flatMap { $0.$done }
.map { _ in nil }
.sink { [weak self] in self?.childViewModel = $0 }
.store(in: &cancellableSet)
}
}
deinit { print("deinit ParentViewModel") }
}
print("ParentViewModel test - use `assign`")
var viewModel: ParentViewModel? = ParentViewModel(useAssign: true)
viewModel?.childViewModel = ChildViewModel()
viewModel?.childViewModel = ChildViewModel()
print("`viewModel` set to nil")
viewModel = nil
print()
print("ParentViewModel test - use `sink` & `store`")
viewModel = ParentViewModel(useAssign: false)
viewModel?.childViewModel = ChildViewModel()
viewModel?.childViewModel = ChildViewModel()
print("`viewModel` set to nil")
viewModel = nil
What I'm trying to say in the code is that I have a class ParentViewModel. It has an optional member childViewModel. When ever childViewModel has a value I'd like to listen to a publishing member of ChildViewModel called done. If done produces a value I'de like to clear childViewModel by setting its value to nil.
running this code produces the following results
ParentViewModel test - use `assign`
init ChildViewModel - 0x0000600000a18360
init ChildViewModel - 0x0000600000a279a0
deinit ChildViewModel - 0x0000600000a18360
`viewModel` set to nil
deinit ParentViewModel
ParentViewModel test - use `sink` & `store`
init ChildViewModel - 0x0000600000a09900
init ChildViewModel - 0x0000600000a097a0
deinit ChildViewModel - 0x0000600000a09900
`viewModel` set to nil
deinit ParentViewModel
deinit ChildViewModel - 0x0000600000a097a0
as you can see, if I use assign(to:) then the latest ChildViewModel doesn't get deinited. If I use sink and store, both ChildViewModels get deisted properly. It appears that only if I change ParentViewModel deinit as follows
deinit {
childViewModel = nil
print("deinit ParentViewModel")
}
does the current value of childViewModel get properly deinited when I use 'assign'.
Am I wrong in expecting that the assign(to:) version of this code should clear away childViewModel when parentViewModel is set to nil just as the sink and store version does?
Any insight would be much appreciated. Cheers!
nonsensery
(Alex Johnson)
2
I think, yes, you do have a retain cycle. Your code is creating a subscription between the $childViewModel publisher and itself.
To illustrate, you can create a similar, but maybe more obvious cycle like so (extra selfs for clarity):
self.$childViewModel
.filter { _ in false } // do nothing
.assign(to: \.childViewModel, on: self)
.store(in: &self.cancellableSet)
Your cycle is the same, except "self" is the $childViewModel publisher (or some internal object that it manages).
Here it is with the same, simplified chain:
$childViewModel
.filter { _ in false } // do nothing
.assign(to: &$childViewModel)
As you've noted, to break that cycle, you need to introduce a non-strong reference somewhere. The most straightforward way to do that is to use sink instead of assign.
1 Like