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, assign) id delegate;
@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_tt<ivar_t, ivar_list_t, 0> {
    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,它包含如下成员:

解析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, assign) id delegate;
@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"
(lldb) x/3xb $1
0x100002ecf: 0x01 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 同理。苹果这么做的初衷是为了用尽可能少的内存去描述类的每一个成员变量的内存修饰符。像上面的例子,MCLargeExample 总共有38个成员变量,但是 ivarLayout 只用了 2+1=3 个字节,weakIvarLayout 只用了 3+1=4 个字节就描述了这38个成员变量的内存修饰符,节约了80%以上的内存占用,这其实可以看作是一种非常简单高效的压缩算法。

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


是否能够在运行时修改 Ivar Layout?

虽然我们已经破译了 oc runtime 如何存储变量的内存修饰符的秘密,但是我们是否能够在运行时通过修改 Ivar Layout 的方式来改变变量的内存管理方式呢?例如 assgin 变为 weak ?仔细推敲Objective-C Class Ivar Layout 探索的细节后,我们不难得出一个简单直接的办法——调用 class_setIvarLayout 和 class_setWeakIvarLayout 重新设置 Ivar Layout 不就达成目标了么?看起来简单可行,我们新建了一个测试类 MCAssignToWeak 来模拟 UIScrollView 的场景:

@interface MCCAssignToWeak : NSObject
@property (nonatomic, strong) id s1;
@property (nonatomic, assign) id delegate;
@property (nonatomic, weak) id w1;
- (void)notifyDelegate;
@end
// MCCAssignToWeak.m
@implementation MCCAssignToWeak
- (void)notifyDelegate {
    // 这里检查delegate是否已经变成了野指针
    aassert(!self.delegate || malloc_size((__bridge void *)self.delegate) > 0);
    NSLog(@"===== notify %@", [self.delegate class]);
}
- (void)setDelegate:(id)delegate {
    _delegate = delegate;
    NSLog(@"===== setDelegate:");
}
@end

并将里面的 delegate 属性从assign设置为weak,直接 hardcode 在纸上算好的 ivarLayout 和 weakIvarLayout 的新值赋给 MCAssignToWeak,调用后立马被 runtime 无情地打了脸。

*** Can't set ivar layout for already-registered class 'MCCAssignToWeak'

无奈之下, 我们只好回过头来翻出 class_setIvarLayout 的源码看一下:

/***********************************************************************
* class_setIvarLayout
* Changes the class's ivar layout.
* nil layout means no unscanned ivars
* The class must be under construction.
...
**********************************************************************/
void
class_setIvarLayout(Class cls, const uint8_t *layout)
{
    ...
    // Can only change layout of in-construction classes.
    // note: if modifications to post-construction classes were 
    //   allowed, there would be a race below (us vs. concurrent object_setIvar)
    if (!(cls->data()->flags & RW_CONSTRUCTING)) {
        _objc_inform("*** Can't set ivar layout for already-registered "
                     "class '%s'", cls->nameForLogging());
        return;
    }
    ...
}

注释里明确说了 The class must be under construnction, 而我们看到的那行 log 则来自于第 15 行的 if 判断失败。我们只好继续在源代码里搜索使用RW_CONSTRUCTING的地方,接着就找到了下面代码:

static void objc_initializeClassPair_internal(Class superclass, const char *name, Class cls, Class meta)
{
    ...
    cls->data()->flags = RW_CONSTRUCTING | RW_COPIED_RO | RW_REALIZED | RW_REALIZING;
    meta->data()->flags = RW_CONSTRUCTING | RW_COPIED_RO | RW_REALIZED | RW_REALIZING;
    ...
}

原来只有调用了 objc_initializeClassPair 的类才会有这个RW_CONSTRUCTING的标志位,而这意味着只有在运行时由开发者动态添加的类在 objc_registerClassPair 调用之前才能修改 Ivar Layout,一旦调用了 objc_registerClassPair 就意味着这个类已经对修改关闭,不再接受任何对 Ivar 的修改了,而那些编译时就已确定的类根本就没有任何机会修改 Ivar Layout。回想Objective-C Class Ivar Layout 探索里,大神需要解决的问题确实是如何为一个动态添加的类添加 weak 属性的 Ivar,和我们所处的场景不一样。难道我们探索了这么久最终还是走进了一条根本行不通的死胡同?

幸亏我们有 runtime 的源代码,让我们知道这个标志位的定义以及作用。我们尝试在调用 class_setIvarLayout 之前,将这个类的 flags 加上RW_CONSTRUCTING标志,调用完成后再重置。因为设置 flags 需要使用到 runtime 源码内关于 object_class、class_data_bits_t 以及 class_rw_t 的结构体定义,于是我们偷懒地在大神的代码基础上进行再加工,那些我们暂时还不需要知道细节的指针一律使用了void *

static void _fixupAssginDelegate(Class class) {
    struct {
        Class isa;
        Class superclass;
        struct {
            void *_buckets;
#if __LP64__
            uint32_t _mask;
            uint32_t _occupied;
#else
            uint16_t _mask;
            uint16_t _occupied;
#endif
        } cache;
        uintptr_t bits;
    } *objcClass = (__bridge typeof(objcClass))class;
#if !__LP64__
#define FAST_DATA_MASK 0xfffffffcUL
#else
#define FAST_DATA_MASK 0x00007ffffffffff8UL
#endif
    struct {
        uint32_t flags;
        uint32_t version;
        struct {
            uint32_t flags;
            uint32_t instanceStart;
            uint32_t instanceSize;
#ifdef __LP64__
            uint32_t reserved;
#endif
            const uint8_t *ivarLayout;
            const char *name;
            void *baseMethodList;
            void *baseProtocols;
            void *ivars;
            const uint8_t *weakIvarLayout;
        } *ro;
    } *objcRWClass = (typeof(objcRWClass))(objcClass->bits & FAST_DATA_MASK);
#define RW_CONSTRUCTING (1<<26)
    objcRWClass->flags |= RW_CONSTRUCTING;
    
    // delegate从assign变为weak,需要将weakIvarLayout从\x21修改为\x12
    uint8_t *weakIvarLayout = (uint8_t *)calloc(3, 1);
    *weakIvarLayout = 0x21; *(weakIvarLayout+1) = 0x12;
    class_setWeakIvarLayout(class, weakIvarLayout);
    // 完成后清除标志位
    objcRWClass->flags &= ~RW_CONSTRUCTING;
}

一次失败的尝试

既然我们已经有了如何修复的假设,接下来就需要验证我们的假设是不是正确的。这段代码应该放在哪里执行呢?我们知道 runtime 在启动的时候会依次调用所有类以及所有分类的+ (void)load方法,我们为了展示 UIScrollView 这种没有源码的系统类应该如何进行修改,特意为 MCAssignToWeak 创建了一个新的分类 fixup,然后在这个分类重写+ (void)load方法:

@interface MCCAssignToWeak (fixup)
@end
@implementation MCCAssignToWeak (fixup)
+ (void)load {
    _fixupAssginDelegate(self);
}
@end

为了验证我们的代码是否真的将delegate对象从assign变为了weak,我们还需要下面的验证代码:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        MCCAssignToWeak *atw = [MCCAssignToWeak new];
        {
            NSObject *proxy = [NSObject new];
            atw.delegate = proxy;
            [atw notifyDelegate]; // 这里不会崩溃
        }
        // 如果delegate仍然是assign,那这里有几率崩溃
        [atw notifyDelegate]; 
    }
    return 0;
}

运行之后,我们期望的输出应该是这样的:

2017-07-21 11:06:31.157609+0800 demo[38605:16165704] ===== notify NSObject
2017-07-21 11:06:31.157691+0800 demo[38605:16165704] ===== notify (null)

但事与愿违,执行程序后崩溃在第二个 notifyDelegate 处,看起来 delegate 对象依然是个野指针。这是为什么呢?仔细推敲assign或着说_unsafe_unretained的实现原理,这个修饰符会在编译时告诉编译器赋值和取值的时候,不需要运行时做任何内存管理,直接操作内存地址即可,这些操作可以直接在编译时确定,无需再依赖运行时。所以编译器插入的 setter 里面,对_delegate = delegate会直接转化为指针拷贝( getter 同理),这样就算我们在运行时动态修改了 _delegate 的 layout 也无济于事,因为代码早就确定了。难道我们又走进了死胡同吗?


继续深入

既然编译器生成的 getter 和 setter不能用,那我们就自己写一套吧。在这之前我们需要搞清楚编译器如何为一个weak对象生成 getter 和 setter。还是 MCAssignToWeak,我们先来看一下 delegate 是assign的时侯 setter 的汇编代码:

; 附上oc代码方便对照
; @property (nonatomic, assign) id delegate;
; - (void)setDelegate:(id)delegate {
;    _delegate = delegate;
; }
demo`::-[MCAssignToWeak setDelegate:](id):
    ...
    0x100001af4 <+36>:  movq   -0x18(%rbp), %rdx
    0x100001af8 <+40>:  movq   -0x8(%rbp), %rsi
    0x100001afc <+44>:  movq   0x21ad(%rip), %rdi        ; MCAssignToWeak._delegate
    0x100001b03 <+51>:  movq   %rdx, (%rsi,%rdi)
    ...

编者注:这是模拟器运行的 x86_64 汇编,AT&T 的汇编语法。ARM 与 AT&T 不同,但原理都一样。如果你对 ARM 汇编有兴趣,可以参考iOS汇编教程:理解ARM。我们这里就以模拟器来做为分析样本了。

我们为了节省篇幅,省略了获取 self 引用的过程,几乎所有的对象方法都有这一段。跳过这里来到第 8 行到第 11 行,这就是我们要找的_delegate = delegate所对应的汇编代码。那这四行都做了什么呢:

    0x100001af4 <+36>:  movq   -0x18(%rbp), %rdx    ; $rbp-0x18里存放delegate的地址
    0x100001af8 <+40>:  movq   -0x8(%rbp), %rsi     ; $rbp-0x8里存放self对象的起始地址
    0x100001afc <+44>:  movq   0x21ad(%rip), %rdi   ; $rip-0x21ad里存放_delegate相对于self的偏移
    0x100001b03 <+51>:  movq   %rdx, (%rsi,%rdi)    ; $rsi+rdi = $rdx => _delegate = delegate

这四句代码印证了我们的推断,对于一个标记为assign的成员变量来说,setter 就是直接进行指针拷贝。那么我们再来看看如果 delegate 是weak的时候是什么样子:

debug-objc`::-[MCAssignToWeak setDelegate:](id):
    ...
    0x100001a74 <+36>:  movq   -0x18(%rbp), %rsi    ; delegate
    0x100001a78 <+40>:  movq   -0x8(%rbp), %rdx     ; self
    0x100001a7c <+44>:  movq   0x2235(%rip), %rdi   ; offset
    0x100001a83 <+51>:  addq   %rdi, %rdx           ; $rdx = self + offset
    0x100001a86 <+54>:  movq   %rdx, %rdi           ; $rdi = $rdx
    0x100001a89 <+57>:  callq  0x100002952          ; symbol stub for: objc_storeWeak
    ...

assign的汇编差不多,唯一不同的是assign的时候,直接进行了指针拷贝,而weak则调用了 objc_storeWeak 方法去拷贝指针。这是因为对于弱引用对象,赋值的时候需要首先在 runtime 全局维护的一张弱引用表中更新记录,维持正确的引用关系,最后才会进行指针拷贝,这一系列操作都要加锁保证线程安全,所以它的代码看起来很长很复杂。objc_storeWeak 也可以在源代码中找到,我们忽略那些对我们完成目标没有直接关系的代码,直接看指针拷贝的那段代码即可:

template <HaveOld haveOld, HaveNew haveNew, CrashIfDeallocating crashIfDeallocating>
static id 
storeWeak(id *location, objc_object *newObj) {
    ...
    // Assign new value, if any.
    if (haveNew) {
        newObj = (objc_object *)
            weak_register_no_lock(&newTable->weak_table, (id)newObj, location, 
                                  crashIfDeallocating);
        // weak_register_no_lock returns nil if weak store should be rejected

        // Set is-weakly-referenced bit in refcount table.
        if (newObj  &&  !newObj->isTaggedPointer()) {
            newObj->setWeaklyReferenced_nolock();
        }

        // Do not set *location anywhere else. That would introduce a race.
        *location = (id)newObj;
    }
    ...
}

通过第 18 行我们最终确认,在更新弱引用记录表后,最后和assign一样也会进行指针拷贝。我们可以由此得出推论,对于任意一个 setter,我们都可以通过替换它的 setter 方法来完成对 Ivar 变量的内存管理方式的修改。幸运的是,runtime 将 objc_storeWeak 方法公开了出来, 我们只要替换原有 setter 后,先调用 objc_storeWeak 方法,再调用原 setter 实现(先后顺序不能颠倒,因为 objc_storeWeak 会检查当前 Ivar 指针是否已经与传入的指针相等),即可将 setter 变为一个可以操作weak变量的方法。同理,getter 也可以通过方法替换的方式来完成对 objc_loadWeak 的调用。


第二次尝试

到了这里,我们已经完全搞清楚了 oc 是如何管理assignweak对象的了,如果你有兴趣也可以去自己尝试破解strong的实现机制,原理一样。接下来我们决定开始对 MCAssignToWeak 进行第二次修改的尝试,这一次,我们需要加入对 delegate 属性的 setter 和 getter 的替换,使之调用正确的方法存取成员变量。

@implementation MCAssignToWeak (fixup)
+ (void)load {...}
- (void)fixup_setDelegate:(id)delegate {
    Ivar ivar = class_getInstanceVariable([self class], "_delegate");
    object_setIvar(self, ivar, delegate);
    [self fixup_setDelegate:delegate]; // 最后调用原实现
}
- (id)fixup_delegate {
    id del = [self fixup_delegate];
    del = objc_loadWeak(&del);
    return del;
}
@end

我们之所以在 fixup_setDelegate: 方法里,调用了 object_setIvar 而不是 objc_storeWeak 方法来设置弱引用到 _delegate,是因为 object_setIvar 里面需要先获取 Ivar 的 offset,然后将加上了偏移后的地址传入到 objc_storeWeak方法,同时 object_setIvar 还可以根据内存修饰符来调用与之相符的内存管理方法,这样写不仅能适应我们当前的assignweak的需要,还可以满足以后其他类型之间互转的需要:

static ALWAYS_INLINE 
void _object_setIvar(id obj, Ivar ivar, id value, bool assumeStrong)
{
    if (!obj  ||  !ivar  ||  obj->isTaggedPointer()) return;

    ptrdiff_t offset;
    objc_ivar_memory_management_t memoryManagement;
    _class_lookUpIvar(obj->ISA(), ivar, offset, memoryManagement);

    if (memoryManagement == objc_ivar_memoryUnknown) {
        if (assumeStrong) memoryManagement = objc_ivar_memoryStrong;
        else memoryManagement = objc_ivar_memoryUnretained;
    }

    id *location = (id *)((char *)obj + offset);

    switch (memoryManagement) {
    case objc_ivar_memoryWeak:       objc_storeWeak(location, value); break;
    case objc_ivar_memoryStrong:     objc_storeStrong(location, value); break;
    case objc_ivar_memoryUnretained: *location = value; break;
    case objc_ivar_memoryUnknown:    _objc_fatal("impossible");
    }
}

同理 fixup_delegate 也可以使用object_getIvar 方法来获取 Ivar,这里我们先简单调用 objc_loadWeak。看到这里,你可能会问,如果 setter 和 getter 被重写,对应的并不是与 property 同名的 Ivar,那怎么办呢?遇到这种情况需要通过解析汇编代码确定 setter 和 getter 操作的内存地址,然后利用 runtime 方法获取目标类所有的 Ivar 信息比对即可得知 Ivar 的名称。

现在我们修改一下之前的 _fixupAssignDelegate方法,在方法的最后增加代码:

static void _fixupSelector(Class cls, SEL origSel, SEL fixSel) {
    Method setter = class_getInstanceMethod(cls, origSel);
    Method fixSetter = class_getInstanceMethod(cls, fixSel);
    BOOL success = class_addMethod(cls, origSel,
                                   method_getImplementation(fixSetter),
                                   method_getTypeEncoding(fixSetter));
    if (success) {
        class_replaceMethod(cls, fixSel,
                            method_getImplementation(setter),
                            method_getTypeEncoding(setter));
    } else {
        method_exchangeImplementations(setter, fixSetter);
    }
}
static void _fixupAssginDelegate(Class class) {
    ...
    // swizzling setter finally
    _fixupSelector(origCls, @selector(setDelegate:), @selector(fixup_setDelegate:));
    _fixupSelector(origCls, @selector(delegate), @selector(fixup_delegate));
}

重新运行我们的 demo,当 delegate 定义为assign的时候, 我们通过 log 可以观察到,delegate对象在第二次调用 Notify 前已经被正确置为 nil:

2017-07-21 19:16:31.157609+0800 demo[38605:16165704] ===== notify NSObject
2017-07-21 19:16:31.157691+0800 demo[38605:16165704] ===== notify (null)

通过代码生成 Ivar Layout

到了这里,我们已经非常地接近目标了,能够通过修改内存修饰符在运行时改变成员变量的内存管理方式。但是在上面的例子里,对 IvarLayout 和 WeakIvarLayout的重新赋值都是需要我们提前计算好并且 hardcode 到代码里面的。如果需要修改的目标类发生了变化,或者在不同的版本上成员变量的数量和内存修饰符不一样,例如添加了新的成员变量、或是简单地调整了成员变量的定义顺序,就会导致代码里 hardcode 的 layout 值失效需要重新计算。为了避免频繁改动代码,我们的方案应当更智能更自动化,通过代码自动生成的方式来确定 Ivar Layout。

class_ro_t里面 IvarLayout 和 weakIvarLayout 通常是在编译时生成的,如果在运行时将一个变量的内存 Layout 变更,可能需要同时更新 ivarLayout 和 weakIvarLayout 的值。我们在上面的章节说过,Ivar Layout 为了节省内存占用对内存修饰符进行了压缩,所以我们在修改前,需要先将它还原成非压缩的格式,修改完成后再压缩回 Ivar Layout。我们设计了一个简单的 char 数组 ivarInfos,用来表示每个成员变量的内存类型,其长度与成员变量的总数相当,数组的每一个 char 与 ivar_list 里面每一个成员变量一一对应,它有 3 个可能的值('S'、'W'、'A'),分别对应着strongweak、以及_unsafe_unretained类型。我们通过遍历 ivarLayout 和 weakIvarLayout 来重建 Layout 信息,重建逻辑与 runtime 中 isScanned 方法的逻辑一样,结合我们上面的章节所讲的 Ivar Layout 的编码细节,我们首先找到需要修改的成员变量在 ivar_list 中的位置:

uint32_t ivarPos = 0;
for (_mcc_ivar_list_t::iterator it = ivarList->begin(); it != ivarList->end(); ++it, ++ivarPos) {
    if (it->name  &&  0 == strcmp("_delegate", it->name)) {
        ivar = &*it; break;
    }
}

然后通过调用 _constructIvarInfos 函数来重建 Layout 信息:

static void _inferLayoutInfo(const uint8_t *layout, char *ivar_info, char type) {
    if (!layout || !ivar_info) {
        return;
    }
    ptrdiff_t index = 0; uint8_t byte;
    while ((byte = *layout++)) {
        unsigned skips = (byte >> 4);
        unsigned scans = (byte & 0x0F);
        index += skips;
        for (ptrdiff_t i = index; i < index+scans; ++i) {
            *(ivar_info+i) = type;
        }
        index = index+scans;
    }
}
static char *_constructIvarInfos(Class cls, _mcc_ivar_list_t *ivar_list) {
    if (!cls || !ivar_list) {
        return NULL;
    }
    uint32_t ivarCount = ivar_list->count;
    char *ivarInfo = (char *)calloc(ivarCount+1, sizeof(char));
    memset(ivarInfo, 'A', ivarCount);
    const uint8_t *ivarLayout = class_getIvarLayout(cls);
    _inferLayoutInfo(ivarLayout, ivarInfo, 'S');
    const uint8_t *weakLayout = class_getWeakIvarLayout(cls);
    _inferLayoutInfo(weakLayout, ivarInfo, 'W');
    return ivarInfo;
}

重建后的 ivarInfo 列表,对 ivar_list 中每一个成员变量的内存属性进行了标注。这样可以直接修改 ivarInfo 列表,将成员变量的内存属性从一种类型变更为另一种类型,修改完成后,调用 _fixupIvarLayout 方法重新创建 ivarLayout 和 weakIvarLayout,这是 _inferLayoutInfo 方法的逆向逻辑。因为 _fixupIvarLayout 代码逻辑比较复杂,就不在这里贴出来了,如果有兴趣可以直接查看demo的源代码


写在最后

到了这里,方案3已经初具雏形。我们基于此解决了 8.x 系统上 UIScrollView 的 delegate 属性被声明为assign所带来的崩溃。 虽然它看起来很简单佷暴力,既不像方案1那样需要开发者在业务代码里添加或修改任何代码,也不像方案2那样需要对 dealloc 方法做全局 hook 会带来其他的风险,但和任何方案一样,方案3也受到一些先决条件的限制:

  • 修改必须要在 runtime 初始化完成之后立即执行,一旦app已经开始创建你需要修改的类的对象后,再修改 Ivar Layout 会造成不可预知的后果。与 method swizzling 的推荐做法一样,在 + (void) load 方法里面执行是最稳妥最简单的。
  • 修改前必须要知道所修改的变量名。这个看似简单的前提条件,在实际操作中通常会耗费一些时间才能得到。以 UITableView 为例,它从 UIScrollView 继承而来,在 8.x 系统上都有一个名为@property (nonatomic, assign) id delegate的属性,但是仔细分析 UITableView 的变量列表发现其实它并没有定义与 delegate 对应的_delegate,而是它的父类 UIScrollView 有一个名为 _delegate 的变量。那么实际修改的对象从 UITableView 变成了 UIScrollView。由于 property 定义的多样性以及 setter 和 getter 实现的灵活性,导致寻找到正确的 Ivar Name 在有些特殊场景下变成了一个比较费时费力的操作。

虽然存在着上述这些局限性,方案3相比其它两种方案,依然有着不可忽视的优势:

成员变量的内存管理方式可以在编译确定后重新定义

这一点为各种热修复方案提供了巨大的操作空间,例如一个不慎被程序员指定错误的内存管理方式,可以在运行时被重新修复,不需要重新发版。至于其他可能的应用场景,还需要靠我们天马行空的想象力一起来发掘。

最后可能你会疑问,property 的 type encodings,有一个 'W' 的类型标识来表明这个属性是不是weak的,我们既然修改了成员变量的内存管理方式,从assign变成了weak,那我们是否需要添加这个标识到 UIScrollView 和 UITableView 的 delegate 呢?这个问题就作为本文的习题留给大家自己思考吧,如果有疑问请联系我:dechaos@163.com

(完)


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

推荐阅读更多精彩内容