WKWebView导致我的视​​图控制器泄漏


74

我的视图控制器显示一个WKWebView。我安装了消息处理程序,这是一个很酷的Web Kit功能,该功能使您可以从网页内部通知我的代码:

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) {
        // ...
}

到目前为止,还不错,但是现在我发现我的视图控制器正在泄漏-当应该将其释放时,它不是:

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

看来,仅仅将自己安装为消息处理程序会导致保留周期并因此导致泄漏!

Answers:


141

像往常一样正确,星期五国王。事实证明WKUserContentController保留了其消息处理程序。这具有一定意义,因为如果它的消息处理程序不再存在,则几乎无法将消息发送到其消息处理程序。例如,它与CAAnimation保留其委托的方式平行。

但是,由于WKUserContentController本身正在泄漏,因此也会导致保留周期。它本身并没有多大关系(只有16K),但是视图控制器的保留周期和泄漏很糟糕。

我的解决方法是在WKUserContentController和消息处理程序之间插入一个蹦床对象。蹦床对象对真实消息处理程序的引用很少,因此没有保留周期。这是蹦床对象:

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

现在,当我们安装消息处理程序时,我们将安装蹦床对象而不是self

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

有用!现在deinit被调用,证明没有泄漏。看起来这应该行不通,因为我们创建了LeakAvoider对象,并且从未持有过对该对象的引用。但是请记住,WKUserContentController本身保留了它,所以没有问题。

为了完整起见,现在deinit可以调用它,您可以在此处卸载消息处理程序,尽管我认为这实际上不是必需的:

deinit {
    println("dealloc")
    self.wv.stopLoading()
    self.wv.configuration.userContentController.removeScriptMessageHandlerForName("dummy")
}

1
任何一种灵魂都可以将此翻译成与Objectivec等效的代码吗?
mkto 2015年

3
对我来说,除非我在viewWillDisappear中删除脚本消息处理程序,否则deinit实际上永远不会被调用。此外,现在泄漏的是LeakAvoider。
亚历克西斯(Alexis)

1
尽管我发现实际上我确实也需要显式删除scriptMessageHandler,但很有趣
SomaMan

1
仍在尝试理解为什么它不起作用。如果myWKUserContentController保留了导致泄漏的消息处理程序(自身),则不应使用weak self原因ARC不要增加my的引用计数self。因此,当自己的另一个唯一参照人停止指向它时,应该释放它吗?
亚当·约翰斯

4
蛮过分的解决方案,只需在清理时调用userContentController.removeScriptMessageHandler(String)即可!
StackUnderflow

28

导致泄漏的原因是userContentController.addScriptMessageHandler(self, name: "handlerName")它将保留对消息处理程序的引用self

为了防止泄漏,只需在userContentController.removeScriptMessageHandlerForName("handlerName")不再需要时删除消息处理程序即可。如果在添加addScriptMessageHandler viewDidAppear,最好在中将其删除viewDidDisappear


4
“何时不再需要它”问题是:那是什么时候?理想情况下,它将在您的视图控制器的deinit(Objective-C dealloc)中,但是由于(正在等待)我们正在泄漏,因此从未调用它!那是我的蹦床解决方案解决的问题。顺便说一下,这个相同的问题和相同的解决方案继续进入
iOS9。– matt

1
它确实取决于您的用例。假设您是通过presentViewController展示它的,那么该时间就是您将其关闭的时间。当您将其推入导航视图控制器时,就是您弹出它的时间。它不会被取消初始化,因为WKWebView永远不会调用deinit,因为它会保留自身。
siuying

正如我提到的,如果您在viewDidAppear中调用addScriptMessageHandler,则在viewDidDisapper中执行相反的removeScriptMessageHandlerForName将起作用。
siuying

将所有WKUserContentController内容放在单独的处理程序类中也将很有用。因此,视图控制器可以正常取消初始化,然后告诉单独的处理程序也进行清理。
菲利普·奥托

我的deinit仍然没有被调用,但这是因为我也有一个文本更改监听器(与Web视图无关)。我删除了该监听器,它又开始工作了。
tree_are_great

18

马特发布的解决方案正是您所需要的。以为我会把它翻译成Objective-C代码

@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

然后像这样使用它:

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

2

我还注意到,您还需要在拆卸期间删除消息处理程序,否则处理程序仍将继续存在(即使有关Webview的其他所有内容都已释放):

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

[controller removeScriptMessageHandlerForName:@"message"];

1

基本问题:WKUserContentController拥有对添加到它的所有WKScriptMessageHandlers的强引用。您必须手动删除它们。

由于这仍然是Swift 4.2和iOS 11的问题,因此我想提出一个解决方案,该解决方案使用的处理程序与保存UIWebView的视图控制器是分开的。这样,视图控制器可以正常地初始化并告诉处理程序也进行清理。

这是我的解决方案:

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() {

    }
}

处理程序:

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

1

细节

  • 迅捷5.1
  • Xcode 11.6(11E708)

根据马特的答案

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

完整样本

不要忘记在此处粘贴解决方案代码

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传输安全设置

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.