IOS三人行swift入门与提高iOS进阶指南

从一个内存泄漏复习swift对象的构造过程

2016-10-25  本文已影响157人  肆_春分

XCode 8有非常多的更新,其中的 memory graph 对于内存分析非常有用,十分强大,可以方便的查看对象引用关系以及侦测内存泄漏,近期在使用memory graph进行调试的过程中发现了一些奇怪的现象,这里使用一个简单的demo工程来说明这个内存泄漏的原因和解决方法。

@0 内存孤岛

如下图是在调试过程中发现的一处内存泄漏:

从图上看,这里被检测到有内存泄漏,但是这个对象没有被任何其它对象引用,也没有引用其它对象,一般在ARC代码里面常见的内存泄漏一般都是由于retain cycle引起的,这里看起来没有任何循环持有,那到底是怎么回事儿呢,从右边的backtrace可以看到这个对象创建时候的调用堆栈(如果你看不到的话,需要在scheme的Diagnostics下面打开Malloc Stack的开关),从调用栈上可以轻松找到这个对象创建的代码。

看起来这里应该很容易解决了,毕竟代码已经找到了。可实际上不是那么回事儿,下面来看一下这里的代码:

class DerrivedView : LKSuperView {
    let config = Config()  // <<<--- 报告就是这里创建的对象没有释放
    
    override init() {
        super.init()
    }


    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = config.color
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
// 在某个地方用到了这个view
{ 
    let derrivedView = DerrivedView()
}

@1 代码追查

既然代码已经定位到了,为什么还不能解决呢,不过这一句的语法实在是简单到不能再简单了,完全无法修改。仔细检查代码,没有其它的地方引用这个对象,完全不会存在循环引用的问题,难道是swift最后没有释放这个对象,这里来看看LKSuperView有没有问题:

@interface LKSuperView : UIView
- (instancetype)init;
//...
@end

@implementation LKSuperView
- (instancetype)init {
    return [self initWithFrame:CGRectMake(0, 0, 100, 100)];
}
@end

这里是一个OC类,重写了init方法,给了一个默认的frame,看起来也没什么问题。

会不会是误报呢,给Config类加一些log看看

class Config {
    let color : UIColor
    init() {
        print(#function)
        color = UIColor.red
    }
    deinit {
        print(#function)
    }
}
//output
//init()
//init()
//deinit

从log输出看确实调用了两次构造和一次析构,但是DerrivedView也只有一个对象,为什么会调用两次Config的init呢,打个断点看一下,好像有些新发现:


从两次断点来看,一次来自于DerrivedView::init,一次来自于DerrivedView::initWithFrame,回头看看LKSuperView::init,确实调用了initWithFrame,最后又回到了DerrivedView::initWithFrame,两次调用在OC里面看起来是比较正常的写法了,在swift里面难道会初始化两遍成员变量吗,是不是这样的呢,接下来打开调试时显示汇编代码的功能,一看究竟


汇编代码

果然,在init和initWithFrame的汇编代码里前面都看到同样的汇编代码,天书一样的汇编可以先忽略,因为反汇编代码的注释也很强大,看看框起来的代码注释,第一行是调用Config::init创建一个Config对象,第二行就是把生成的Config对象地址直接赋值到DerrivedView.config,这里不存在getter和setter,也不会考虑这里是否被初始化过,最终直接覆盖了第一次初始化的对象,第一次创建的对象变成了一具尸体,放逐到无尽的深渊。

@2 知识回顾

Swift对象构造过程

Swift构造函数有两种:指定构造函数和便利构造函数,这里不详细描述,可以参考官方文档,简单来说,便利构造函数必须调用本类的其它构造函数,指定构造函数必须调用父类的指定构造函数,便利构造函数最终必须要调用到一个指定构造函数,如图:

构造函数调用示意图

实际上还有一个非常重要的区别,指定构造函数都有隐式的进行成员变量的初始化,而便利构造函数没有,这也是为什么便利构造函数为什么一定要直接或间接调用本类的指定构造函数的原因之一,通过检查两种不同构造函数的反汇编代码,可以很清楚的看到这一结论。

两步构造法

在Swift里面一个对象的继承关系链和对象的初始化有着对应的关系,按照两步构造法,第一步从子类向上依次初始化成员变量,当根类初始化完成之后,开始第二阶段的调用,第二阶段就可以自由调用使用该对象的所有变量和函数,为了保证这两步构造,编译器会进行4种安全检查。这里看一些常见的错误例子:

class Base {
    convenience init() {
        print("base")//错误:没有调用本类的指定构造函数
    }
    init(name : String) {
        print(name)
    }
    init(name : String, age : Int) {
        print("just for demo")
    }
}

class Sample : Base {
    let value : Int
    func sayHello() {
        print("Hello")
    }
    init () {
        value = 10
        //错误:指定构造必须调用super的指定构造函数
    }
    override init(name : String) {
        //错误1:必须在init之前对value进行初始化
        self.sayHello() //错误2:在super.init结束前,或者第一阶段构造结束前不能使用self关键字
        super.init(name : name) 
    }
    convenience init(test : Int) {
        super.init()//错误:便利构造必须使用self调用本类的构造函数,这样才有机会对本类的成员进行初始化
    }
}

对于一个指定构造函数来讲,可以认为一个标准的模板是这样的

init{
  //第一阶段:变量初始化
  //如果有父类,构造过程传递给父类,等待父类初始化
  //第二阶段:自定义行为
}

两步构造保证了函数调用的安全性,相对比,C++就没有这个特性,C++有一个常见的问题就是:在构造函数里面是否可以调用虚函数,C++没有两步构造,所以在父类的构造函数调用的时候,子类还完全没有开始初始化,也就是说虚函数表还没有准备就绪,所有在父类的构造函数里调用虚函数很可能不能给你想要的结果。

先复习到这里,更多内容请参考官方文档关于构造过程的章节。

@3 问题分析及预防

继续回到内存泄漏的问题上,根据前面的分析,OC代码把构造过程传递给了子类,这明显不符合Swift两步构造的安全检查,但是OC并没有这样的检查,OC同样也是基于两步构造的,只不过OC的成员变量统一被初始化为0或者nil,OC的构造函数传递也是基于消息的,这样最终导致了开头的问题出现。

OC的init函数里面调用[self init…]同样是基于消息发送,最终调用的是子类的方法。
Swift的init函数里面调用self.init(…)是函数调用,一定会调用本类的实现,当然这个调用者必须是便利构造函数。

如何解决这个问题呢,直观上来看不能够调用LKSuperView的init函数,因为在init里面调用initWithFrame是不符合两步构造的原则的,第一个解决方法就是,在DerrivedView里面实现自己的init方法:

convenience override init() {
    self.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
}

其实,有经验的同学也一定清楚UIView::init函数最后也会调用initWithFrame,这里感觉有些坑,所以就算是在LKSuperView里面直接调用[super init]也解决不了问题,在Swift代码继承OC的类的时候,需要注意以下几点:

  • OC类的init函数尽量正规化,不要修改self的值
  • Swift类尽量去检查OC构造函数链,避免发生以上状况,有时候这种情况会比较隐蔽
  • 无论OC还是Swift尽量不去重写init构造函数,而是重写标记了 NS_DESIGNATED_INITIALIZER 的构造函数

@4 结语

对于Swift和OC的代码混用中,一定会存在各种意想不到的问题,毕竟两种语言的设计思想存在较大的差异,在遇到问题的时候,要善于利用Xcode提供的各种强大的工具来检查,解决。

上一篇 下一篇

猜你喜欢

热点阅读