查看原文
其他

Swift中常见循环引用的注意事项与总结

iOS大全 2022-07-03

推荐关注↓

RxSwift编写wanandroid客户端现已开源

前略,在肝完了6月的每日更文活动后,我并没有立刻参与掘金7月的好文活动。主要干了下面几件事情:

  • 自我休整,每日更文,使得我自己也落下了很多掘金的文章没有看,我自己需要看一下并学习一下。

  • wanandroid客户端的代码CodeReview,之前写的有些匆匆忙忙,很多细节功能没有实现。

  • 整理思路,想想7月的思路该如何开始。

目前RxSwift编写wanandroid客户端已经开源了——项目链接[1],切记切换到play_android分支上喔。

附上一张效果图片:

本篇文章就得益于wanandroid客户端的代码CodeReview,因为使用RxSwift大量使用闭包,导致循环引用。

废话了这么多,那么我们进入主题吧。

Timer导致循环引用

为什么Timer不能被销毁

虽然绝大部分的循环引用是对象与对象相互的强引用导致,但是Timer却是另有隐情:

主线程的runloop在程序运行期间是不会销毁的, runloop引用着timer,timer就不会自动销毁。timer引用着target,target也不会销毁。

关于Swift中的Timer会导致循环引用,如果是一个新手,基本上可能都会陷进去而不能自拔。

由于Timer导致的循环引用,苹果自己都要负很大一部分责任。

所幸的是苹果在iOS1 0的时候对Timer引入了新的API来改善这个问题,我强烈建议,如果你的App工程配置文件支持iOS 10之后使用系统的新API做定时任务!

iOS 10之后的处理方式

我们先来看一看Timer的源码:

open class Timer : NSObject {

    /// 不建议使用
    public /*not inherited*/ init(timeInterval ti: TimeInterval, invocation: NSInvocation, repeats yesOrNo: Bool)
    
    /// 不建议使用
    open class func scheduledTimer(timeInterval ti: TimeInterval, invocation: NSInvocation, repeats yesOrNo: Bool) -> Timer

    /// 不建议使用
    public /*not inherited*/ init(timeInterval ti: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool)

    /// 不建议使用
    open class func scheduledTimer(timeInterval ti: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool) -> Timer

    /// 建议使用
    /// Creates and returns a new NSTimer object initialized with the specified block object. This timer needs to be scheduled on a run loop (via -[NSRunLoop addTimer:]) before it will fire.
    /// - parameter:  timeInterval  The number of seconds between firings of the timer. If seconds is less than or equal to 0.0, this method chooses the nonnegative value of 0.1 milliseconds instead
    /// - parameter:  repeats  If YES, the timer will repeatedly reschedule itself until invalidated. If NO, the timer will be invalidated after it fires.
    /// - parameter:  block  The execution body of the timer; the timer itself is passed as the parameter to this block when executed to aid in avoiding cyclical references
    @available(iOS 10.0, *)
    public /*not inherited*/ init(timeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void)

    /// 建议使用
    /// Creates and returns a new NSTimer object initialized with the specified block object and schedules it on the current run loop in the default mode.
    /// - parameter:  ti    The number of seconds between firings of the timer. If seconds is less than or equal to 0.0, this method chooses the nonnegative value of 0.1 milliseconds instead
    /// - parameter:  repeats  If YES, the timer will repeatedly reschedule itself until invalidated. If NO, the timer will be invalidated after it fires.
    /// - parameter:  block  The execution body of the timer; the timer itself is passed as the parameter to this block when executed to aid in avoiding cyclical references
    @available(iOS 10.0, *)
    open class func scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void) -> Timer
    
}

上面4个方法,我都写了明确的不建议使用,因为你可能按部就班的编写,也会循环引用。

最后下面2个方法,是iOS 10 之后的新API,创建定时任务,并通过block的方式进行回调,使用得当的话,就不会出现循环引用了。注意,block中请弱引用。

iOS 10之前处理方式

虽然目前已经在iOS系统已经是14了,但是很多App可能会向上兼容很多历史版本,导致上述API无法使用,这个时候,我们可以自己通过编写一个Timer分类,来解决循环引用:

extension Timer {
    
    /// Timer将userInfo作为callback的定时方法
    /// 目的是为了防止Timer导致的内存泄露
    /// - Parameters:
    ///   - timeInterval: 时间间隔
    ///   - repeats: 是否重复
    ///   - callback: 回调方法
    /// - Returns: Timer
    public static func scheduledTimer(timeInterval: TimeInterval, repeats: Bool, with callback: @escaping () -> Void) -> Timer {
        return scheduledTimer(timeInterval: timeInterval,
                              target: self,
                              selector: #selector(callbackInvoke(_:)),
                              userInfo: callback,
                              repeats: repeats)
    }
    
    /// 私有的定时器实现方法
    ///
    /// - Parameter timer: 定时器
    @objc
    private static func callbackInvoke(_ timer: Timer) {
        guard let callback = timer.userInfo as? () -> Void else { return }
        callback()
    }
}

这里其实调用的系统API实际上是上面源代码中的第4个:

open class func scheduledTimer(timeInterval ti: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool) -> Timer

这里其实将Timer的定时任务在Timer里面实现了,而不是在target中进行了实现,推测iOS 10之后的系统API和这个实现类似。

这里其实用了一个小技巧:userInfo: Any?传入的参数是Any类型,而我们在封装的入参中传入的是callback: @escaping () \-> Void,没错,是一个闭包,闭包也是Any类型嘛,所以后面的实现Timer任务时,才有了guard let callback = timer.userInfo as? () \-> Void else { return }这一段,来保证调用的合法性与合理性。

init(timeInterval...)与scheduledTimer(timeInterval...)方法的区别

  • init(timeInterval...)创建出来的timer无法立刻使用,需要添加到NSRunloop中才可以正常工作

「After creating it, you must add the timer to a run loop manually by calling the addTimer:forMode: method of the corresponding NSRunLoop object。」

  • scheduledTimer(timeInterval...)创建出来的runloop已经被添加到当前线程的currentRunloop中来了。

「Schedules it on the current run loop in the default mode。」

WKScriptMessageHandler导致循环引用

在之前的文章中我,我讲到Swift与JS方法互调[2],讲到了在WebView中通过监听方法句柄,来进行JS调用Swift的方法,不知道注意到没有,在添加句柄的时候,没有使用self,而是通过WeakScriptMessageDelegate中间类来进行添加。

let config = WKWebViewConfiguration()
config.userContentController.add(WeakScriptMessageDelegate(scriptDelegate: self), name: JSCallback)
let preferences = WKPreferences()
preferences.javaScriptCanOpenWindowsAutomatically = true
config.preferences = preferences

let webView = WKWebView(frame: CGRect.zero, configuration: config)

因为如果直接这样config.userContentController.add(self, name: JSCallback)这样的话,是会导致循环引用的。

WebViewController根本不会走析构函数,也就无法移除对应的监听方法的句柄:

deinit {
    webView.configuration.userContentController.removeScriptMessageHandler(forName: JSCallback)
}

而WeakScriptMessageDelegate并没有什么特别支持,仅仅是让强持有变为弱持有

import Foundation
import WebKit

class WeakScriptMessageDelegate: NSObject {

    //MARK:- 属性设置 之前这个属性没有用weak修饰,所以一直持有,无法释放
    private weak var scriptDelegate: WKScriptMessageHandler!

    //MARK:- 初始化
    convenience init(scriptDelegate: WKScriptMessageHandler) {
        self.init()
        self.scriptDelegate = scriptDelegate
    }
}

extension WeakScriptMessageDelegate: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        scriptDelegate.userContentController(userContentController, didReceive: message)
    }
}

网上也有另外一种方式来解决WKScriptMessageHandler的循环引用问题,那就是在UIViewController的声明周期去解决,Timer也有这种方式:

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        webView.configuration.userContentController.add(self, name: JSCallback)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        webView.configuration.userContentController.removeScriptMessageHandler(forName: JSCallback)
    }

但是这种与Controller声明周期绑定的方式并不好,因为它依赖的是其他类去触发条件,有不可控的因素在其中!

可以说Timer与WKScriptMessageHandler的循环引用,有一部分原因是苹果自身API设计的陷阱导致,那么一般我们怎么发现某个Controller、某个View、某个ViewModel有没有循环引用呢?

如何发现自己编写的代码是否循环引用

Cococa框架下的class

  1. 对于Cocoa框架下的类,由于都是继承于NSObject,所以在处理上会比较有统一性,我们先在NSObject上写一个分类,便于我们获取一个类的名称:
// MARK: - 获取类的字符串名称
extension NSObject {
    
    /// 对象获取类的字符串名称
    public var className: String {
        return runtimeType.className
    }
    
    /// 类获取类的字符串名称
    public static var className: String {
        return String(describing: self)
    }
    
    /// NSObject对象获取类型
    public var runtimeType: NSObject.Type {
        return type(of: self)
    }
}
  1. 编写BaseViewController与BaseView,重写deinit方法,这里以BaseViewController为例子:
class BaseViewController: UIViewController {
    /// 其他业务代码省略
    
    deinit {
        print("\(className)被销毁了")
    }
}
  1. 所有的子Controller与子View继承基类,通过在push到某个页面后,然后在进行pop操作,看看控制台是否有析构函数的打印。

通过以上方式,我们可以看到页面是否有没有被销毁,进而排查问题了。

非Cococa框架下的的class

针对非Cococa框架下的的class,我们可以写一个基类,这里我以BaseViewModel为例子:

class BaseViewModel {
    /// 其他业务代码省略
    
    /// 模型名称
    var className: String { String(describing: self) }
    
    deinit {
        print("\(className)被销毁了")
    }
}

class HotKeyViewModel: BaseViewModel {
    /// 其他业务代码省略
}

我同样在BaseViewModel中重deinit方法,然后其他的ViewModel都继承BaseViewModel,也可以通过打印日志查看对象是否销毁。

下图是打印的日志:

需要注意的struct

注意struct创建的对象在栈中,而不是堆中,不需要进行其析构管理,你甚至根本就无法在struct中调用deinit函数。

Kingfisher中的处理方式

如果你读过Kingfisher5的源码,你会发现,喵神对于对象的强弱引用导致的问题通过一个Delegate类进行了解决:

public class Delegate<Input, Output> {
    public init() {}

    private var block: ((Input) -> Output?)?
    public func delegate<T: AnyObject>(on target: T, block: ((T, Input) -> Output)?) {
        self.block = { [weak target] input in
            guard let target = target else { return nil }
            return block?(target, input)
        }
    }

    public func call(_ input: Input) -> Output? {
        return block?(input)
    }

    public func callAsFunction(_ input: Input) -> Output? {
        return call(input)
    }
}

extension Delegate where Input == Void {
    public func call() -> Output? {
        return call(())
    }

    public func callAsFunction() -> Output? {
        return call()
    }
}

其具体例子,Kingfisher中的Delegate的注释已经做出了很好的说明:

/// You can create a `Delegate` and observe on `self`. Now, there is no retain cycle inside:
///
/// ```swift
/// // MyClass.swift
/// let onDone = Delegate<(), Void>()
/// func done() {
///     onDone.call()
/// }
///
/// // ViewController.swift
/// var obj: MyClass?
///
/// func doSomething() {
///     obj = MyClass()
///     obj!.onDone.delegate(on: self) { (self, _)
///         // `self` here is shadowed and does not keep a strong ref.
///         // So you can release both `MyClass` instance and `ViewController` instance.
///         self.reportDone()
///     }
/// }
/// ```

虽然会麻烦一点,但是在闭包中,可以安全的使用self,而不用[weak self]的修饰,以及self的健壮性判断,可以少掉写头发。

weak还是unowned

我们可能在一个对象的闭包中使用对象本身,经常用[weak target]修饰,其实还有一种修饰方式是[unowned target],两者有什么差异?

下面这段话是我引用的网络上的一点说明:

在 Swift 中除了 weak 以外,还有另一个冲着编译器叫喊着类似的 "不要引用我" 的标识符,那就是 unowned。它们的区别在哪里呢?如果您是一直写 Objective-C 过来的,那么从表面的行为上来说 unowned 更像以前的 unsafe_unretained,而 weak 就是以前的 weak。用通俗的话说,就是 unowned 设置以后即使它原来引用的内容已经被释放了,它仍然会保持对被已经释放了的对象的一个 "无效的" 引用,它不能是 Optional 值,也不会被指向 nil。如果你尝试调用这个引用的方法或者访问成员属性的话,程序就会崩溃。而 weak 则友好一些,在引用的内容被释放后,标记为 weak 的成员将会自动地变成 nil (因此被标记为 @weak 的变量一定需要是 Optional 值)。关于两者使用的选择,Apple 给我们的建议是如果能够确定在访问时不会已被释放的话,尽量使用 unowned,如果存在被释放的可能,那就选择用 weak。

就问你读完了累不累,懂了没?

我说说我的理解:

  • unowned使用,需要清晰的理解target对象的生命周期,万一调用的不好,就翻车了导致崩溃问题。简而言之,就是抓头发,掉头发。

  • weak使用,可能需要使用guard去守护一下target简而言之,就是多写点代码,但是不掉头发。

所以,我选择多写点代码而不掉头发的weak,如果使用喵神的Delegate类,[weak self]也可以不写,虽然会增添其他的代码量。

WeakProxy

其实针对Cocoa框架下的强弱引用导致的循环应用,我们可以通过写一个比较通用的中间层来处理:

class WeakProxy: NSObject {
    
    weak var target: NSObjectProtocol?
    
    init(target: NSObjectProtocol) {
        self.target = target
        super.init()
    }
    
    override func responds(to aSelector: Selector!) -> Bool {
        return (target?.responds(to: aSelector) ?? false) || super.responds(to: aSelector)
    }

    override func forwardingTarget(for aSelector: Selector!) -> Any? {
        return target
    }
}

这个运用了NSObject中的消息转发机制,以保证方法正确传递,同时通过弱引用消除了强引用导致的循环引用。

如果对WeakProxy增加一点扩展(实际上就是协议的实现),WeakScriptMessageDelegate是可以被代替的喔:

extension WeakProxy: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        (target as? WKScriptMessageHandler)?.userContentController(userContentController, didReceive: message)
    }
}

WeakProxy在WebView中的使用:

let config = WKWebViewConfiguration()

/// 之前这里是
/// config.userContentController.add(WeakScriptMessageDelegate(scriptDelegate: self), name: JSCallback)
config.userContentController.add(WeakProxy(target: self), name: JSCallback)
let preferences = WKPreferences()
preferences.javaScriptCanOpenWindowsAutomatically = true
config.preferences = preferences

let webView = WKWebView(frame: CGRect.zero, configuration: config)

总结

  • 大部分的循环引用是对象与对象相互的强引用导致,解决对象与对象的这种相互强引用方式是解决问题的根本。

  • Timer的导致循环引用情况特殊,它是由于主线程的runloop引用着timer,timer就不会自动销毁。timer引用着target,target也不会销毁。

  • 本文通过Timer、WKScriptMessageHandler为出发点,通过引入中间层来减少相互强引用导致的循环引用问题,而Kingfisher中的Delelgate类,以及通用的WeakProxy是比较好的经验与例子分享。

  • 通过对class基类重写deinit方法,通过继承打日志的方式去排查对象的销毁情况,有助于进行循环引用的问题分析。

参考文章:

深入浅出了解NSTimer循环引用的原因[3]

内存管理,weak 和 unowned[4]

iOS卡顿监测方案总结[5]

参考资料

[1]

https://github.com/seasonZhu/RxStudy: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2FseasonZhu%2FRxStudy

[2]

https://juejin.cn/post/6971221137522966565: https://juejin.cn/post/6971221137522966565

[3]

https://www.jianshu.com/p/9f387abfb2e8: https://link.juejin.cn/?target=https%3A%2F%2Fwww.jianshu.com%2Fp%2F9f387abfb2e8

[4]

https://www.jianshu.com/p/9494ea08fe3a?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation: https://link.juejin.cn/?target=https%3A%2F%2Fwww.jianshu.com%2Fp%2F9494ea08fe3a%3Futm_campaign%3Dmaleskine%26utm_content%3Dnote%26utm_medium%3Dseo_notes%26utm_source%3Drecommendation

[5]

https://juejin.cn/post/6844903944867545096#heading-0: https://juejin.cn/post/6844903944867545096#heading-0


转自:掘金 season_zhu

https://juejin.cn/post/6981355574357131272

- EOF -

推荐阅读  点击标题可跳转

1、iOS APP 架构设计

2、iOS 底层原理:界面优化

3、深入浅出 GCD 之 dispatch_source


看完本文有收获?请分享给更多人

关注「 iOS大全 」加星标,关注 iOS 动态

点赞和在看就是最大的支持❤️

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存