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

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提供的各种强大的工具来检查,解决。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,525评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,203评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,862评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,728评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,743评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,590评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,330评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,244评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,693评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,885评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,001评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,723评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,343评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,919评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,042评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,191评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,955评论 2 355

推荐阅读更多精彩内容