[Combine] assign(to:on:) causes memory leak
It seems Apple did fix this issue in iOS 14, by introducing the
assign(to:)
operator, which takes aPublished.Publisher
as its input and instead of returning anAnyCancellable
, it binds the lifetime of the subscription to the lifetime of the Published property.
https://developer.apple.com/documentation/combine/publisher/assign(to:)
What a beautiful day!
I've tried to add a search bar to my Webview player.
That's cool to let any user enter their favorite URL instead of static tap action.
you can download the app here
Here is the view
struct AddressBarView : View {
@ObservedObject var viewModel: AddressBarViewModel
init(_ vm: AddressBarViewModel) {
viewModel = vm
}
var body : some View {
VStack {
SearchBar(text: $viewModel.barString, tapSearch: viewModel.tapGo, placeholder: "enter url here".localized)
}.onAppear(perform: {
self.viewModel.transform()
})
}
}
struct SearchBar: UIViewRepresentable {
@Binding var text: String
let tapSearch: PassthroughSubject<Void, Never>
var placeholder: String
class Coordinator: NSObject, UISearchBarDelegate {
@Binding var text: String
private let tapSearch: PassthroughSubject<Void, Never>
init(text: Binding<String>, didSearch: PassthroughSubject<Void, Never>) {
_text = text
tapSearch = didSearch
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
tapSearch.send()
}
}
func makeCoordinator() -> SearchBar.Coordinator {
return Coordinator(text: $text, didSearch: tapSearch)
}
func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
searchBar.placeholder = placeholder
searchBar.searchBarStyle = .minimal
searchBar.autocapitalizationType = .none
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
uiView.text = text
}
}
And view model
class AddressBarViewModel : ObservableObject, ViewModelType {
@Published var barString: String = ""
@Published var goWithURL: URL?
private let tapGo = PassthroughSubject<Void, Never>()
private var cancellables = Set<AnyCancellable>()
func transform() {
tapGo.map { [weak self] () -> URL? in
guard let `self` = self else { return nil }
return URL(string: self.barString.fixHTTP())
}.filter {$0 != nil}
.assign(to: \.goWithURL, on: self)
.store(in: &cancellables)
}
}
The big problem I found is .assign(to: \.goWithURL, on: self)
will cause memory leak, self
object will be capture as retained value.
So after several searches, I found that we can use sink
instead of assign(to:on:)
to capture self
as weak value.
tapGo.map { [weak self] () -> URL? in
guard let `self` = self else { return nil }
//the fixHTTP method just extension to add http or https when missing
return URL(string: self.barString.fixHTTP())
}.filter {$0 != nil}
.sink(receiveValue: { [weak self] url in
self?.goWithURL = url
})
.store(in: &cancellables)
Or an extension will make our code cleaner:
extension Publisher where Self.Failure == Never {
public func assignNoRetain<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable where Root: AnyObject {
sink { [weak object] (value) in
object?[keyPath: keyPath] = value
}
}
}
Tada! Now we can use it easily.
tapGo.map { [weak self] () -> URL? in
guard let `self` = self else { return nil }
return URL(string: self.barString.fixHTTP())
}.filter {$0 != nil}
.assignNoRetain(to: \.goWithURL, on: self)
.store(in: &cancellables)
I hope Apple support to solve this problem soon.
Best.