ObjC如何通过runtime修改Ivar的内存管理方式(一)

为什么要这么做?

在iOS 9之前,UITableView(或者更确切的说是 UIScrollView)有一个众所周知的问题:

@property (nonatomic, assign) id delegate;

苹果将 delegate 的内存修饰符声明为了assign,这是 MRC 时代防止循环引用的不二法门。但是到了 ARC 时代,苹果引入了弱引用修饰符(weak)对原先的(assign)暨非强引用修饰符进行了细分。在大多数场景下,将 delegate 声明为assign并不会产生什么严重后果,因为 delegate 对象(例如 UIViewController)通常持有了这个 UIScrollView,当 delegate 对象释放的时候,UIScrollView 也会被一起释放。

然而只要存在发生意外的风险,意外就一定会发生。如果在 delegate 对象释放的时候,UIScrollView 因为某些原因正在被其他对象强持有而导致没有被一起释放,那么当 UIScrollView 在之后调用 delegate 方法的时候就会崩溃,因为这个时候 delegate 已经是一个野指针了。最常见的导致 UIScrollView 没有被及时释放的原因是滚动所带来的动画,因为系统在渲染动画的时候需要强持有这个 view,而 UIScrollView 这种天生内置动画效果的类就变成了受到这个 assign 修饰符影响最广泛的类。

因为国内用户对iOS系统的更新并不像国外那样普遍,至今仍然有大量手机运行着iOS 7.x和8.x,很多app也因此一直保持着对iOS 7.x和8.x系统的支持,所以这个问题在iOS 11都即将到来的时代仍然持续不断地困扰着众多的iOS开发者。

第一个非常流行的解决方案:

在 delegate 对象的 dealloc 方法里将 UIScrollView 的 delegate 属性置空。

这个看似简单的解决办法却也带来了两个额外的问题,一是只能对有源代码的类进行修改,那些没有源代码的第三方库是没有办法进行修复的。二是就算是自己写的类,人都会犯错或疏忽大意,忘记在dealloc里面将 delegate 置空会导致这个问题依然还会时不时的出现。在后面的文章为了简明起见,我们将这种方法称之为方案1

那有没有办法解决上面提到的这两个问题呢?答案是肯定的。可能已经有人想到用 oc runtime 的方法替换的去做,替换 NSObject 的 dealloc 方法和 UIScrollView 的setDelegate:方法。具体方法在这里就不展开细说了,大家有兴趣可以参考这里。在后面的文章为了简明起见,我们将这种方法称之为方案2


我们为什么还要继续?

提出方案2的时候,这个关于 UIScrollView 崩溃的问题已经比较完美地被解决了。剩下的无非比较权衡方案2的各种实现之间的优劣而已,那我们为什么还要继续呢?

我在最开始在崩溃日志上看到 UIScrollView 的崩溃的时候,经过 google 和 stackoverflow 大法搞明白崩溃的原因之后,跳入我脑中的完美解决方案,即不是方案1也不是方案2,而是:

如何将一个已经在编译时确定为__unsafe_unretained的成员变量在运行时重新声明为__weak

我们姑且称之为方案3。事情往往没有那么简单,在这条直接粗暴看似捷径的小路上,其实荆棘遍地步履维艰。方案3需要对 objective c 有着深入的理解和认知,所需要的逻辑和方法也远比方案1方案2晦涩难懂。如果你只想解决UIScrollView 在ios 9之前因为 delegate 被声明为assign所导致的崩溃的话,那么无论方案1或者方案2都是非常简单有效的解决方案,直接套用即可。如果你和我一样,想顺便探索一下 objective c 的秘密的话,我邀请你和我一起继续前行。


成员变量 Ivar 及内存修饰符

既然问题的症结在于成员变量 Ivar 在编译时所使用的修饰符是错误的,那 Ivar 以及它的修饰符到底是什么呢?

如果你熟悉oc的源码,你可能很清楚的知道 Ivar 与属性(property)的不同。我们现在写代码所使用的通常都是使用 property 来间接定义 Ivar 。当前的 XCode 已经很少需要在声明 property 的时候同时声明 Ivar ,大部分场景下编译器会自动声明对应的 Ivar(使用 property 的名字前面加下划线的方式命名),并为之创建默认的gettersetter。这极大的简化了代码,避免像 Java 一样一个类包含大量冗余方法。例如:

// MCCLabelView.h
@interface MCCLabelView : UIView
@property (nonatomic, weak, readonly) UIViewController *viewController;
@property (nonatomic, strong) UILabel *label;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, strong) NSNumber *number;
@property (nonatomic, weak) UITextView *textView;
@property (nonatomic, assign) BOOL enabled;
@end
// MCCLabelView.m
@interface MCCLabelView () {
    __strong UILabel *_a_label;
    __weak UIViewController *_vc;
}
@end
@implementation MCCLabelView
@synthesize label = _a_label;
@synthesize viewController = _vc;
@end

上面的例子里,属性 label 和 viewController 所对应的 Ivar 与习惯命名不同,所以需要手动声明@synthesize告诉编译器这个 property 所对应的 Ivar 是什么,以便编译器能够正确生成gettersetter。当然还有另外一种@dynamic的声明,这个就超出了此篇的讨论范围,就不在这里延展了。

细心的你可能已经发现了,成员变量 _vc 所使用的修饰符是__weak。这就是 Ivar 在 ARC 上使用的内存修饰符,将一个 Ivar 声明为弱引用对象。可以声明的值包括__strong(默认), __weak以及__unsafe_unretained。这个和 property 所支持的修饰符是一致的。如果是编译器根据 property 自动生成的 Ivar ,编译器会根据 property 的修饰符推断出 Ivar 所需要的内存修饰符。

到了这里,我们已经知道,成员变量的内存修饰符,可以单独指定,也可以跟随 property 自动指定。内存修饰符决定着 Ivar 在运行时所使用的内存管理模式。不幸的是,这是在编译时就已经确定的(也就是我们通常所说的编译时决议),oc的runtime 并没有提供给我们在运行时动态变更一个 Ivar 内存修饰符的方法。

那怎么办呢?这个时候只好寄希望于oc runtime的源代码能给我们指一条明路了。


探寻 Ivar 的内存修饰符

我们的目标是要深入到 object_class 类的源码里面挖掘关于成员变量 Ivar 的所有实现细节,通过这些细节找到运行时修改的方法。如果对于 oc 中类和对象的结构你并不了解,请先移步仔细阅读 Draveness 大神的这两篇神作,这对你建立一个微观的 oc 世界观有着极为重要的启发作用:

从 NSObject 的初始化了解 isa
深入解析 ObjC 中方法的结构

如上图所示,经过抽丝剥茧一层一层地深入到 NSObject 的内部,我们终于到达了此次探寻的目的地class_ro_t。这个结构顾名思义,它存放着所有在编译阶段就已经确定的成员变量列表、属性列表以及方法列表、协议等等只读信息。而运行时可以修改的数据都存放在它的持有者class_rw_t里面,这里面并不包括成员变量。runtime 提供的方法都是针对class_rw_t的数据进行修改,这样看起来我们像是走进了死胡同。

既然 Ivar 的信息都存放class_ro_t里面,那本着不撞南墙不回头的精神让我们来看看class_ro_t里面是如何存储 Ivar 的。ivar_list_t这个变量是const类型的指针,从名字看是存储成员变量列表的地方,那我们先看看源码里它是怎么定义的吧:

struct ivar_list_t : entsize_list_tt0> {
    bool containsIvar(Ivar ivar) const {
        return (ivar >= (Ivar)&*begin()  &&  ivar < (Ivar)&*end());
    }
};

这个struct看起来很复杂的样子,entsize_list_tt是通过 C++ 模版定义的容器类,提供了一些诸如 count 、 get 以及迭代器 iterator 的方法和类,通过这些方法和类可以方便地遍历并获取容器内的数据。ivar_list_t继承自entsize_list_tt,并指定了容器内存放的数据类型为ivar_t

那么这个ivar_t又是什么呢?我们继续在源代码里寻找它的定义:

struct ivar_t {
#if __x86_64__
    // *offset was originally 64-bit on some x86_64 platforms.
    // We read and write only 32 bits of it.
    // Some metadata provides all 64 bits. This is harmless for unsigned 
    // little-endian values.
    // Some code uses all 64 bits. class_addIvar() over-allocates the 
    // offset for their benefit.
#endif
    int32_t *offset;
    const char *name;
    const char *type;
    // alignment is sometimes -1; use alignment() instead
    uint32_t alignment_raw;
    uint32_t size;

    uint32_t alignment() const {
        if (alignment_raw == ~(uint32_t)0) return 1U << WORD_SHIFT;
        return 1 << alignment_raw;
    }
};

ivar_t依然是一个c struct,它包含如下成员:

  • offset,变量的偏移量:Objective-C类成员变量深度剖析
  • name,变量名
  • type,变量类型标注:Type Encodings, Apple

揭开 Ivar Layout的秘密

到了这里,我们发现ivar_t里并没有存储 Ivar 的内存管理的信息。我们返回class_ro_t继续研究,这一次 ivarLayout 和 weakIvarLayout 进入了我们的视野中。这两个成员都是const uint8_t *,这个看起来像是 c 的数组的家伙到底是如何将类中那么多的变量的内存修饰符一一存储起来的呢?runtime 虽然提供了 class_getIvarLayout 和 class_setIvarLayout 方法,但是却并没有对它的内容含义进行详细解释。再次搬出 google 大法后,找到了一篇孙源大神两年前写的Objective-C Class Ivar Layout 探索以及 Draveness 大神的检测 NSObject 对象持有的强指针。这两篇文章是我们此次寻找解决方案的最重要的基石。他们都对 Ivar Layout 的内容进行了详细的解读和试验。

Ivar Layout 就是一系列的字符,每两个一组,比如 \xmn,每一组 Ivar Layout 中第一位表示有 m 个非强属性,第二位表示接下来有 n 个强属性

class_ro_t中我们可以看出,ivarLayout 存储着strong类型的成员变量信息,而 weakIvarLayout 存储着weak类型的成员变量信息,那么由此可以推断出既不在 ivarLayout 也不在 weakIvarLayout 里面的成员变量肯定是__unsafe_unretained的变量。举个例子:

// MCCLabelView.h
@interface MCCLabelView : UIView
@property (nonatomic, weak, readonly) UIViewController *viewController;
@property (nonatomic, strong) UILabel *label;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, strong) NSNumber *number;
@property (nonatomic, weak) UITextView *textView;
@property (nonatomic, assign) BOOL enabled;
@end

编译后运行,使用 runtime 的 class_getIvarLayout 方法获取 ivarLayout 信息,会得到如下输出:

(lldb) p class_getIvarLayout([MCCLabelView class])
(const uint8_t *) $1 = 0x0000000100002ecd "\x13"
(lldb) x/2xb $1
0x100002ecd: 0x13 0x00

接下来使用 class_getWeakIvarLayout 方法获取 weakIvarLayout 信息,会得到如下输出:

(lldb) p class_getWeakIvarLayout([MCCLabelView class])
(const uint8_t *) $1 = 0x0000000100002ecf "\x01\x31"
(lldb) x/3xb $1
0x100002ecf: 0x01 0x31 0x00

我们必须对 Ivar Layout 做一个更全面的解读,这是我们在完成最终解决方案时必不可少的前提条件。我们首先给出更准确的定义:

对于 ivarLayout 来说,每个uint8_t的高4位代表连续是非storng类型 Ivar 的数量(m),m ∈ [0x0, 0xf],低4位代表连续是strong类型 Ivar 的数量(n),n ∈ [0x0, 0xf]。

对于 weakIvarLayout 来说,每个uint8_t的高4位代表连续是非weak类型 Ivar 的数量(m),m ∈ [0x0, 0xf],低4位代表连续是weak类型 Ivar 的数量(n),n ∈ [0x0, 0xf]。

无论是 ivarLayout 还是 weakIvarLayout,结尾都需要填充 \x00 结尾

看到这里,可能你会问,如果连续存在相同类型超过 0xf 个变量怎么办呢?超出的部分,会重新开始一个新的uint8_t来记录。我们来看个更复杂的例子:

@interface MCCLargeExample : NSObject {
    __strong id s1;
    __strong id s2;
    ...
    __strong id s20;
    BOOL u1;
    __weak id w1;
    __weak id w2;
    ...
    __weak id w16;
    BOOL u2;
}
@end

使用 class_getIvarLayout 方法会得到如下输出:

(lldb) p class_getIvarLayout([MCCLargeExample class])
(const uint8_t *) $1 = 0x0000000100002ecd "\x0f\x05"

使用 class_getWeakIvarLayout 方法会得到如下输出:

(lldb) p class_getWeakIvarLayout([MCCLargeExample class])
(const uint8_t *) $1 = 0x0000000100002ed0 "\xf0\x6f\x01"

为什么 ivarLayout 只描述了总共20个strong变量,而 s20 后面明明还有18个非strong变量呢?不应该是

"\x0f\x05\xf0\x30"

么?对于 ivarLayout 来说,它其实只关心strong变量的数量,记录前面有多少个非strong变量的数量无非是为了正确移动索引值而已。在最后一个strong变量后面的所有非strong变量,都会被自动忽略。weakIvarLayout 同理。苹果这么做的初衷是为了用尽可能少的内存去描述类的每一个成员变量的内存修饰符。像上面的例子,MCCLargeExample 总共有38个成员变量,但是 ivarLayout 只用了 2+1=3 个字节,weakIvarLayout 只用了 3+1=4 个字节就描述了这38个成员变量的内存修饰符,节约了80%以上的内存占用,这其实可以看作是一种非常简单高效的压缩算法。

现在我们知道了class_ro_t如何通过 ivarLayout 和 weakIvarLayout 来描述类中每个成员变量的内存修饰符,我们离我们的最终目标——动态修改内存修饰符又近了一步。

(待续)


你可能感兴趣的:(objective,c,runtime)