并非所有NSObject的子类的构造都会经过NSObject的构造方法

发布于: 2016-10-16 14:02
阅读: 935
评论: 0
喜欢: 6

作者:Enum

原文链接:http://posts.enumsblog.com/posts/16004

TAG: Swift iOS


这是一次失败了的项目,Demo在「GitHub」中开源。

如果需要使用内存泄露监测工具,推荐使用Facebook的产品「FBMemoryProfiler」

开发环境

macOS 10.12 + Xcode 8 with Swift 3.0

导语

本项目最初的想法是利用方法交叉(method swizzling)替换掉NSObjectinitdeinit方法来接受对象创建时和销毁时的消息。在Swift中,使用Objc-runtime有一点小坑,绕过坑的办法后面再讲。但最终,这个项目失败了。

先说结论:当我们创建新的NSObject子类时,在构造方法中通常会调用super.init,但研究结果发现,并非所有NSObject的子类的构造都会经过NSObject的init。

方法交叉

项目中用到了两个Objc-runtime的API,其用法跟Objc语法类似:

public func class_getInstanceMethod(_ cls: Swift.AnyClass!, _ name: Selector!) ->Method!
public func method_exchangeImplementations(_ m1: Method!, _ m2: Method!)

这两个方法是全局的,可以直接调用。这一步的目的是利用Objc的动态性将两个实例方法的方法的实现对调。需要注意的点:

  • Swift的类请继承NSObject,来避免各种奇怪的问题。
  • 两个对调的方法参数要对应。
  • 在Swift中使用时,交换的方法需要通过ObjC选择器去执行才能调用到交换后的方法。
self.perform(#selector(NSObject.init(_:)))
  • 在方法前面加入dynamic关键字可以绕过上面这个坑,直接使用C-Style的调用方法也能调用交换后的方法。

交换构造方法

交换init方法,由于参数需要对应的原则,使用默认值进行混淆,这个构造方法可以成功被交换。

convenience init(_ whatever : Int = 0) {
    self.init(0)
    ZYQLeaks.shared.objInit(cls: object_getClass(self))
}

交换的方法为:

    let objectClass: AnyClass = NSObject.classForCoder()
    let objectInit = class_getInstanceMethod(objectClass, #selector(NSObject.init))
    let exchangeInit = class_getInstanceMethod(objectClass, #selector(NSObject.init(_:)))
    method_exchangeImplementations(objectInit, exchangeInit)

交换掉构造函数后,就可以监控NSObject对象的子类了。监控是在AppDelegate中启动的。 对象初始化监控

可以看到控制台已经打印出了从AppDelegate中启动监控后所有NSObject子类创建时的日志了。最初我也是这么以为的,直到我替换掉了deinit方法,发现了问题。

交换析构方法

在Swift中,所有对象在释放前都会走deinit{ }方法。这是个很特殊的方法,不允许手动调用,只会在对象需要释放时被ARC自动调用

deinit「官方文档」中这样描述:

Deinitializers are called automatically, just before instance deallocation takes place. You are not allowed to call a deinitializer yourself. Superclass deinitializers are inherited by their subclasses, and the superclass deinitializer is called automatically at the end of a subclass deinitializer implementation. Superclass deinitializers are always called, even if a subclass does not provide its own deinitializer.

关于析构函数:

  • deinit会被自动调用,且不允许手动调用。
  • 在调用完子类的析构方法后会自动调用父类的析构方法。
  • 在执行析构方法时,所有的属性都还没有被释放掉,还是可以用的。

为什么说析构方法很特殊,因为它的写法为:

    deinit {
        //whatever
    }
  • 它是个方法,却没有参数,甚至没有括号。
  • 不用也不能调用super.deinit,但super.deinit会自动被调用。意味着这个方法不仅仅执行表面上的代码,有很多表面上看不到的,没写出来的代码会被执行。

这些问题很可能影响到我们现在要做的方法交叉,抱着试试看的心理,开始交换析构方法,很遗憾的是,尝试了一下几种方法都出现了问题:

方法1

将deinit看做普通函数,使用常规方法交换:

let objectDeinit = class_getInstanceMethod(objectClass, #selector(NSObject.deinit))
let exchangeDeinit = class_getInstanceMethod(objectClass,#selector(NSObject.zyqleaksDeinit))
method_exchangeImplementations(objectDeinit, exchangeDeinit)
dynamic func zyqleaksDeinit() {
    ZYQLeaks.shared.objDeinit(cls: object_getClass(self))
}

方法1

这里的错误我没有找到原因,如果有大神知道请务必告诉我原因,在此谢过。

方法2

使用类方法交换,虽然成功调用了,但是造成了内存溢出。所有的对象都没有被释放掉。显然NSObject的deinit中有一些看不到的代码被执行了,这些代码可能跟引用计数有关。

    let objectDeinit = class_getInstanceMethod(objectClass, #selector(NSObject.deinit))
    let exchangeDeinit = class_getClassMethod(objectClass,#selector(NSObject.zyqleaksDeinit))
    method_exchangeImplementations(objectDeinit, exchangeDeinit)
    static dynamic func zyqleaksDeinit() {
        ZYQLeaks.shared.objDeinit(cls: self.classForCoder())
    }

方法2

使用静态方法交换后产生了2个问题:

  • deinitzyqleaksDeinit交换后,deinit变成了zyqleaksDeinit,在对象需要deinit时调用了zyqleaksDeinit。而根据上面的推测deinit中可能包含了一些跟引用计数有关的代码,在执行完zyqleaksDeinit后我必须要执行deinit才能保证对象正常释放。而问题是,与zyqleaksDeinit是个类方法,如果我在这里调用zyqleaksDeinit,那么执行的仍然是zyqleaksDeinit,并不是被交换掉的deinit。因此导致引用计数出现问题,对象没有被正常标记释放,内存泄露。
  • 在查看日志时发现NSStringNSArrayNSDictionary等,包括Mutable的这类对象,计数都是负数。这意味着只出现了init的日志,没有出现deinit的日志。 由此得出,这些类也是NSObject的子类,而他们构造时并没有经过NSObject的init。

最终的问题

对于上面提到的两个问题:

第一个问题的关键就是前面方法1的方案,如果可以正确处理方法1中崩溃,那么相信在交换实例方法后再调用一次zyqleaksDeinit就可以解决。

第二个问题,经过和殿神 酷酷的哀殿 的讨论,殿神的解释为:

NSArray等长度可变类型,在最初的设计时,它是被设计为 开发者需要提前预估容量 的类型,所以,它的初始化方法需要最终调用到 带有 count 的初始化方法。

而Sunny大神 @我就叫Sunny怎么了 的解释为:

这几个类是类簇,像 NSArray 这种是虚类,要 hook 的话去 hook 什么 _NSArrayI NSCFArray NSSingleObjectArray 的 init 才行,还得看你是用什么方式来 hook

两个人的答案基本是一致的 —— 我并没有hook到这些类的初始化。至此基本上可以得出的结论就是,并不是所有NSObject子类的初始化都走了NSObject的init。我hook了NSObject的init并不能在所有NSObject构造时都收到消息。因此用这个思路去设计内存泄露监测工具是错误的。

结语

在这两天的研究过程中,踩了不少坑。在hookinit方法时对Cocoa的不熟悉,以为hook到NSObject就等于hook到了所有的子类。在deinit的交换过程中暴露了自己对对象销毁过程的不熟悉,至今都没有找到解决方法。但这两天的研究并没有白费,得到了这样的一个结论。同时也希望如果有大神知道deinit中崩溃的原因,能指导一下我。

更新

2016年11月02日 参考文献补充


Thanks for reading.

All the best wishes for you! 💕