為什么要這么做?
在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 的名字前面加下劃線的方式命名),并為之創建默認的getter
和setter
。這極大的簡化了代碼,避免像 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 是什么,以便編譯器能夠正確生成getter
和setter
。當然還有另外一種@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 的內部,我們終于到達了此次探尋的目的地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,它包含如下成員:
- 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, 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 是如何管理assign
和weak
對象的了,如果你有興趣也可以去自己嘗試破解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 還可以根據內存修飾符來調用與之相符的內存管理方法,這樣寫不僅能適應我們當前的assign
到weak
的需要,還可以滿足以后其他類型之間互轉的需要:
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'),分別對應著strong
、weak
、以及_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
(完)