第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

posted @ 2017-08-19 07:07  鳄鱼不怕牙医不怕  阅读(180)  评论(0编辑  收藏  举报