Swift中常见循环引用的注意事项与总结
↓推荐关注↓
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
对于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)
}
}
编写BaseViewController与BaseView,重写 deinit
方法,这里以BaseViewController为例子:
class BaseViewController: UIViewController {
/// 其他业务代码省略
deinit {
print("\(className)被销毁了")
}
}
所有的子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]
参考资料
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 -
看完本文有收获?请分享给更多人
关注「 iOS大全 」加星标,关注 iOS 动态
点赞和在看就是最大的支持❤️