DEV Community

Cover image for The Sneaky Coordinator Leak in UIViewRepresentable
Suprie
Suprie

Posted on

The Sneaky Coordinator Leak in UIViewRepresentable

Let's talk a little about a memory leak I recently found while working with UIViewRepresentable.
The issue happened in a custom UIKit view wrapped inside SwiftUI. At first, nothing looked suspicious. The view worked fine. But after navigating in and out of the screen a few times, the Memory Graph Debugger showed that the Coordinator instances kept increasing.
Here is a simplified version of the setup:

import SwiftUI
import UIKit

final class CallbackButton: UIButton {
    var onTap: (() -> Void)?
    override init(frame: CGRect) {
        super.init(frame: frame)
        addTarget(self, action: #selector(handleTap), for: .touchUpInside)
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    @objc private func handleTap() {
        onTap?()
    }
    deinit {
        print("CallbackButton deinit")
    }
}

struct LeakyCallbackButtonView: UIViewRepresentable {

    @Binding var title: String
    func makeCoordinator() -> Coordinator {
        Coordinator(title: $title)
    }

    func makeUIView(context: Context) -> CallbackButton {
        let button = CallbackButton(type: .system)
        button.setTitle(title, for: .normal)
        // Looks harmless, but this strongly captures the coordinator.
        button.onTap = context.coordinator.handleTap
        return button
    }

    func updateUIView(_ uiView: CallbackButton, context: Context) {
        uiView.setTitle(title, for: .normal)
    }

    final class Coordinator {
        @Binding var title: String
        init(title: Binding<String>) {
            self._title = title
            print("Coordinator init")
        }
        func handleTap() {
            title = "Tapped at \(Date())"
        }
        deinit {
            print("Coordinator deinit")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The suspicious line is this one:

button.onTap = context.coordinator.handleTap

It looks like we are only passing a function, but an instance method reference still needs to retain its instance. In this case, the closure stored button.onTap strongly retains the Coordinator.
In a normal UIKit view controller, many people would immediately fix this with [weak self]. But this is UIViewRepresentable.

UIViewRepresentable is a struct, so self is a value type. You cannot use [weak self] the same way you would inside a UIViewController, because weak only applies to class-bound references.

The fix is to weakly capture the Coordinator instead:

struct FixedCallbackButtonView: UIViewRepresentable {
    @Binding var title: String

    func makeCoordinator() -> Coordinator {
        Coordinator(title: $title)
    }

    func makeUIView(context: Context) -> CallbackButton {
        let button = CallbackButton(type: .system)
        button.setTitle(title, for: .normal)
        button.onTap = { [weak coordinator = context.coordinator] in
            coordinator?.handleTap()
        }
        return button
    }

    func updateUIView(_ uiView: CallbackButton, context: Context) {
        uiView.setTitle(title, for: .normal)
    }

    static func dismantleUIView(_ uiView: CallbackButton, coordinator: Coordinator) {
        uiView.onTap = nil
    }

    final class Coordinator {
        @Binding var title: String
        init(title: Binding<String>) {
            self._title = title
            print("Coordinator init")
        }
        func handleTap() {
            title = "Tapped at \(Date())"
        }
        deinit {
            print("Coordinator deinit")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There are two fixes here:

button.onTap = { [weak coordinator = context.coordinator] in
    coordinator?.handleTap()
}
Enter fullscreen mode Exit fullscreen mode

This prevents the closure from strongly retaining the Coordinator.
And:

static func dismantleUIView(_ uiView: CallbackButton, coordinator: Coordinator) {
    uiView.onTap = nil
}
Enter fullscreen mode Exit fullscreen mode

This clears the callback when SwiftUI dismantles the UIKit view.
My takeaway:

// UIKit / UIViewController
{ [weak self] in
    self?.doSomething()
}

// UIViewRepresentable
{ [weak coordinator = context.coordinator] in
    coordinator?.doSomething()
}
Enter fullscreen mode Exit fullscreen mode

UIViewRepresentable is a value-type wrapper, but the Coordinator is the reference object that usually carries UIKit-style lifetime and callback behavior. So when a UIKit view stores a closure, be careful not to accidentally retain the Coordinator through that closure.

Just a note for myself.

Top comments (0)