WKWebView führt dazu, dass mein View Controller leckt

73

Mein View Controller zeigt eine WKWebView an. Ich habe einen Nachrichtenhandler installiert, eine coole Web Kit-Funktion, mit der mein Code von der Webseite aus benachrichtigt werden kann:

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 weit so gut, aber jetzt habe ich festgestellt, dass mein View Controller undicht ist - wenn er freigegeben werden soll, ist es nicht:

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

Es scheint, dass die bloße Installation als Message-Handler einen Speicherzyklus und damit ein Leck verursacht!

matt
quelle

Antworten:

139

Richtig wie immer, King Friday. Es stellt sich heraus, dass der WKUserContentController seinen Nachrichtenhandler beibehält . Dies ist in gewissem Maße sinnvoll, da es kaum eine Nachricht an seinen Nachrichtenhandler senden könnte, wenn sein Nachrichtenhandler nicht mehr existiert. Dies ist parallel dazu, wie eine CAAnimation beispielsweise ihren Delegaten behält.

Es verursacht jedoch auch einen Aufbewahrungszyklus, da der WKUserContentController selbst undicht ist. Das allein macht nicht viel aus (es sind nur 16 KB), aber der Haltezyklus und das Leck des View Controllers sind schlecht.

Meine Problemumgehung besteht darin, ein Trampolinobjekt zwischen dem WKUserContentController und dem Nachrichtenhandler einzufügen. Das Trampolinobjekt hat nur einen schwachen Bezug zum realen Nachrichtenhandler, daher gibt es keinen Aufbewahrungszyklus. Hier ist das Trampolin-Objekt:

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)
    }
}

Wenn wir jetzt den Nachrichtenhandler installieren, installieren wir das Trampolinobjekt anstelle von self:

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

Es klappt! Jetzt deinitwird aufgerufen, um zu beweisen, dass es kein Leck gibt. Es sieht so aus, als ob dies nicht funktionieren sollte, da wir unser LeakAvoider-Objekt erstellt haben und nie einen Verweis darauf hatten. Denken Sie jedoch daran, dass der WKUserContentController es selbst beibehält, sodass es kein Problem gibt.

Der Vollständigkeit deinithalber können Sie den Nachrichtenhandler dort deinstallieren, obwohl ich dies nicht für notwendig halte:

deinit {
    println("dealloc")
    self.wv.stopLoading()
    self.wv.configuration.userContentController.removeScriptMessageHandlerForName("dummy")
}
matt
quelle
1
Kann irgendeine Art Seele dies in objektive äquivalente Codes übersetzen?
Mkto
3
Für mich wird deinit eigentlich nie aufgerufen, es sei denn, ich entferne den Skriptnachrichten-Handler in viewWillDisappear. Außerdem wird jetzt LeakAvoider durchgesickert.
Alexis
1
Obwohl ich finde, dass ich tatsächlich auch den scriptMessageHandler explizit entfernen muss, komischerweise
SomaMan
1
Ich versuche immer noch zu verstehen , warum es nicht funktioniert. Wenn mein WKUserContentControllerNachrichtenhandler (self), der das Leck verursacht, beibehalten wird, sollte weak selfARC nicht dazu führen, dass die Referenzanzahl von my nicht erhöht wird self. Wenn also der andere alleinige Referent des Selbst nicht mehr darauf zeigt, sollte er freigegeben werden?
Adam Johns
4
Eine ziemlich übertriebene Lösung, rufen Sie einfach userContentController.removeScriptMessageHandler (String) bei der Bereinigung auf, das war's!
StackUnderflow
28

Das Leck wird verursacht, userContentController.addScriptMessageHandler(self, name: "handlerName")wodurch ein Verweis auf den Nachrichtenhandler erhalten bleibt self.

Um Undichtigkeiten zu vermeiden, entfernen userContentController.removeScriptMessageHandlerForName("handlerName")Sie einfach den Nachrichtenhandler über, wenn Sie ihn nicht mehr benötigen. Wenn Sie den addScriptMessageHandler unter hinzufügen viewDidAppear, ist es eine gute Idee, ihn zu entfernen viewDidDisappear.

siuying
quelle
4
"wenn du es nicht mehr brauchst" Das Problem ist: wann ist das? Idealerweise befindet es sich in Ihrem View Controller deinit(Objective-C dealloc), aber es wird nie aufgerufen, weil (warten Sie darauf) wir undicht sind! Das ist das Problem, das meine Trampolinlösung löst. Übrigens, dasselbe Problem und dieselbe Lösung setzen sich auch in iOS 9 fort.
Matt
1
Es hängt wirklich von Ihrem Anwendungsfall ab. Wenn Sie es über presentViewController präsentieren, ist es an der Zeit, es zu schließen. Wenn Sie es in einen Navigationsansichts-Controller schieben, ist die Zeit, in der Sie es öffnen. Es wird nicht deinit sein, da WKWebView niemals deinit aufruft, da es sich selbst beibehält.
Siuying
Wie bereits erwähnt, funktioniert das Entfernen von removeScriptMessageHandlerForName in viewDidDisapper, wenn Sie in viewDidAppear addScriptMessageHandler aufgerufen haben.
Siuying
Es wäre auch nützlich, alle WKUserContentController-Inhalte in einer separaten Handlerklasse abzulegen. Der View Controller kann also normal deinitieren und dann den separaten Handler anweisen, ebenfalls zu bereinigen.
Philipp Otto
Mein Deinit wurde immer noch nicht aufgerufen, aber das lag daran, dass ich auch einen Listener für Textänderungen habe (der sich nicht auf die Webansicht bezieht). Ich habe diesen Listener entfernt und er hat wieder funktioniert.
tree_are_great
18

Die von matt veröffentlichte Lösung ist genau das, was benötigt wird. Ich dachte, ich würde es in Objective-C-Code übersetzen

@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

Dann nutzen Sie es so:

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

Ich habe auch festgestellt, dass Sie auch die Nachrichtenhandler während des Herunterfahrens entfernen müssen, da die Handler sonst weiterhin aktiv sind (auch wenn alles andere an der Webansicht freigegeben ist):

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

[controller removeScriptMessageHandlerForName:@"message"];
coderSeb
quelle
1

Grundproblem: Der WKUserContentController enthält einen starken Verweis auf alle hinzugefügten WKScriptMessageHandler. Sie müssen sie manuell entfernen.

Da dies bei Swift 4.2 und iOS 11 immer noch ein Problem darstellt, möchte ich eine Lösung vorschlagen, bei der ein Handler verwendet wird, der vom Ansichtscontroller getrennt ist, in dem sich UIWebView befindet. Auf diese Weise kann der View Controller normal deinitieren und den Handler anweisen, ebenfalls zu bereinigen.

Hier ist meine Lösung:

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()
    }
}
Philipp Otto
quelle
0

Einzelheiten

  • Swift 5.1
  • Xcode 11.6 (11E708)

Lösung

basierend auf Matts Antwort

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)
    }
}

Vollständige Probe

Vergessen Sie nicht, den Lösungscode hier einzufügen

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

Fügen Sie Ihre Transportsicherheitseinstellung Info.plist hinzu

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>
Wassili Bodnarchuk
quelle