第40条:用块引用其所属对象时不要出现保留环
本条要点:(作者总结)
- 如果块所捕获的对象直接或间接地保留了块本身,那么就得当心保留环问题。
- 一定要找个适当的时机解除保留环,而不能把责任推给API 的调用者。
使用块时,若不仔细思量,则很容易导致“保留环”(retain cycle)。比方说,下面这个类就提供了一套接口,调用者可由此从某个 URL 中下载数据。在启动获取器时,可设置 completion handler,这个块会在下载结束之后以回调方式执行。为了能在下载完成后通过 p_requestCompleted 方式执行调用者所指定的块。这段代码需要把 completion handler 保存到实例变量里面。
1 // EOCNetworkFetcher.h 2 3 #import <Foundation/Foundation.h> 4 5 typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data); 6 7 8 9 @interface EOCNetworkFetcher : NSObject 10 11 @property (nonatomic, strong, readOnly) NSURL *url; 12 13 - (id)initWithURL:(NSURL *)url; 14 15 - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionhandler)completion; 16 17 @end
1 // EOCNetworkFetcher.m 2 3 #import "EOCNetworkFetcher.h" 4 5 6 7 @interface EOCNetworkFetcher () 8 9 @property (nonatomic, strong, readwrite) NSURL *url; 10 11 @property (nonatomic, copy) EOCNetworkFetcherCompletionHandler completionHandler; 12 13 @property (nonatomic, strong) NSData *downloadedData; 14 15 @end 16 17 @implementation EOCNetworkFetcher 18 19 - (id)initWithURL:(NSURL *)url { 20 21 if ((self = [super init])) { 22 _url = url; 23 } 24 return self; 25 26 } 27 28 - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion { 29 30 self.completionHandler = completion; 31 // Start the request 32 // Request sets downloadedData property 33 // When request is finished, p_requestCompleted is called 34 } 35 36 - (void)p_requestCompleted { 37 38 if (_completionHandler) { 39 _completionHandler(_downloadedData); 40 } 41 } 42 43 @end
某个类可能会创建这种网路数据获取器对象,并用其从 URL 中下载数据:
1 @implementation EOCClass { 2 3 EOCNEtworkFetcher *_networkFetcher; 4 NSData *_fetchedData; 5 } 6 7 8 9 - (void)downloadData { 10 11 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/something.dat"]; 12 _networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 13 [_networkFetcher startWithCompletionHandler:^(NSData *data) { 14 NSLog(@"Request URL %@ finished", _networkFetcher.url); 15 _fetchedData = data; 16 }]; 17 } 18 19 @end
这段代码看上去没什么问题。但你可能没发现其中有个保留环。因为 completion handLer 块要设置 _fetchedData 实例变量,所以它必须捕获 self 变量,这就是说,handler 块保留了创建网络数据获取器的那个 EOCClass 实例。而 EOCClass 实例则通过 strong 实例变量保留了获取器,最后,获取器对象又保留了 handler 块。图描述了这个保留环。
要打破保留环也很容易:要么令 _networkFetcher 实例变量不再引用获取器,要么令获取器的 completionHandler 属性不在持有 handler 块。在网络数据获取器这个例子中,应该等 completion handler 块执行完毕后,再去打破保留环,以便使获取器对象在 handler 块执行期间保持存活状态。比方说,completion handler 块的代码可以这么修改:
1 [_networkFetcher startWithCompletionHandler:^(NSData *data) { 2 3 NSLog(@"Request for URL %@ finished", _networkFetcher.url); 4 5 _fetchedData = data; 6 7 _networkFetcher = nil; 8 9 ];
网络数据获取器和拥有它的 EOCClass 类实例之间构成了保留环
如果设计 API 时用到了 completion handler 这样的回调块,那么很容易形成保留环,所以必须意识到这个重要问题。一般来说,只要适时清理掉环中的某个引用,即可解决此问题,然而,未必总有这种机会。在本例中,唯有 completion handler 运行过后,方能解除保留环。若是 completion handler 一直不运行,那么保留环就无法打破,于是内存就会泄漏。
像 completion handler 块这种写法,还可能引入另外一种形式的保留环。如果 completion handler 块所引用的对象最终又引用了这个块本身,那么就会出现保留环。比方说,我们修改一下前面那个例子,使调用 AIPI 的那段代码无须在执行期间保留指向网络数据获取器的引用。而是设定一套机制,令获取器对象自己设法保持存活。要想保持存活,获取器对象可以在启动任务时把自己加到全局的 collection 中(比如用 set 来实现这个 collection),待任务完成后,再移除。而调用方则需要将其代码修改如下:
1 - (void)downloadData { 2 3 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/something.dat"]; 4 5 EOCNetworkFetcher *networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 6 7 [networkFetcher startWithCompletionHandler:^(NSData *data) { 8 9 NSLog(@"Request URL %@ finished", networkFetcher.url); 10 11 _fetchedData = data; 12 13 }]; 14 }
大部分网络通信库都采用这种办法,因为假如令调用者自己来将获取器对象保持存活的话,他们会觉得麻烦。Twitter 框架的 TWRequest 对象也用这个方法。然而,就 EOCNetworkFetcher 的现有代码来看,此做法会引入保留环。而这次比刚才那个例子更难于发觉,completion handler 块其实要通过获取器对象来引用其中的 URL。于是,块就要保留获取器,而获取器反过来又经由其 completionHandler 属性保留了这个块。所幸要修复这个问题也不难。回想一下,获取器对象之所以要把 completion handler 块保存在属性里面,其唯一目的就是想稍后使用这个块。可是,获取器一旦运行过 completion handler 之后,就没有必要再保留它了。所以,只需要将 p_requestCompleted 方法按如下方式修改即可:
1 - (vlid)p_requestCompleted { 2 3 if (_completionHandler) { 4 5 _completionHandler(_downloadedData); 6 } 7 self.completionHandler = nil; 8 }
这样一来,只要下载请求执行完毕,保留环就解除了,而获取器对象也将会在必要时为系统所回收。请注意,之所以要在 start 方法中把 completion handler 作为参数传进去,这也是一条重要原因。假如把 completion handler 暴露为获取器对象的公共属性,那么就不便在执行完下载请求之后直接将其清理掉了,因为既然已经把 handler 作为属性公布了,那就意味着调用者可以自由使用它,若是此时又在内部将其清理掉的话,则会破坏“封装语义”(encapsulation semantic)。在这种情况下要想打破保留环,只有一个办法可用,那就是强迫调用者在 handler 代码里自己把 compleionHandler 属性清理干净。可这并不是十分合理,因为你无法假定调用者一定会这么做,他们反过来会抱怨你没把内存泄漏问题处理好。
这两种保留环都很容易发生。使用块来编程时,一不小心就会出现这种 bug,反过来说,只要小心谨慎,这种问题也很容易解决。关键在于,要想清楚块可能会捕获并保留哪些对象。如果这些对象又直接或间接保留了块,那么就要考虑怎样在适当的时机解除保留环。
END