98

My view controller displays a WKWebView. I installed a message handler, a cool Web Kit feature that allows my code to be notified from inside the web page:

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    let url = // ...
    self.wv.loadRequest(NSURLRequest(URL:url))
    self.wv.configuration.userContentController.addScriptMessageHandler(
        self, name: "dummy")
}

func userContentController(userContentController: WKUserContentController,
    didReceiveScriptMessage message: WKScriptMessage) {
        // ...
}

So far so good, but now I've discovered that my view controller is leaking - when it is supposed to be deallocated, it isn't:

deinit {
    println("dealloc") // never called
}

It appears that merely installing myself as a message handler causes a retain cycle and hence a leak!

7 Answers 7

182

Correct as usual, King Friday. It turns out that the WKUserContentController retains its message handler. This makes a certain amount of sense, since it could hardly send a message to its message handler if its message handler had ceased to exist. It's parallel to the way a CAAnimation retains its delegate, for example.

However, it also causes a retain cycle, because the WKUserContentController itself is leaking. That doesn't matter much on its own (it's only 16K), but the retain cycle and leak of the view controller are bad.

My workaround is to interpose a trampoline object between the WKUserContentController and the message handler. The trampoline object has only a weak reference to the real message handler, so there's no retain cycle. Here's the trampoline object:

class LeakAvoider : NSObject, WKScriptMessageHandler {
    weak var delegate : WKScriptMessageHandler?
    init(delegate:WKScriptMessageHandler) {
        self.delegate = delegate
        super.init()
    }
    func userContentController(userContentController: WKUserContentController,
        didReceiveScriptMessage message: WKScriptMessage) {
            self.delegate?.userContentController(
                userContentController, didReceiveScriptMessage: message)
    }
}

Now when we install the message handler, we install the trampoline object instead of self:

self.wv.configuration.userContentController.addScriptMessageHandler(
    LeakAvoider(delegate:self), name: "dummy")

It works! Now deinit is called, proving that there is no leak. It looks like this shouldn't work, because we created our LeakAvoider object and never held a reference to it; but remember, the WKUserContentController itself is retaining it, so there's no problem.

For completeness, now that deinit is called, you can uninstall the message handler there, though I don't think this is actually necessary:

deinit {
    println("dealloc")
    self.wv.stopLoading()
    self.wv.configuration.userContentController.removeScriptMessageHandlerForName("dummy")
}
19
  • 1
    can any kind soul translate this to objectivec equivalent codes?
    – mkto
    Oct 14, 2015 at 13:05
  • 3
    For me deinit actually never gets called unless I remove the script message handler in viewWillDisappear. Additionally now it's LeakAvoider that gets leaked.
    – Alexis
    May 29, 2017 at 9:04
  • 1
    Though I find that I do in fact need to explicitly remove the scriptMessageHandler as well, funnily enough
    – SomaMan
    Nov 9, 2017 at 14:09
  • 2
    Still trying to grasp why it doesn't work. If my WKUserContentController retains its message handler (self) which is causing the leak, shouldn't using weak self cause ARC not to increase reference count of my self. So when self's other sole referencer stops pointing to it, it should be released?
    – Adam Johns
    Feb 7, 2018 at 16:53
  • 8
    Quite overkill solution, just call userContentController.removeScriptMessageHandler(String) on clean-up, thats it! Dec 22, 2018 at 16:41
43

The leak is caused by

userContentController.addScriptMessageHandler(self, name: "handlerName")

which will keep a reference to the message handler self.

To prevent leaks, simply remove the message handler via

userContentController.removeScriptMessageHandlerForName("handlerName")

when you no longer need it.

If you add the addScriptMessageHandler in viewDidAppear, it's a good idea to remove it in viewDidDisappear.

5
  • 4
    "when you no longer need it" The problem is: when is that? Ideally it would be in your view controller's deinit (Objective-C dealloc), but it is never called because (wait for it) we are leaking! That is the problem that my trampoline solution solves. By the way, this same problem and this same solution continue on into iOS 9.
    – matt
    Sep 7, 2015 at 17:24
  • 1
    Its really depends on your use case. Say if you present it via presentViewController, the time is when you dismiss it. When you push it into a nav view controller, the time is when you pop it. It will not be deinit because WKWebView will never call deinit as it is retaining itself.
    – siuying
    Sep 8, 2015 at 7:10
  • As I mentioned, if you called addScriptMessageHandler in viewDidAppear, do the opposite removeScriptMessageHandlerForName in viewDidDisapper will work.
    – siuying
    Sep 8, 2015 at 7:12
  • It would also be useful to put all the WKUserContentController stuff in a separate handler class. So the view controller can deinit normally and then tell the separate handler to clean up as well. Jan 11, 2019 at 10:17
  • My deinit still wasn't being called, but that was because I has a text change listener also (not relating to the web view). I removed that listener and it started working again. Aug 27, 2020 at 9:10
23

The solution posted by matt is just what's needed. Thought I'd translate it to objective-c code

@interface WeakScriptMessageDelegate : NSObject<WKScriptMessageHandler>

@property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate;

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate;

@end

@implementation WeakScriptMessageDelegate

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate
{
    self = [super init];
    if (self) {
        _scriptDelegate = scriptDelegate;
    }
    return self;
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
    [self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}

@end

Then make use of it like this:

WKUserContentController *userContentController = [[WKUserContentController alloc] init];    
[userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:@"name"];
7

I've also noted that you also need to remove the message handler(s) during teardown, otherwise the handler(s) will still live on (even if everything else about the webview is deallocated):

WKUserContentController *controller = 
self.webView.configuration.userContentController;

[controller removeScriptMessageHandlerForName:@"message"];
4

Details

  • Swift 5.1
  • Xcode 11.6 (11E708)

Solution

based on Matt's answer

protocol ScriptMessageHandlerDelegate: class {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
}

class ScriptMessageHandler: NSObject, WKScriptMessageHandler {

    deinit { print("____ DEINITED: \(self)") }
    private var configuration: WKWebViewConfiguration!
    private weak var delegate: ScriptMessageHandlerDelegate?
    private var scriptNamesSet = Set<String>()

    init(configuration: WKWebViewConfiguration, delegate: ScriptMessageHandlerDelegate) {
        self.configuration = configuration
        self.delegate = delegate
        super.init()
    }

    func deinitHandler() {
        scriptNamesSet.forEach { configuration.userContentController.removeScriptMessageHandler(forName: $0) }
        configuration = nil
    }
    
    func registerScriptHandling(scriptNames: [String]) {
        for scriptName in scriptNames {
            if scriptNamesSet.contains(scriptName) { continue }
            configuration.userContentController.add(self, name: scriptName)
            scriptNamesSet.insert(scriptName)
        }
    }

    func userContentController(_ userContentController: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        delegate?.userContentController(userContentController, didReceive: message)
    }
}

Full Sample

Do not forget to paste the Solution code here

import UIKit
import WebKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let button = UIButton(frame: CGRect(x: 0, y: 0, width: 100, height: 40))
        button.setTitle("WebView", for: .normal)
        view.addSubview(button)
        button.center = view.center
        button.addTarget(self, action: #selector(touchedUpInsed(button:)), for: .touchUpInside)
        button.setTitleColor(.blue, for: .normal)
    }
    
    @objc func touchedUpInsed(button: UIButton) {
        let viewController = WebViewController()
        present(viewController, animated: true, completion: nil)
    }
}

class WebViewController: UIViewController {

    private weak var webView: WKWebView!
    private var scriptMessageHandler: ScriptMessageHandler!
    private let url = URL(string: "http://google.com")!
    deinit {
        scriptMessageHandler.deinitHandler()
        print("____ DEINITED: \(self)")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        let configuration = WKWebViewConfiguration()
        scriptMessageHandler = ScriptMessageHandler(configuration: configuration, delegate: self)
        let scriptName = "GetUrlAtDocumentStart"
        scriptMessageHandler.registerScriptHandling(scriptNames: [scriptName])

        let jsScript = "webkit.messageHandlers.\(scriptName).postMessage(document.URL)"
        let script = WKUserScript(source: jsScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
        configuration.userContentController.addUserScript(script)
        
        let webView = WKWebView(frame: .zero, configuration: configuration)
        self.view.addSubview(webView)
        self.webView = webView
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        view.bottomAnchor.constraint(equalTo: webView.bottomAnchor).isActive = true
        view.rightAnchor.constraint(equalTo: webView.rightAnchor).isActive = true
        webView.load(URLRequest(url: url))
    }
}

extension WebViewController: ScriptMessageHandlerDelegate {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        print("received \"\(message.body)\" from \"\(message.name)\" script")
    }
}

Info.plist

add in your Info.plist transport security setting

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>
2

Basic problem: The WKUserContentController holds a strong reference to all WKScriptMessageHandlers that were added to it. You have to remove them manually.

Since this is still a problem with Swift 4.2 and iOS 11 I want to suggest a solution which is using a handler which is separate from the view controller that holds the UIWebView. This way the view controller can deinit normally and tell the handler to clean up as well.

Here is my solution:

UIViewController:

import UIKit
import WebKit

class MyViewController: JavascriptMessageHandlerDelegate {

    private let javascriptMessageHandler = JavascriptMessageHandler()

    private lazy var webView: WKWebView = WKWebView(frame: .zero, configuration: self.javascriptEventHandler.webViewConfiguration)

    override func viewDidLoad() {
        super.viewDidLoad()

        self.javascriptMessageHandler.delegate = self

        // TODO: Add web view to the own view properly

        self.webView.load(URLRequest(url: myUrl))
    }

    deinit {
        self.javascriptEventHandler.cleanUp()
    }
}

// MARK: - JavascriptMessageHandlerDelegate
extension MyViewController {
    func handleHelloWorldEvent() {

    }
}

Handler:

import Foundation
import WebKit

protocol JavascriptMessageHandlerDelegate: class {
    func handleHelloWorld()
}

enum JavascriptEvent: String, CaseIterable {
    case helloWorld
}

class JavascriptMessageHandler: NSObject, WKScriptMessageHandler {

    weak var delegate: JavascriptMessageHandlerDelegate?

    private let contentController = WKUserContentController()

    var webViewConfiguration: WKWebViewConfiguration {
        for eventName in JavascriptEvent.allCases {
            self.contentController.add(self, name: eventName.rawValue)
        }

        let config = WKWebViewConfiguration()
        config.userContentController = self.contentController

        return config
    }

    /// Remove all message handlers manually because the WKUserContentController keeps a strong reference on them
    func cleanUp() {
        for eventName in JavascriptEvent.allCases {
            self.contentController.removeScriptMessageHandler(forName: eventName.rawValue)
        }
    }

    deinit {
        print("Deinitialized")
    }
}

// MARK: - WKScriptMessageHandler
extension JavascriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // TODO: Handle messages here and call delegate properly
        self.delegate?.handleHelloWorld()
    }
}
2

@matt perfectly described the reason of view controller leak, I would propose to use weak pointer to self and use it as function parameter.

required init?(coder: NSCoder) {     
    super.init(coder: coder)
            
    self.weakSelf = self
}
...
webView.configuration.userContentController.add(weakSelf, name: "dummy")
...

private weak var weakSelf: WKScriptMessageHandler!

That solves the issue of releasing view controller, but if you take a look at Instruments->Leaks ;), webView object exits and has retain count=1. I did some research and realised that it doesn't matter what type of reference is passed to the function (strong or weak), one thing is important - you must call:

webView.configuration.userContentController.removeScriptMessageHandler(forName: "dummy")

I would advise to do that in the viewWillDisappear() method.

1
  • Unfortunately, even this isn't enough for the retention to go away (at least as of iOS 16.3/Xcode 14.2).
    – Josh Hrach
    Apr 10, 2023 at 18:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.