WKWebView causes my view controller to leak
Solution 1
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")
}
Solution 2
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 at viewDidAppear
, its a good idea to remove it in viewDidDisappear
.
Solution 3
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"];
Solution 4
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"];
Solution 5
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>
Related videos on Youtube
matt
Author most recently of iOS 15 Programming Fundamentals with Swift and of Programming iOS 14. Code examples from the books available at my github repository. A much earlier edition, Programming iOS 6, is available to read for free at http://www.apeth.com/iOSBook/. And the Swift language chapters of iOS 10 Programming Fundamentals With Swift are available at http://www.apeth.com/swiftBook/.
Updated on July 08, 2022Comments
-
matt almost 2 years
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!
-
matt almost 9 years"when you no longer need it" The problem is: when is that? Ideally it would be in your view controller's
deinit
(Objective-Cdealloc
), 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. -
siuying almost 9 yearsIts 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 almost 9 yearsAs I mentioned, if you called addScriptMessageHandler in viewDidAppear, do the opposite removeScriptMessageHandlerForName in viewDidDisapper will work.
-
mkto over 8 yearscan any kind soul translate this to objectivec equivalent codes?
-
johan over 8 years@mkto - Posted a obj-c version of the implementation.
-
Alexis about 7 yearsFor me deinit actually never gets called unless I remove the script message handler in viewWillDisappear. Additionally now it's LeakAvoider that gets leaked.
-
matt over 6 years@SomaMan Yes, I'm still seeing it.
-
SomaMan over 6 yearsThough I find that I do in fact need to explicitly remove the scriptMessageHandler as well, funnily enough
-
matt over 6 years@SomaMan Yes, that might be true; the purpose of my trampoline solution is merely to allow the view controller's
deinit
to be called so that you have a place to do that. My code is here in case you want to play with it: github.com/mattneub/Programming-iOS-Book-Examples/blob/master/… -
Adam Johns over 6 yearsCould this also be solved by using a
weak self
inaddScriptMessageHandler
? -
matt over 6 years@AdamJohns try it and see
-
Adam Johns over 6 yearsJust did - doesn't work. I'm not sure why though. If I don't call
addScriptMessageHandler
then I don't have a leak, so I know that call is what is causing my leak. Not sure how usingweak self
doesn't solve it since that is the case. -
matt over 6 yearsI know it doesn't work. That's why I gave an answer that does work.
-
Adam Johns over 6 yearsStill trying to grasp why it doesn't work. If my
WKUserContentController
retains its message handler (self) which is causing the leak, shouldn't usingweak self
cause ARC not to increase reference count of myself
. So when self's other sole referencer stops pointing to it, it should be released? -
Jonathan Zrake about 6 yearsWhy does the implementation allow the
WKUserContentController
instance to leak? Or, does it get GC'd if the web view is dead, and there are no more registered javascript message handlers? -
Jonathan Zrake about 6 yearsAnswered my own question:
WKUserContentController
does not leak, once its handlers are all deregistered. -
Emre Önder about 6 yearsAs I see now web view is deiniting but LeakAvoider object itself leaks. Is there any info about it?
-
StackUnderflow over 5 yearsQuite overkill solution, just call userContentController.removeScriptMessageHandler(String) on clean-up, thats it!
-
Philipp Otto over 5 yearsI agree with the last comment. This solution is just not necessary since it is really easy to fix the retain cycle by removing the handlers you added.
-
Philipp Otto over 5 yearsIt 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.
-
matt over 5 years@PhilippOtto Obviously, but the question is when.
-
Philipp Otto over 5 years@matt: If you use the WKUserContentController in the view controller itself you could use the viewDidDisappear. I prefer using an external handler. I added an answer to this question to show what I mean.
-
Patrick about 5 yearsDeinit is called for me and so I added
removeScriptMessageHandler
there and it worked. -
trees_are_great almost 4 yearsMy 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.