ObjC如何通過runtime修改Ivar的內存管理方式

為什么要這么做?

在iOS 9之前悠抹,UITableView(或者更確切的說是 UIScrollView)有一個眾所周知的問題:

property (nonatomic, assign) id delegate;

蘋果將 delegate 的內存修飾符聲明為了assign珠月,這是 MRC 時代防止循環(huán)引用的不二法門。但是到了 ARC 時代楔敌,蘋果引入了弱引用修飾符(weak)對原先的(assign)暨非強引用修飾符進行了細分啤挎。在大多數場景下,將 delegate 聲明為assign并不會產生什么嚴重后果卵凑,因為 delegate 對象(例如 UIViewController)通常持有了這個 UIScrollView庆聘,當 delegate 對象釋放的時候,UIScrollView 也會被一起釋放勺卢。

然而只要存在發(fā)生意外的風險伙判,意外就一定會發(fā)生。如果在 delegate 對象釋放的時候黑忱,UIScrollView 因為某些原因正在被其他對象強持有而導致沒有被一起釋放宴抚,那么當 UIScrollView 在之后調用 delegate 方法的時候就會崩潰,因為這個時候 delegate 已經是一個野指針了甫煞。最常見的導致 UIScrollView 沒有被及時釋放的原因是滾動所帶來的動畫菇曲,因為系統(tǒng)在渲染動畫的時候需要強持有這個 view,而 UIScrollView 這種天生內置動畫效果的類就變成了受到這個 assign 修飾符影響最廣泛的類危虱。

因為國內用戶對iOS系統(tǒng)的更新并不像國外那樣普遍羊娃,至今仍然有大量手機運行著iOS 7.x和8.x唐全,很多app也因此一直保持著對iOS 7.x和8.x系統(tǒng)的支持埃跷,所以這個問題在iOS 11都即將到來的時代仍然持續(xù)不斷地困擾著眾多的iOS開發(fā)者。

第一個非常流行的解決方案:

在 delegate 對象的 dealloc 方法里將 UIScrollView 的 delegate 屬性置空邮利。

這個看似簡單的解決辦法卻也帶來了兩個額外的問題弥雹,一是只能對有源代碼的類進行修改,那些沒有源代碼的第三方庫是沒有辦法進行修復的延届。二是就算是自己寫的類剪勿,人都會犯錯或疏忽大意,忘記在 dealloc 里面將 delegate 置空會導致這個問題依然還會時不時的出現方庭。在后面的文章為了簡明起見厕吉,我們將這種方法稱之為方案1酱固。

那有沒有辦法解決上面提到的這兩個問題呢?答案是肯定的头朱≡吮可能已經有人想到用 oc runtime 的方法替換的去做了。

替換 NSObject 的 dealloc 方法和 UIScrollView 的setDelegate:方法

具體方法在這里就不展開細說了项钮,大家有興趣可以參考這里班眯。在后面的文章為了簡明起見,我們將這種方法稱之為方案2烁巫。


我們?yōu)槭裁催€要繼續(xù)署隘?

提出方案2的時候,這個關于 UIScrollView 崩潰的問題已經比較完美地被解決了亚隙。剩下的無非比較權衡方案2的各種實現之間的優(yōu)劣而已磁餐,那我們?yōu)槭裁催€要繼續(xù)呢?

我在最開始在崩潰日志上看到 UIScrollView 的崩潰的時候阿弃,經過 google 和 stackoverflow 大法搞明白崩潰的原因之后崖媚,跳入我腦中的完美解決方案,即不是方案1也不是方案2恤浪,而是:

如何將一個已經在編譯時確定為__unsafe_unretained的成員變量在運行時重新聲明為__weak畅哑?

我們姑且稱之為方案3。事情往往沒有那么簡單水由,在這條直接粗暴看似捷徑的小路上荠呐,其實荊棘遍地步履維艱。方案3需要對 objective c 有著深入的理解和認知砂客,所需要的邏輯和方法也遠比方案1方案2晦澀難懂泥张。如果你只想解決UIScrollView 在ios 9之前因為 delegate 被聲明為assign所導致的崩潰的話,那么無論方案1或者方案2都是非常簡單有效的解決方案鞠值,直接套用即可媚创。如果你和我一樣,想順便探索一下 objective c 的秘密的話彤恶,我邀請你和我一起繼續(xù)前行钞钙。


成員變量 Ivar 及內存修飾符

既然問題的癥結在于成員變量 Ivar 在編譯時所使用的修飾符是錯誤的,那 Ivar 以及它的修飾符到底是什么呢声离?

如果你熟悉oc的源碼芒炼,你可能很清楚的知道 Ivar 與屬性(property)的不同。我們現在寫代碼所使用的通常都是使用 property 來間接定義 Ivar 术徊。當前的 XCode 已經很少需要在聲明 property 的時候同時聲明 Ivar 本刽,大部分場景下編譯器會自動聲明對應的 Ivar(使用 property 的名字前面加下劃線的方式命名),并為之創(chuàng)建默認的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的聲明蝙寨,這個就超出了此篇的討論范圍晒衩,就不在這里延展了。

細心的你可能已經發(fā)現了墙歪,成員變量 _vc 所使用的修飾符是__weak听系。這就是 Ivar 在 ARC 上使用的內存修飾符,將一個 Ivar 聲明為弱引用對象虹菲】渴ぃ可以聲明的值包括__strong(默認), __weak以及__unsafe_unretained。這個和 property 所支持的修飾符是一致的毕源。如果是編譯器根據 property 自動生成的 Ivar 浪漠,編譯器會根據 property 的修飾符推斷出 Ivar 所需要的內存修飾符。

到了這里霎褐,我們已經知道址愿,成員變量的內存修飾符,可以單獨指定冻璃,也可以跟隨 property 自動指定械哟。內存修飾符決定著 Ivar 在運行時所使用的內存管理模式搏明。不幸的是卵蛉,這是在編譯時就已經確定的(也就是我們通常所說的編譯時決議)回铛,oc的runtime 并沒有提供給我們在運行時動態(tài)變更一個 Ivar 內存修飾符的方法。

那怎么辦呢跋炕?這個時候只好寄希望于oc runtime的源代碼能給我們指一條明路了赖晶。


探尋 Ivar 的內存修飾符

我們的目標是要深入到 object_class 類的源碼里面挖掘關于成員變量 Ivar 的所有實現細節(jié),通過這些細節(jié)找到運行時修改的方法辐烂。如果對于 oc 中類和對象的結構你并不了解遏插,請先移步仔細閱讀 Draveness 大神的這兩篇神作,這對你建立一個微觀的 oc 世界觀有著極為重要的啟發(fā)作用:

從 NSObject 的初始化了解 isa
深入解析 ObjC 中方法的結構

如上圖所示棉圈,經過抽絲剝繭一層一層地深入到 NSObject 的內部涩堤,我們終于到達了此次探尋的目的地class_ro_t。這個結構顧名思義分瘾,它存放著所有在編譯階段就已經確定的成員變量列表、屬性列表以及方法列表、協(xié)議等等只讀信息德召。而運行時可以修改的數據都存放在它的持有者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又是什么呢滚朵?我們繼續(xù)在源代碼里尋找它的定義:

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的秘密

到了這里匿垄,我們發(fā)現ivar_t里并沒有存儲 Ivar 的內存管理的信息亏推。我們返回class_ro_t繼續(xù)研究,這一次 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位代表連續(xù)是非storng類型 Ivar 的數量(m)疯趟,m ∈ [0x0, 0xf],低4位代表連續(xù)是strong類型 Ivar 的數量(n)谋梭,n ∈ [0x0, 0xf]信峻。

對于 weakIvarLayout 來說,每個uint8_t的高4位代表連續(xù)是非weak類型 Ivar 的數量(m)章蚣,m ∈ [0x0, 0xf]站欺,低4位代表連續(xù)是weak類型 Ivar 的數量(n),n ∈ [0x0, 0xf]纤垂。

無論是 ivarLayout 還是 weakIvarLayout矾策,結尾都需要填充 \x00 結尾

看到這里,可能你會問峭沦,如果連續(xù)存在相同類型超過 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 個字節(jié)眶蕉,weakIvarLayout 只用了 3+1=4 個字節(jié)就描述了這38個成員變量的內存修飾符,節(jié)約了80%以上的內存占用唧躲,這其實可以看作是一種非常簡單高效的壓縮算法造挽。

現在我們知道了class_ro_t如何通過 ivarLayout 和 weakIvarLayout 來描述類中每個成員變量的內存修飾符碱璃,我們離我們的最終目標——動態(tài)修改內存修飾符又近了一步。


是否能夠在運行時修改 Ivar Layout刽宪?

雖然我們已經破譯了 oc runtime 如何存儲變量的內存修飾符的秘密厘贼,但是我們是否能夠在運行時通過修改 Ivar Layout 的方式來改變變量的內存管理方式呢界酒?例如 assgin 變?yōu)?weak 圣拄?仔細推敲Objective-C Class Ivar Layout 探索的細節(jié)后,我們不難得出一個簡單直接的辦法——調用 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 判斷失敗寞肖。我們只好繼續(xù)在源代碼里搜索使用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的標志位衰腌,而這意味著只有在運行時由開發(fā)者動態(tài)添加的類在 objc_registerClassPair 調用之前才能修改 Ivar Layout新蟆,一旦調用了 objc_registerClassPair 就意味著這個類已經對修改關閉,不再接受任何對 Ivar 的修改了右蕊,而那些編譯時就已確定的類根本就沒有任何機會修改 Ivar Layout琼稻。回想Objective-C Class Ivar Layout 探索里饶囚,大神需要解決的問題確實是如何為一個動態(tài)添加的類添加 weak 屬性的 Ivar帕翻,和我們所處的場景不一樣。難道我們探索了這么久最終還是走進了一條根本行不通的死胡同萝风?

幸虧我們有 runtime 的源代碼嘀掸,讓我們知道這個標志位的定義以及作用。我們嘗試在調用 class_setIvarLayout 之前规惰,將這個類的 flags 加上RW_CONSTRUCTING標志睬塌,調用完成后再重置。因為設置 flags 需要使用到 runtime 源碼內關于 object_class卿拴、class_data_bits_t 以及 class_rw_t 的結構體定義衫仑,于是我們偷懶地在大神的代碼基礎上進行再加工,那些我們暫時還不需要知道細節(jié)的指針一律使用了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變?yōu)閣eak堕花,需要將weakIvarLayout從\x21修改為\x12
    uint8_t *weakIvarLayout = (uint8_t *)calloc(3, 1);
    *weakIvarLayout = 0x21; *(weakIvarLayout+1) = 0x12;
    class_setWeakIvarLayout(class, weakIvarLayout);
    // 完成后清除標志位
    objcRWClass->flags &= ~RW_CONSTRUCTING;
}

一次失敗的嘗試

既然我們已經有了如何修復的假設文狱,接下來就需要驗證我們的假設是不是正確的。這段代碼應該放在哪里執(zhí)行呢缘挽?我們知道 runtime 在啟動的時候會依次調用所有類以及所有分類的+ (void)load方法瞄崇,我們?yōu)榱苏故?UIScrollView 這種沒有源碼的系統(tǒng)類應該如何進行修改呻粹,特意為 MCAssignToWeak 創(chuàng)建了一個新的分類 fixup,然后在這個分類重寫+ (void)load方法:

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

為了驗證我們的代碼是否真的將delegate對象從assign變?yōu)榱?code>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)

但事與愿違摹蘑,執(zhí)行程序后崩潰在第二個 notifyDelegate 處筹燕,看起來 delegate 對象依然是個野指針。這是為什么呢衅鹿?仔細推敲assign或著說_unsafe_unretained的實現原理撒踪,這個修飾符會在編譯時告訴編譯器賦值和取值的時候,不需要運行時做任何內存管理大渤,直接操作內存地址即可制妄,這些操作可以直接在編譯時確定,無需再依賴運行時泵三。所以編譯器插入的 setter 里面耕捞,對_delegate = delegate會直接轉化為指針拷貝( getter 同理),這樣就算我們在運行時動態(tài)修改了 _delegate 的 layout 也無濟于事烫幕,因為代碼早就確定了俺抽。難道我們又走進了死胡同嗎?


繼續(xù)深入

既然編譯器生成的 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翠桦。我們這里就以模擬器來做為分析樣本了横蜒。

我們?yōu)榱斯?jié)省篇幅,省略了獲取 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 變?yōu)橐粋€可以操作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 到代碼里面的定鸟。如果需要修改的目標類發(fā)生了變化,或者在不同的版本上成員變量的數量和內存修飾符不一樣著瓶,例如添加了新的成員變量联予、或是簡單地調整了成員變量的定義順序,就會導致代碼里 hardcode 的 layout 值失效需要重新計算材原。為了避免頻繁改動代碼沸久,我們的方案應當更智能更自動化,通過代碼自動生成的方式來確定 Ivar Layout华糖。

class_ro_t里面 IvarLayout 和 weakIvarLayout 通常是在編譯時生成的麦向,如果在運行時將一個變量的內存 Layout 變更,可能需要同時更新 ivarLayout 和 weakIvarLayout 的值客叉。我們在上面的章節(jié)說過诵竭,Ivar Layout 為了節(jié)省內存占用對內存修飾符進行了壓縮,所以我們在修改前兼搏,需要先將它還原成非壓縮的格式卵慰,修改完成后再壓縮回 Ivar Layout。我們設計了一個簡單的 char 數組 ivarInfos佛呻,用來表示每個成員變量的內存類型裳朋,其長度與成員變量的總數相當,數組的每一個 char 與 ivar_list 里面每一個成員變量一一對應吓著,它有 3 個可能的值('S'鲤嫡、'W'送挑、'A'),分別對應著strong暖眼、weak惕耕、以及_unsafe_unretained類型。我們通過遍歷 ivarLayout 和 weakIvarLayout 來重建 Layout 信息诫肠,重建邏輯與 runtime 中 isScanned 方法的邏輯一樣司澎,結合我們上面的章節(jié)所講的 Ivar Layout 的編碼細節(jié),我們首先找到需要修改的成員變量在 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 方法重新創(chuàng)建 ivarLayout 和 weakIvarLayout,這是 _inferLayoutInfo 方法的逆向邏輯骡送。因為 _fixupIvarLayout 代碼邏輯比較復雜昂羡,就不在這里貼出來了,如果有興趣可以直接查看demo的源代碼摔踱。


寫在最后

到了這里,方案3已經初具雛形怨愤。我們基于此解決了 8.x 系統(tǒng)上 UIScrollView 的 delegate 屬性被聲明為assign所帶來的崩潰派敷。 雖然它看起來很簡單佷暴力,既不像方案1那樣需要開發(fā)者在業(yè)務代碼里添加或修改任何代碼撰洗,也不像方案2那樣需要對 dealloc 方法做全局 hook 會帶來其他的風險篮愉,但和任何方案一樣,方案3也受到一些先決條件的限制:

  • 修改必須要在 runtime 初始化完成之后立即執(zhí)行差导,一旦app已經開始創(chuàng)建你需要修改的類的對象后试躏,再修改 Ivar Layout 會造成不可預知的后果。與 method swizzling 的推薦做法一樣设褐,在 + (void) load 方法里面執(zhí)行是最穩(wěn)妥最簡單的颠蕴。
  • 修改前必須要知道所修改的變量名。這個看似簡單的前提條件助析,在實際操作中通常會耗費一些時間才能得到犀被。以 UITableView 為例,它從 UIScrollView 繼承而來外冀,在 8.x 系統(tǒng)上都有一個名為@property (nonatomic, assign) id delegate的屬性寡键,但是仔細分析 UITableView 的變量列表發(fā)現其實它并沒有定義與 delegate 對應的_delegate,而是它的父類 UIScrollView 有一個名為 _delegate 的變量雪隧。那么實際修改的對象從 UITableView 變成了 UIScrollView西轩。由于 property 定義的多樣性以及 setter 和 getter 實現的靈活性员舵,導致尋找到正確的 Ivar Name 在有些特殊場景下變成了一個比較費時費力的操作。

雖然存在著上述這些局限性藕畔,方案3相比其它兩種方案固灵,依然有著不可忽視的優(yōu)勢:

成員變量的內存管理方式可以在編譯確定后重新定義

這一點為各種熱修復方案提供了巨大的操作空間,例如一個不慎被程序員指定錯誤的內存管理方式巫玻,可以在運行時被重新修復,不需要重新發(fā)版仍秤。至于其他可能的應用場景,還需要靠我們天馬行空的想象力一起來發(fā)掘可很。

最后可能你會疑問诗力,property 的 type encodings,有一個 'W' 的類型標識來表明這個屬性是不是weak的我抠,我們既然修改了成員變量的內存管理方式苇本,從assign變成了weak,那我們是否需要添加這個標識到 UIScrollView 和 UITableView 的 delegate 呢菜拓?這個問題就作為本文的習題留給大家自己思考吧瓣窄,如果有疑問請聯(lián)系我:dechaos@163.com

(完)


最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市纳鼎,隨后出現的幾起案子俺夕,更是在濱河造成了極大的恐慌,老刑警劉巖贱鄙,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件劝贸,死亡現場離奇詭異,居然都是意外死亡逗宁,警方通過查閱死者的電腦和手機映九,發(fā)現死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來瞎颗,“玉大人件甥,你說我怎么就攤上這事⊙早停” “怎么了嚼蚀?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長管挟。 經常有香客問我轿曙,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任导帝,我火速辦了婚禮守谓,結果婚禮上,老公的妹妹穿的比我還像新娘您单。我一直安慰自己,他們只是感情好平酿,可當我...
    茶點故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布悦陋。 她就那樣靜靜地躺著俺驶,像睡著了一般暮现。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上拍顷,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天菇怀,我揣著相機與錄音,去河邊找鬼匆背。 笑死钝尸,一個胖子當著我的面吹牛珍促,可吹牛的內容都是我干的剩愧。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼芒帕!你這毒婦竟也來了背蟆?” 一聲冷哼從身側響起带膀,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤本砰,失蹤者是張志新(化名)和其女友劉穎点额,沒想到半個月后还棱,有當地人在樹林里發(fā)現了一具尸體珍手,經...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡寡具,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年童叠,在試婚紗的時候發(fā)現自己被綠了厦坛。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片杜秸。...
    茶點故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡撬碟,死狀恐怖小作,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情达罗,我是刑警寧澤粮揉,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布扶认,位于F島的核電站辐宾,受9級特大地震影響叠纹,放射性物質發(fā)生泄漏誉察。R本人自食惡果不足惜持偏,卻給世界環(huán)境...
    茶點故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一鸿秆、第九天 我趴在偏房一處隱蔽的房頂上張望谬莹。 院中可真熱鬧,春花似錦井誉、人聲如沸颗圣。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽抽莱。三九已至食铐,卻和暖如春虐呻,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背斟叼。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工犁柜, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人萤悴。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像费薄,于是被迫代替她去往敵國和親伟众。 傳聞我的和親對象是個殘疾皇子凳厢,可洞房花燭夜當晚...
    茶點故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內容