iOS 使用引用计数来进行内存管理。在某些情况下,代码编写不慎会产生循环引用,进而导致内存泄露。根据我们目前项目中的代码来看,开发成员对导致循环引用的原因可能没有完全掌握清楚,所以存在着滥用以及漏用 weak 的现象,这里进行简要分析,并进行规范,防止 weak 的滥用及漏用。
引用计数的适用对象
首先要明确,引用计数是对 reference types(即 classes、closures) 而言的,value types(如 struct、enumeration)不使用引用计数进行内存管理,所以也就不存在“循环引用”这个概念。
代理模式的循环引用
我们一般会为被代理对象创建一个 delegate 属性,并且 delegate 需要由对象持有,此时如果我们的 delegate 对象同时持有了被代理对象,那么就会导致循环引用。如下例:
protocol TestBDelegate: class {
func printMessage() -> String
}
class TestB {
var delegate: TestBDelegate
init(delegate: TestBDelegate) {
self.delegate = delegate
}
func callDelegate() {
print(delegate.printMessage())
}
deinit {
print("TestB deinit")
}
}
class TestC: TestBDelegate {
var testb: TestB?
init() {
self.testb = TestB(delegate: self)
}
func printMessage() -> String {
return "Hello world"
}
deinit {
print("TestC deinit")
}
}
var testc: TestC? = TestC()
testc = nil
上述示例中,testc 不会被释放,原因如下:
- testc 是代理对象,testb 是被代理对象;
- testc 强引用了 testb;
- TestC 的构造器中为 testb 的 delegate 参数传入了 self;
- testb 中强引用了 delegate 属性,而此时 delegate 属性正是 testc,形成循环引用;
我们有两种方式来打破循环引用:weak & unowned。
weak
为了打破这种循环引用,我们可以使用 weak 来声明 delegate 属性。weak 表示不对该属性进行强引用,也就是不会将 weak 声明的属性的引用计数加 1,这样就防止了循环引用。当 weak 属性引用的对象被释放后,weak 属性会被 ARC 置为 nil。修改后的 TestB 代码如下:
class TestB {
weak var delegate: TestBDelegate?
init(delegate: TestBDelegate) {
self.delegate = delegate
}
func callDelegate() {
print(delegate?.printMessage())
}
deinit {
print("TestB deinit")
}
}
这里注意两点:
- TestB 的 delegate 属性使用了 weak 来声明,这样声明后,TestB 的实例对象就不会再强引用 delegate 对象。
- 对于 weak 声明的属性,必须声明为 Optional 类型(如上例中的 TestBDelegate?),因为 weak 引用的对象被释放时,会将 weak 引用设置为 nil;
unowned
同 weak 一样,使用 unowned 声明的属性表示不对该属性进行强引用,也就是不会将 unowned 声明的属性的引用计数加 1,这样可以防止循环引用。单从这一点上看,unowned 与 weak 的作用是相同的,但是两者还是有一些区别的:
- unowned 属性引用的对象被释放后,unowned 属性不会被 ARC 置为 nil,如果 unowned 被释放后,仍然使用 unowned 属性,就属于引用无效内存,从而导致 crash;
- 使用 unowned 声明属性表示该属性的生命周期一定大于对象本身,原因如第一条所述;
- weak 声明的属性必须声明为 Optional 类型,而 unowned 声明的属性不用声明为 Optional 类型,因为我们确认 unowned 属性的生命周期大于本对象;
使用 unowned 声明 delegate 的代码如下:
class TestB {
unowned var delegate: TestBDelegate
init(delegate: TestBDelegate) {
self.delegate = delegate
}
func callDelegate() {
print(delegate.printMessage())
}
deinit {
print("TestB deinit")
}
}
这里注意三点:
- TestB 的 delegate 属性使用了 unowned 来声明,这样声明后,TestB 的实例对象就不会再强引用 delegate 对象。
- 对于 unowned 声明的属性,我们没有声明为 Optional 类型,因为我们明确知道 testb 的生命周期不会长于 delegate
- 如果 TestB 对象的 delegate 生命周期比 TestB 对象生命周期短,则会触发 crash。例如下述代码会导致 crash:
var testc: TestC? = TestC()
var testb: TestB? = testc?.testb
testc = nil
testb?.callDelegate() // testc 已经被释放,此时如果调用 testb 的 delegate 属性,会触发 crash,因为 unowned 声明的属性不会自动被置为 nil
我们可以看到 unowned 存在 crash 的风险,那么为什么还要有 unowned 这种声明方式呢?
- 使用 unowned 声明,可以明确的告诉代码阅读者,unowned 属性的生命周期大于本对象;
- 使用 unowned 声明,我们可以不使用 Optional 类型,这样引用的时候就无需使用可选链去调用该属性了;
- 其余的原因暂时未想到,如果有想到,欢迎补充;
closure 导致的循环应用
举例及原因分析
如下例:
class TestA {
var testADescription: String?
var describeTestA: (() -> Void)?
init() {
describeTestA = {
self.testADescription = "Hello, TestA"
}
}
deinit {
print("TestA deinit")
}
}
var testa: TestA? = TestA()
testa = nil
上述示例中不会 testa 不会被释放。原因如下:
- describeTestA 是一个 closure,而 Swift 的 closure 也是使用引用计数管理内存的;
- testa 对象强持有了 describeTestA closure;
- describeTestA 中,我们使用了 testa 对象的属性 testADescription,这时,testa 对象会被 describeTestA closure 捕获,并且是强引用捕获住,这样就导致了循环引用;
这类循环引用的情况可以归结为:一个对象强持有了一个 closure,同时 closure 强引用捕获了该对象。closure 强捕获对象的情况发生在 closure 内部调用了对象的方法,或者使用了对象的某个属性。
对于 closure 循环引用问题,可以使用 closure 的捕获参数列表来解决,在捕获参数列表中,我们可以将某个对象声明为 weak 或者 unowned,例如:
class TestA {
var testADescription: String?
var describeTestA: (() -> Void)?
init() {
describeTestA = { [weak self] in
if let strongSelf = self {
strongSelf.testADescription = "Hello, TestA"
}
}
}
deinit {
print("TestA deinit")
}
}
var testa: TestA? = TestA()
testa = nil
使用上述方式来定义 describeTestA 则可以避免循环引用。当然上述示例中也可以使用 [unowned self],unowned 与 weak 的区别已在上面详述过。还有一点需要注意,在 describeTestA closure 内部,使用了 if let strongSelf = self 的方式,使用这种方式的原因以及用处请参考:
I finally figured out weakSelf and strongSelf。
工程中常用的 weak/unowned 的误用
非逃逸 closure
对于非逃逸闭包,编译器可以保证在函数返回时闭包会释放它捕获的所有对象,所以对于非逃逸闭包,不会出现循环引用问题,例如:
class TestA {
var testADescription: String?
func decribe(decribeClosure: () -> Void) {
decribeClosure()
print(testADescription)
}
init() {
decribe {
self.testADescription = "Hello world"
}
}
deinit {
print("TestA deinit")
}
}
var testa: TestA? = TestA()
testa = nil
这里传递给 describe 函数的闭包就是非逃逸闭包,这类闭包强引用 self 并不会造成循环引用,因为在 init 函数执行结束后,闭包会释放它捕获的对象(此处为 self)。
所以,如果使用了非逃逸闭包,我们是无需使用 [weak self] 这类方式来声明捕获参数的,因为其不会产生循环引用问题。对于什么是非逃逸闭包,建议仔细阅读:可选型的非逃逸闭包
对于逃逸闭包而言,是有可能造成循环引用的,因为逃逸闭包可能会被赋值给本对象的某个强引用属性的,这时就导致了循环引用,如下:
class TestA {
var testADescription: String?
var describeClosure: (() -> Void)?
func decribe(describeClosure: @escaping () -> Void) {
describeClosure()
self.describeClosure = describeClosure
print(testADescription)
}
init() {
decribe {
self.testADescription = "Hello world"
}
}
deinit {
print("TestA deinit")
}
}
var testa: TestA? = TestA()
testa = nil
上述代码产生了循环引用,因为传入 describe 函数的参数 describeClosure 被赋值给了 testa 的 describeClosure 属性。这里再提一点,如果 describe 参数没有被声明为逃逸闭包,那么编译器是不允许我们将其赋值给 testa 的 describeClosure 属性的。
虽然逃逸闭包可能造成循环引用,但是并不是所有的逃逸闭包都会造成循环引用,下面举几个例子。
UIView animation
使用 UIView animation 的接口如下:
open class func animate(withDuration duration: TimeInterval,
delay: TimeInterval,
options: UIViewAnimationOptions = [],
animations: @escaping () -> Swift.Void,
completion: ((Bool) -> Swift.Void)? = nil)
我们可以看到 completion closure 和 animations clousre 都是逃逸闭包(completion 没有使用 escaping 关键字,但是参考 可选型的非逃逸闭包 可知 completion 也是逃逸闭包),所以这两个闭包内会强持有 self 对象。
但是这里并不会造成循环引用问题,因为我们传入的 animations 和 completion 闭包并没有被 self 持有(使用时我们是直接新建了一个 closure 传给了 animate 函数),这两个闭包被 Core Animation 持有,所以不存在循环引用。
虽然 animations/completion closure 没有被使用动画的对象持有,但是 animations/completion closure 强持有了使用动画的对象(我们这里假设为 self),那么会不会导致 self 的释放时机受到 animations 的影响而延迟呢?从内存管理的角度分析是会这样的,只要 Core Animation 没有释放 closure,那么 self 就不会被释放掉。由于没有看过动画实现的源码,下述只是推测:
- 当 View 从 window 上移除后,会立即释放在该 View 以及 subviews 添加的动画,这时,animations/completion closure 被释放,随后 self 被释放。
- 至于什么时候从 window 上移除,这里举个例子,当 viewController disappear 后,其 view 会从 window 上移除。一般来说,对于动画,我们肯定是要对 UIView 来处理的,而当我们不需要 UIView 时,这个 view 也肯定会被 window 移除,此时,动画其实完全可以停止了(因为 view 已经不在 window 上了,再做动画也没意义),animations/completion closure 就会释放,所以 UIView animate 动画的 closure 不会引起循环引用问题,一般也不会导致与 View 有关的内存释放延迟。
网络请求 - 以 Alamofire 为例
Alamofire 发送请求使用方法如下:
Alamofire.request(baseUrl + url,
method: httpMethod,
parameters: parameters as? [String: Any],
encoding: encoding, headers: header).responseString { (response) in
self.completeHandler(response: response, success: success, failure: failure)
}
@discardableResult
public func responseString(
queue: DispatchQueue? = nil,
encoding: String.Encoding? = nil,
completionHandler: @escaping (DataResponse<String>) -> Void)
-> Self
responseString 的最后一个参数是一个逃逸闭包,虽然在这个闭包里面使用 self 强引用了调用 Alamofire.request 的对象(也就是 self),但是由于传递给 responseString 的 completionHandler 闭包并没有被调用对象所引用,所以这里是不会产生循环引用的。
但是这里存在一个问题:调用对象的释放时机收到了 completionHandler 生命周期的影响,我们深入看一下 responseString 的源码会发现如下:
@discardableResult
public func responseString(
queue: DispatchQueue? = nil,
encoding: String.Encoding? = nil,
completionHandler: @escaping (DataResponse<String>) -> Void)
-> Self {
return response(queue: queue,
responseSerializer: DataRequest.stringResponseSerializer(encoding: encoding),
completionHandler: completionHandler
)
}
@discardableResult
public func response<T: DataResponseSerializerProtocol>(
queue: DispatchQueue? = nil,
responseSerializer: T,
completionHandler: @escaping (DataResponse<T.SerializedObject>) -> Void)
-> Self {
// ......
(queue ?? DispatchQueue.main).async { completionHandler(dataResponse) }
}
return self
}
也就是说 completionHandler 最终是传给了 gcd 派发系统,所以
completionHandler 会被 gcd 强引用住,如果这个 completionHandler 被释放的实际比较晚,那么它所引用的 self 也会延迟释放。
- request 发送一般属于 controller 的工作,如果 controller 已经被 pop 出去了,我们再去执行数据源刷新、UI 渲染这类操作是完全没有意义的,对于这类情况,需要使用 [weak self] 和 if let strongSelf = self {} 的方式捕获 self 对象,以便对象能够及时释放。
- 当然,也有例外,例如我们这个请求就是为了刷新一些用户数据,那么我们是完全可以直接捕获 self 的,这样我们可以保证 self 不被释放,能够正确的处理数据。
SnapKit 布局
使用 SnapKit 布局的一般代码如下:
secondLabel.snp.makeConstraints { (make) in
make.left.equalTo(self.firstLabel.snp.right).offset(7.5)
make.centerY.equalTo(self.firstLabel.snp.centerY)
make.height.equalTo(15)
make.width.equalTo(ThemeSizes.pixelLineWidth1())
}
我们可以看一下 makeConstraints 这个函数的参数:
public func makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
ConstraintMaker.makeConstraints(view: self.view, closure: closure)
}
// ConstraintMaker 的 makeConstraints 函数
internal static func makeConstraints(view: ConstraintView, closure: (_ make: ConstraintMaker) -> Void)
这个函数的 closure 参数是一个非逃逸闭包,也就是说这个闭包不会被其他属性所持有,执行完毕后就会被释放,所以,这里不会有循环引用问题。
总结
本文分析了造成循环引用的常见原因,并讲述了如何打破循环引用,最后,给出了几种常见的 closure 引用对象的内存分析。
总的来说,分析 iOS 的内存管理重点在于引用计数,而引用计数在于有几个强引用,我们在分析内存时不要着急,要理清楚相互之间的引用关系,这样才能够正确分析每个对象的释放时机,也就掌握了每个对象的生命周期,更利于编写出我们想要的代码。
网友评论