HàPhan 河

[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 a Published.Publisher as its input and instead of returning an AnyCancellable, 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.

IMG_0916

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.

Comments