文章有点长,写的过程很有收获,但读的过程不一定有收获,慎入
【摘要】
【摘要】
悬垂指针(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。
改用一个实例变量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的指针。
不用实例变量,想办法在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指针集合绑定(对应)在一起的呢?
既然问题出在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的较为完整的解决方案。思考和实现过程当中还有很多不成熟的地方,欢迎大家一起讨论、不正确的地方也欢迎批评指正。