標籤:
文章有點長,寫的過程很有收穫,但讀的過程不一定有收穫,慎入
【摘要】 懸垂指標(dangling pointer)引起的crash問題,是我們在iOS開發過程當中經常會遇到的。其中由delegate引發的此類問題更是常見。本文由一個UIActionSheet引發的delegate懸垂指標問題開始,逐步思索和嘗試解決這類問題的幾種方案並進行比較。 【本文】 UIActionSheet是一個常用的iOS系統控制項,用法很簡單,實現UIActionDelegate協議方法,然後通過showInView:等方法彈出。我們來看一段代碼:(如無特殊說明,本文中的代碼均在ARC條件下書寫)
- (void)popUpActionSheet{ UIActionSheet* sheet = [[UIActionSheet alloc] initWithTitle:nil delegate:self cancelButtonTitle:NSLocalizedString(@"str_cancel", @"") destructiveButtonTitle:NSLocalizedString(@"str_delete", @"") otherButtonTitles:nil]; [sheet showInView:self.view];}
像這樣用一個局部變數彈出actionsheet的代碼喜聞樂見。 那麼這樣做是否有問題呢? 來看某項目中的一個bug:頁面X,按住地區Y點擊按鈕B,再點擊按鈕C(關閉),鬆開後頁面X退出,但actionsheet A彈出,點擊其中的按鈕,程式crash。 從描述中不難看出問題所在:這是個dangling pointer的問題。點擊按鈕B,本應彈出actionsheet A,但由於某些特殊操作(具體原因各不相同,這裡是按住地區Y),這個actionsheet的彈出被延遲了,當它彈出的時候,其delegate(通常是一個UIViewController或者一個UIView,這裡是頁面的ViewController X)已經被銷毀了,於是delegate成了一個dangling pointer,點擊按鈕,向delegate發送訊息的時候,就出現了crash。 為了防止retain cycle,iOS中大部分的delegate都是不增加對象(X)的引用計數的(弱引用),因而容易出現dangling pointer的問題。對於此類問題,解決方向通常有兩個:其一,在向delegate發送訊息之前,判斷delegate是否仍然有效;其二,使對象X在dealloc的時候,主動設定所有指向X的delegate為nil。 對於方向一,看上去很美,如果能夠在發訊息前判斷一個指標是否是dangling pointer,那麼我們就有了最後一道防線,從此再不會發生此類crash問題。但是,當dangling pointer真出現的時候,我們更應反思一下代碼設計上是否出現了不合理的地方,而不是簡單以這種方式捕獲並丟棄。 相比之下,方向二“銷毀時置空”這個方案顯得更治本,亦是一種良好的編程習慣。推而廣之,不局限於delegate,所有弱引用指標都可以如此處理。 這正是ARC中引入的weak指標的概念,它會在所指對象dealloc的時候自動置為nil。也就是說,只要所有delegate都是weak類型的,此類dangling pointer問題就不複存在了。本文也可以到此結束了。 但是,現實總是殘酷的。首先,weak指標只有在iOS 5.0及以上的版本中的ARC條件下才能使用,而目前很多項目依然需要支援iOS4.3。當然,隨著iOS7的發布,這種情況會有所好轉。但即使所有的使用者都是5.0+,問題仍然沒有解決。為何? 我們自訂的delegate,可以全部採用weak類型。但是系統控制項是什麼情況呢?比如UIActionSheet,看看iOS7.0版本SDK下的UIActionSheet.h:
@property(nonatomic,assign) id delegate; // weak reference
即使是7.0,這些系統控制項的delegate仍然不是weak,而是assign,大約是為了相容非ARC環境的原因吧。 也就是說,weak指標並不能解決系統控制項delegate的dangling pointer問題。這下腫麼辦? 花開兩朵,各表一支。 我們先回過頭來看另外一個問題:為什麼actionsheet會出現這個dangling pointer的問題? 直接原因是作為delegate的ViewController X被銷毀了,而此時actionsheet A本身還在顯示。但這個A明明是show在self.view上的,為什麼self.view都沒了,它還會存在呢? 我們來看下面一段代碼:
-(void)viewDidAppear:(BOOL)animated{ UIActionSheet *sheet = [[UIActionSheet alloc] initWithTitle:@"abcde" delegate:self cancelButtonTitle:@"cancel" destructiveButtonTitle:nil otherButtonTitles:nil]; NSLog(@"application windows:%@", [UIApplication sharedApplication].windows); [sheet showInView:self.view]; NSLog(@"self.window:%@", self.view.window); NSLog(@"sheet.window:%@", sheet.window); NSLog(@"application windows:%@", [UIApplication sharedApplication].windows);}
主要運行結果(為iphone上的,情況在iPad上略有不同)如下:
application windows:( UIWindow ...) // actionsheet彈出前,只有1個UIWindowself.window: UIWindow ...sheet.window: _UIAlertOverlayWindow ...application windows:( UIWindow ... UITextEffectsWindow ... _UIAlertOverlayWindow ... ) // actionsheet彈出後,有3個UIWindow
原來iOS的application並非只有一個window,actionsheet是彈在另外一個內部的window上的(iPad上情況不同,只有一個window,actionsheet是在showInView的superview的一個叫UIDimmingView的subview上),與showInView:方法中指定的view並沒有持有的關係,所以能在完全不依賴於後者生命週期的情況下存在,於是出現dangling pointer delegate一點也不奇怪了。 那麼知道了來龍去脈以後,我們可以開始著手解決文章開始時的那個bug了。按照“在一個對象X dealloc的時候,設定所有指向X的delegate為空白”這個“方向二”的中心思想,weak指標是派不上用場了,我們只能另想辦法。 通過分析,我們知道落實這個“中心思想”的要點就是: 怎樣在X dealloc的時候擷取到所有delegate指向X的actionsheet A? 由於文章開始時喜聞樂見的代碼中,actionsheet是局部變數彈出的,在ViewController X dealloc的時候,我們已經訪問不到那個局部變數,怎麼辦呢? 思路1:
改用一個執行個體變數V儲存actionsheet。在X的dealloc方法裡置V.delegate = nil。 這毫無疑問是最容易想到的方法,無須贅述。只是要注意一個問題:actionsheet是可以同時(或相繼)彈出多個的(我們會看到背景的黑色蒙板隨著彈出actionsheet的數量而疊加,越來越深。)這樣一來,我們要麼改用一個數組來儲存actionsheet的指標(們),要麼就要在每彈出一個新的時候,就把舊的處理掉(或者delegate置空,或者乾脆dismiss掉)。 這種思路,優點有二: 一、思路簡單,代碼添加量少; 二、如果你是在寫一個iPad app,那反正應付轉屏重新布局actionsheet也是需要這個執行個體變數的,一舉多得。
其缺點也有二: 一、這種方式通用性差,我們需要針對每一個這樣的X都寫一遍這樣的代碼,如果這是一個已經存在的項目,而這個項目裡幾乎所有的actionsheet都是這樣用局部變數彈出的,怎麼辦?我們需要修改多少代碼? 二、actionsheet作為一個系統控制項,ViewController多數情況下只是控制彈出和實現delegate方法,並不做其他任何操作,這也就是為什麼會出現前述喜聞樂見的代碼,因為其他地方用不著引用這個actionsheet。只為解決dangling pointer的問題而在類中添加一個執行個體變數儲存指標,甚至要儲存一個指標數組,並且這部分代碼還和類本身邏輯的代碼耦合在一起,有潔癖的人看起來總覺得刺眼。 理想中解決dangling pointer問題的方法,應該是一個通用的基礎方法,與類的商務邏輯無關,代碼相對獨立。 思路2:
不用執行個體變數,想辦法在delegate dealloc的時候獲得actionsheet的指標。 系統的view樹一定是儲存了actionsheet的指標的,第一反應是想在actionsheet上打tag,然後利用viewWithTag:方法來擷取。或者,在dealloc的時候遍曆整個view樹來尋找當前存在的actionsheet,這兩種方法本質上是相同的。我們暫且不討論遍曆view樹的開銷是否值得,只討論方法可行性。剛才我們說過,iphone上的actionsheet是從屬於一個內部window的,並不在我們程式可控的window中,所以上述方法根結點的選取是關鍵。
UIActionSheet *sheet = [[UIActionSheet alloc] initWithTitle:@"sheet" delegate:self cancelButtonTitle:@"cancel" destructiveButtonTitle:nil otherButtonTitles:nil]; [sheet showInView:self.view]; [sheet setTag:kSheetTag]; NSLog(@"root(self.view.window):%@", [self.view.window viewWithTag:kSheetTag]); // null NSLog(@"root(internal window):%@", [[UIApplication sharedApplication].windows[2] viewWithTag:kSheetTag]); // actionsheet found!
結果情理之中,我們在當前的window上是遍曆不到這個actionsheet的,需要在之前說的_UIAlertOverlayWindow上遍曆才行。於是我們可以先在actionsheet建立時打個tag,然後在X dealloc方法裡這樣寫:(不能應付多個actionsheet彈出的情況)
[[UIApplication sharedApplication].windows enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { if (strcmp(class_getName([obj class]),"_UIAlertOverlayWindow") == 0) { UIActionSheet *theSheet = (UIActionSheet *)[obj viewWithTag:kSheetTag]; [theSheet setDelegate:nil]; } }];
也可以不打tag,直接採用遍曆view樹的方式。(如果是在ipad上,不用使用內部window,直接遍曆自己的self.view.superview的subviews就行了,可自行實驗)
[[UIApplication sharedApplication].windows enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { [self traverseView:obj]; }]; // 遍曆view樹- (void)traverseView:(UIView *)root{ [[root subviews] enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { if ([obj isKindOfClass:[UIActionSheet class]]) { if (((UIActionSheet *)obj).delegate == self) { ((UIActionSheet *)obj).delegate = nil; NSLog(@"enemy spotted!"); } } else { [self traverseView:obj]; } }];}
這樣也解決了問題,其優點有一: 一、不用改動類X的商務邏輯部分的代碼,修改範圍縮小到X的dealloc方法中,相對來講便於移植到其他類中,也可以通過一些runtime的手段實現自動化。 二、遍曆的方法,可以輕易應對彈出多個actionsheet的情況。 其缺點有二: 一、提起遍曆view樹,開銷問題是肯定要考慮的。以某項目為例,一vc在dealloc的時候,view樹中共有320個view,遍曆尋找UIAcionSheet共置delegate空需要約0.002s,而正常的dealloc方法只需要不到0.0001s,時間提升20倍。實話說,這個開銷並不是大到無法忍受,是否划算視具體情況而定。 二、如果想節省這個開銷,那麼就需要利用一些“潛規則”,例如像上面viewWithTag的方法代碼那樣,利用_UIAlertOverlayWindow這個類名來縮小遍曆範圍。潛規則這個東西,如果用,請慎用。它們是毫無文檔保障的,可能下一代iOS,這個實現就被改掉了,那時我們的代碼就會出問題。譬如通過hack系統imagePicker的方式實現多圖選擇框,算是一個比較常見且“合理”的利用“潛規則”的例子(因為常規用assetslibrary實現的多圖選擇框在iOS5上會有定位許可權的提示問題,這是很多產品不願意接受的事情),但是iOS7中,imagePicker的內部ViewController的名字就被改掉了,原來用這種方式實現多圖選擇框的代碼,就需要跟進修改。 思路3:
從思路1和2中我們可以得到這樣的啟發,如果有一個集合,裡面存放了所有delegate指向X的actionsheet A(甚至其它對象執行個體),那麼,我們就能在dealloc時遍曆這個集合來置A.delegate = nil。 上述這種集合S有如下特徵: 1、S能夠與一個對象X實現1對1的綁定或對應,並在X dealloc的時候能被訪問到。2、在合適的時機(比如設定delegate時),能夠對S添加或刪除元素 我們先按1和2抽象出一個通用的包含集合S的類結構,取名為parasite:
@interface DelegateBaseParasite : NSObject{ NSMutableSet *sanctuarySet_; // 集合S} // 建立並將自己(parasite)綁定(對應)到hostObj X 上+ (DelegateBaseParasite *)parasitizeIn:(id)hostObj; // 返回已經綁定(對應)到hostObj X上的parasite對象(或nil若未綁定)+ (DelegateBaseParasite *)getParasiteFromHost:(id)hostObj;// 添加一個對象object到此parasite的集合S中,當object.delegate = hostObj X的時候- (void)addToSanctuary:(id)object; // 從此parasite的集合S中移除object,當object.delegate不再=X的時候- (void)removeFromSanctuary:(id)object;// 將所有sanctuary中對象的delegate(此時都指向hostObj)置為nil- (void)redemptionAll;@end
大意是:如果每一個X都與一個這樣的DelegateBaseParasite P綁定(對應),在設定A.delegate = X的時候,調用addToSanctuary將A添加到P的集合S中(同時通過removeFromSanctuary方法將A從舊delegate綁定parasite的集合S中移除),並且在X dealloc的時候執行redemptionAll方法來清空集合S裡的所有對象的delegate屬性,那麼問題就解決了。 對集合S操作的方法沒有什麼複雜的。重點關注的是如何?對象X與parasite P一對一的綁定。我們發現這個parasite對象有如下特點:1、與宿主的類型和實現完全無關,沒有調用宿主的任何方法或訪問任何執行個體變數。2、只需要在宿主dealloc的時候調用自己的一個方法,並且自己也被銷毀。 這讓我們不禁想到了一個叫做associate object(關聯對象文檔)的東西!不妨將DelegateBaseParasite作為一個associate object,綁定到X上。按這個思路派生一個DelegateAssociativeParasite類,實現一下綁定相關方法:
#define kDelegateAssociativeParasiteSanctuaryKey "kDelegateAssociativeParasiteSanctuaryKey"@implementation DelegateAssociativeParasite#pragma mark -#pragma public interface+ (DelegateAssociativeParasite *)parasitizeIn:(id)hostObj{ DelegateAssociativeParasite *parasite = [[DelegateAssociativeParasite alloc] init]; objc_setAssociatedObject(hostObj, &kDelegateAssociativeParasiteSanctuaryKey, parasite, OBJC_ASSOCIATION_RETAIN_NONATOMIC); return parasite;}+ (DelegateAssociativeParasite *)getParasiteFromHost:(id)hostObj{ return objc_getAssociatedObject(hostObj, &kDelegateAssociativeParasiteSanctuaryKey);}- (void)Dealloc{ [self redemptionAll];}@end
不知不覺,我們已經成功了一半。也就是說,還有另一半的問題需要我們解決: 即我們需要在actionsheet A的setDelegate:方法中刪除綁定於舊delegate的集合S中的元素A,並添加A到綁定於新delegate X的集合S中。還要在A的dealloc方法中調用[self setDelegate:nil] 每次調用setDelegate的時候添加代碼手動修改嗎?這顯然不是一個好辦法,並且,actionsheet的dealloc時機,並不由我們控制,想手動添加代碼都辦不到。那麼有沒有辦法能修改這兩個方法的實現,並且這種修改還能夠調用到其原有的方法實現呢? 繼承一個DelegateAutoUnregisteredUIActionSheet出來當然可以辦到,但是將所有UIActionSheet替換掉,仍然要做不少工程,而且,功能上只是在UIActionSheet上打個自動登出delegate的補丁,沒必要也不應該採用繼承的方式。 能不能用category呢?category複寫主類同名方法會產生warning,屬於apple強烈不推薦的方式,而且就算強行複寫了主類同名方法,也無法調用原來的實現。 那麼怎麼辦呢?可以用objc的runtime提供的一些方法。先用class_addMethod為類添加一個新方法,在新方法中調用原有實現,再用method_exchangeImplementation將其與原有實現做交換。(objc runtime文檔) 按這個思路我們可以寫一個輔助類DelegateAutoUnregisterHelper類(代碼見附件樣本工程)。
這樣一來,另一半問題也解決了,現在只需在main.m裡簡單調用:
[DelegateAutoUnregisterHelper registerDelegateAutoUnregisterTo:[UIActionSheet class]];
就可以實現actionsheet的delegate自動置空功能了。 這個利用associate object和runtime相結合的解法,也有其優缺點。其優點有二: 一、向工程中添加的DelegateAssociativeParasite和DelegateAutoUnregisterHelper兩個類是完全與其它類獨立的代碼,與商務邏輯無關,邏輯清晰。 二、使用簡單,只需要在main.m中調用一個方法對目標類(UIActionSheet)進行註冊。項目之前“喜聞樂見”的代碼完全不用做任何修改。其缺點有一: 一、廣義的dangling pointer delegate出現最多的情境其實是多線程。一個線程釋放了delegate對象,而另外一個線程恰好在使用它。反觀我們剛才寫的代碼,卻完全沒有考慮任何安全執行緒的問題。 我們不禁要問兩個問題: 1. 解決UIActionSheet的delegate問題為什麼可以不考慮安全執行緒? 2. 這種利用associate object的思路,能否通過鎖/訊號量等方式解決安全執行緒的問題? 問題1是由UIActionSheet的使用情境決定的,作為一個系統的UI控制項,在大多數情況下,其setDelegate、dealloc、showInView等方法,都是在UI線程中調用的。而其delegate一般都是一個UIView或者UIViewController,這兩種對象的銷毀通常也是發生在UI線程裡(實際上,假如我們發現我們的某些View或者ViewController的最後一次釋放以致銷毀跑到了非UI線程,我們應該停下來思考一下是不是設計上出了問題,因為View和VC的釋放很有可能會涉及到一些在UI線程才能進行的操作。)當然,我說的是大多數情況,而並非絕對。因而通常正常使用actionsheet並不會涉及安全執行緒問題。 那麼來到問題2,這種以associate object為核心的綁定方式,究竟有沒有可能解決安全執行緒問題呢? 一推敲,天然的缺陷就暴露出來了。 之前我們一直刻意模糊了一個概念,即“當X dealloc的時候”。dealloc的時候是什麼時候?是dealloc前還是dealloc後? 對於associate object,其dealloc方法,是在其宿主X的dealloc方法調用完畢以後,也就是宿主X已經被銷毀之後,才調用的。也就是說,delegate的置空是在delegate被銷毀之後。無論之間間隔多麼短,總是有那麼一瞬間,X已經被銷毀了,delegate還沒有被置空,dangling pointer出現,如果是在多線程的情境下,就有可能有另外的線程在此時訪問到了這個dangling pointer,程式依然會crash。 所以,基於associate object的解決方案,歸根結底是無法解決安全執行緒的問題的。 那麼怎樣才能做出一個安全執行緒的dangling pointer delegate問題的解決方案呢? 思路4:
既然問題出在associate object上,那我們就不用它,想想有沒有其它實現X與P一對一綁定(對應)的方法。這時我們又想起了weak指標。系統是怎麼做到將object與指向其的weak指標集合綁定(對應)在一起的呢? 關於weak指標的實現,我們可以在llvm.org上看到相關的文檔內容(http://clang.llvm.org/docs/AutomaticReferenceCounting.html),但是不夠詳細。更直接的方式是閱讀http://www.opensource.apple.com/source/objc4/裡面的NSObject和runtime實現的源碼。 簡而言之,編譯器實現的weak指標與我們的中心思想是一致的,即用一種方法綁定對象X和一個指向X的需要監視的指標集合,並在X dealloc之時自動將集合內元素置空。只不過與associate object的方法相比,有兩點不同: 1. 綁定對象,用的是一個全域的hash table(SideTable),而非associate object。hash table的key對應一個對象X,value為指標集合。2. dealloc之時,指的是X的dealloc方法調用過程之中,而非最終銷毀以後,這樣就不存在天然的缺陷,其安全執行緒問題是可以通過在hash table上加鎖來解決的。 按照這個思路,我們來派生一個新的DelegateDictParasite類,實現另一種利用CFDictionary的綁定(對應)的方法:
@implementation DelegateDictParasite+ (DelegateDictParasite *)parasitizeIn:(id)hostObj{ if (!class_getInstanceMethod([hostObj class], @selector(myHostObjDealloc))) { [DelegateDictParasite addNewMethodToHost:[hostObj class]]; [DelegateAutoUnregisterHelper mergeOldSEL:[DelegateAutoUnregisterHelper deallocSelector] NewSEL:@selector(myHostObjDealloc) ForClass:[hostObj class]]; [DelegateAutoUnregisterHelper mergeOldSEL:[DelegateAutoUnregisterHelper releaseSelector] NewSEL:@selector(myHostObjRelease) ForClass:[hostObj class]]; } DelegateDictParasite *parasite; @synchronized(kDelegateAssociativeParasiteLock) { if (!delegateHostParasiteHashTable) { delegateHostParasiteHashTable = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, NULL, &kCFTypeDictionaryValueCallBacks); } parasite = [[DelegateDictParasite alloc] init]; CFDictionarySetValue(delegateHostParasiteHashTable, (__bridge const void *)(hostObj), (__bridge const void *)(parasite)); } return parasite;}+ (DelegateDictParasite *)getParasiteFromHost:(id)hostObj{ DelegateDictParasite *parasite; @synchronized(kDelegateAssociativeParasiteLock) { if (!delegateHostParasiteHashTable) { return nil; } parasite = CFDictionaryGetValue(delegateHostParasiteHashTable, (__bridge const void *)(hostObj)); } return parasite;}@end
這裡,由於沒有了associate object的協助,X dealloc與parasite dealloc的聯動需要我們自己觸發,同樣利用runtime,我們可以改寫每一個X的dealloc方法來完成這種聯動,解除hash table中對X的綁定,從而引發自動置空。 另外,通過鎖,我們可以解決安全執行緒問題。從而解決多線程下delegate的dangling pointer問題。(完整代碼見附錄) 這種思路,其優點有二: 一、沒有了先天缺陷,解決了安全執行緒問題,從而可以推廣到廣義的dangling pointer delegate問題上。 二、用法與思路三一樣,比較簡單。
其缺點有二: 一、用了全域的一個hash table。一般有潔癖的人看到全域變數會不舒服。 二、對每一個成為delegate的對象X的類,都會修改其dealloc方法,不像associate object的聯動那麼自然,有點不乾淨。 思路5: GitHub上有一個mikeash寫的開源項目MAZeroingWeakRef,目的是在不支援weak的情況下提供一個weak指標的實現,其實現思想也是與系統weak指標類似,即利用全域hash table來做。與思路4不同的是,它修改X的dealloc方法,是通過動態繼承出X的一個子類,然後在子類上addMethod的方式,而不是利用method_exchangeImplementation。 這個項目考慮了更多的情況,比如說對於KVO的支援以及toll-free的CF對象的處理(不過用到了私人API)等等,大家有興趣和時間的話可以研究一下,不再贅述。 其優點有二: 一、考慮了KVO/CF等情況的支援,更加嚴謹。 二、動態繼承的方式把dealloc方法修改的範圍縮小到只是使用weak的執行個體而不是此類的所有執行個體,解決了思路4的缺點二。其缺點有二: 一、動態繼承的方式修改了類的名字。 二、只是用來在weak不能使用的條件下實現weak指標,可以解決自訂的delegate的dangling pointer問題,並不能解決文中已經被指定為assign類型的系統控制項delegate的問題。 註:本文由於篇幅所限,實現過程中一些坑和有意思的地方並未一一提及。例如修改方法實現的時候,需要注意修改的是父類方法還是子類方法;一些方法實現只能放在非ARC(添加-fno-objc-arc標誌)檔案中;等等。 【總結】 本文逐步思考並總結的幾種解決dangling pointer問題的思路各有優缺點,並不存在哪種一定最好,要具體情況具體分析。相比之下,思路4是解決多線程delegate的dangling pointer的較為完整的解決方案。思考和實現過程當中還有很多不成熟的地方,歡迎大家一起討論、不正確的地方也歡迎批評指正。
iOS上Delegate的懸垂指標問題