文章有点长,写的过程颇有收获,但读的过程不必定有收获,慎入
【摘要】
悬垂指针(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个UIWindow
self.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掉)。
这种思路,优势有二:
1、思路简单,代码添加量少;
2、若是你是在写一个iPad app,那反正应付转屏从新布局actionsheet也是须要这个实例变量的,一举多得。
其缺点也有二:
1、这种方式通用性差,咱们须要针对每个这样的X都写一遍这样的代码,若是这是一个已经存在的项目,而这个项目里几乎全部的actionsheet都是这样用局部变量弹出的,怎么办?咱们须要修改多少代码?
2、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];
}
}];
}
这样也解决了问题,其优势有一:
1、不用改动类X的业务逻辑部分的代码,修改范围缩小到X的dealloc方法中,相对来说便于移植到其余类中,也能够经过一些runtime的手段实现自动化。
2、遍历的方法,能够轻易应对弹出多个actionsheet的状况。
其缺点有二:
1、提起遍历view树,开销问题是确定要考虑的。以某项目为例,一vc在dealloc的时候,view树中共有320个view,遍历寻找UIAcionSheet并置delegate空须要约0.002s,而正常的dealloc方法只须要不到0.0001s,时间提高20倍。实话说,这个开销并非大到没法忍受,是否划算视具体状况而定。
2、若是想节省这个开销,那么就须要利用一些“潜规则”,例如像上面viewWithTag的方法代码那样,利用_UIAlertOverlayWindow这个类名来缩小遍历范围。潜规则这个东西,若是用,请慎用。它们是毫无文档保障的,可能下一代iOS,这个实现就被改掉了,那时咱们的代码就会出问题。譬如经过hack系统imagePicker的方式实现多图选择框,算是一个比较常见且“合理”的利用“潜规则”的例子(由于常规用assetslibrary实现的多图选择框在iOS5上会有定位权限的提示问题,这是不少产品不肯意接受的事情),可是iOS7中,imagePicker的内部ViewController的名字就被改掉了,原来用这种方式实现多图选择框的代码,就须要跟进修改。
思路3:
从思路1和2中咱们能够获得这样的启发,若是有一个集合,里面存放了全部delegate指向X的actionsheet A(甚至其它对象实例),那么,咱们就能在dealloc时遍历这个集合来置A.delegate = nil。
上述这种集合S有以下特征:
一、S可以与一个对象X实现1对1的绑定或对应,并在X dealloc的时候能被访问到。
二、在合适的时机(好比设置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对象有以下特色:
一、与宿主的类型和实现彻底无关,没有调用宿主的任何方法或访问任何实例变量。
二、只须要在宿主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相结合的解法,也有其优缺点。其优势有二:
1、向工程中添加的DelegateAssociativeParasite和DelegateAutoUnregisterHelper两个类是彻底与其它类独立的代码,与业务逻辑无关,逻辑清晰。
2、使用简单,只须要在main.m中调用一个方法对目标类(UIActionSheet)进行注册。项目以前“喜闻乐见”的代码彻底不用作任何修改。
其缺点有一:
1、广义的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指针与咱们的中心思想是一致的,即用一种方法绑定对象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问题。(完整代码见附录)
这种思路,其优势有二:
1、没有了先天缺陷,解决了线程安全问题,从而能够推广到广义的dangling pointer delegate问题上。
2、用法与思路三同样,比较简单。
其缺点有二:
1、用了全局的一个hash table。通常有洁癖的人看到全局变量会不舒服。
2、对每个成为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)等等,你们有兴趣和时间的话能够研究一下,再也不赘述。
其优势有二:
1、考虑了KVO/CF等状况的支持,更加严谨。
2、动态继承的方式把dealloc方法修改的范围缩小到只是使用weak的实例而不是此类的全部实例,解决了思路4的缺点二。
其缺点有二:
1、动态继承的方式修改了类的名字。
2、只是用来在weak不能使用的条件下实现weak指针,能够解决自定义的delegate的dangling pointer问题,并不能解决文中已经被指定为assign类型的系统控件delegate的问题。
注:本文因为篇幅所限,实现过程当中一些坑和有意思的地方并未一一说起。例如修改方法实现的时候,须要注意修改的是父类方法仍是子类方法;一些方法实现只能放在非ARC(添加-fno-objc-arc标志)文件中;等等。
【总结】
本文逐步思考并总结的几种解决dangling pointer问题的思路各有优缺点,并不存在哪一种必定最好,要具体状况具体分析。相比之下,思路4是解决多线程delegate的dangling pointer的较为完整的解决方案。思考和实现过程中还有不少不成熟的地方,欢迎你们一块儿讨论、不正确的地方也欢迎批评指正。