【问题标题】:WKWebView causes my view controller to leakWKWebView 导致我的视​​图控制器泄漏
【发布时间】:2014-12-10 13:24:26
【问题描述】:

我的视图控制器显示一个 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
}

似乎仅仅将自己安装为消息处理程序会导致保留周期,从而导致泄漏!

【问题讨论】:

    标签: ios memory-leaks webkit ios8 wkwebview


    【解决方案1】:

    像往常一样正确,星期五国王。事实证明,WKUserContentController 保留了它的消息处理程序。这在一定程度上是有道理的,因为如果它的消息处理程序不再存在,它几乎不能向它的消息处理程序发送消息。例如,它类似于 CAAnimation 保留其委托的方式。

    但是,它也会导致一个保留循环,因为 WKUserContentController 本身正在泄漏。这本身并不重要(它只有 16K),但是视图控制器的保留周期和泄漏是不好的。

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

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

    【讨论】:

    • 任何善良的灵魂都可以将其翻译成客观的等效代码吗?
    • 对我来说 deinit 实际上永远不会被调用,除非我删除 viewWillDisappear 中的脚本消息处理程序。此外,现在泄漏的是 LeakAvoider。
    • 虽然我发现我确实需要明确删除 scriptMessageHandler ,但很有趣
    • 仍在尝试了解为什么它不起作用。如果我的WKUserContentController 保留导致泄漏的消息处理程序(self),则不应使用weak self 导致ARC 不会增加我的self 的引用计数。那么当self的其他唯一引用停止指向它时,它应该被释放吗?
    • 相当过分的解决方案,只需在清理时调用 userContentController.removeScriptMessageHandler(String) 即可!
    【解决方案2】:

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

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

    【讨论】:

    • “当你不再需要它时” 问题是:那是什么时候?理想情况下,它将在您的视图控制器的deinit(Objective-C dealloc)中,但它永远不会被调用,因为(等待它)我们正在泄漏!这就是我的蹦床解决方案解决的问题。顺便说一句,同样的问题和同样的解决方案在 iOS 9 中继续存在。
    • 这真的取决于你的用例。假设如果您通过 presentViewController 呈现它,则时间是您关闭它的时间。当你将它推入导航视图控制器时,就是你弹出它的时候了。它不会是 deinit,因为 WKWebView 永远不会调用 deinit,因为它会保留自己。
    • 如前所述,如果您在 viewDidAppear 中调用 addScriptMessageHandler,则在 viewDidDisapper 中执行相反的 removeScriptMessageHandlerForName 即可。
    • 将所有 WKUserContentController 内容放在单独的处理程序类中也很有用。因此视图控制器可以正常地取消初始化,然后告诉单独的处理程序也进行清理。
    • 我的 deinit 仍然没有被调用,但那是因为我也有一个文本更改侦听器(与 Web 视图无关)。我删除了那个监听器,它又开始工作了。
    【解决方案3】:

    matt 发布的解决方案正是我们所需要的。以为我会把它翻译成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"];
    

    【讨论】:

      【解决方案4】:

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

      WKUserContentController *controller = 
      self.webView.configuration.userContentController;
      
      [controller removeScriptMessageHandlerForName:@"message"];
      

      【讨论】:

        【解决方案5】:

        详情

        • 斯威夫特 5.1
        • Xcode 11.6 (11E708)

        解决方案

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

        完整样本

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

        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

        添加 Info.plist 传输安全设置

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

        【讨论】:

          【解决方案6】:

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

          由于这仍然是 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()
              }
          }
          

          【讨论】:

            【解决方案7】:

            @matt 完美描述了视图控制器泄漏的原因,我建议使用指向 self 的弱指针并将其用作函数参数。

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

            这解决了释放视图控制器的问题,但是如果你看一下Instruments->Leaks ;), webView 对象退出并保留计数=1。我做了一些研究并意识到传递给函数的引用类型(强或弱)并不重要,重要的是 - 你必须调用

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

            我建议在 viewWillDisappear() 方法中这样做。

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2012-01-15
              • 2017-09-15
              相关资源
              最近更新 更多