Objective-C(六)Block与GCD

1,253 阅读16分钟

这是Objective-C系列的第6篇,也是《Effective Objective-C 2.0》系列的最后一篇。

一、最佳实践

  • 用handler Block降低代码分散程度
    • 在创建对象时,可以使用内联的handler Block将相关业务逻辑一并声明。
    • 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用handler Block来实现,则可直接将Block与相关对象放在一起;
    • 设计API时,如果遇到handler Block,那么可以新增一个参数,使调用者可以通过该参数来决定应该把Block安排在哪个队列上执行。
  • 使用Block中发生的循环引用要避免
  • 多用派发队列,少用同步锁
    • 派发队列可用来表述同步语义,这种做法要比使用@synchronizedNSLock对象更简单;
    • 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做不会阻塞执行异步派发的线程;
    • 使用同步队列及栅栏块,可以令同步行为更高效;
  • 多用GCD,少用performSelector系列方法
    • performSelector系列方法在内存管理易有疏漏,它无法确定将要执行的选择子具体是什么,所以ARC编译器也就无法插入适当的内存管理方法;
    • performSelector系列方法所能处理的选择子太过局限,选择子返回值类型及发送方给方法的参数个数都受到限制;
    • 如果想延迟执行,最好不要用performSelector系列方法,而是应该把任务封装到Block里,调用GCD来实现。
  • 掌握GCD及操作队列的使用时机
    • 取消某个操作;
    • 指定操作间的依赖关系;
    • 通过键值观察机制监控NSOperationNSOperation对象许多属性都适合通过键值观察机制来监听,比如isCancelledisFinished
    • 指定操作的优先级;
  • 使用dispatch_once来执行只需运行一次的线程安全代码
  • 不要使用dispatch_get_current_queue
  • 通过Dispatch Group机制,根据系统资源状况来执行任务
    • 一系列的任务可归入一个dispatch_group中,开发者可以在这组任务完毕时获得通知;
    • 通过dispatch_group,可以在并发时派发队列同时执行多项任务。

二、实践详解

2.1 理解Block这一概念

​ Block用“^”(脱字符或插入符)来表示:

^{
  //Block implementation here
}

​ Block其实是个值,自有其类型,与int、float或Objective-C对象一样,也可以把Block赋给变量,其与函数指针类似。 Block的完整的语法结构如下:

return_type (^block_name)(parameters)

​ 看一个实例:

int (^addBlock)(int a, int b) = ^(int a, int b) {
   return a+b;
}

​ 调用:

int add = addBlock(2, 5) //add = 7

​ 下面是各种情况下的Block的写法:

//属性
@property (copy ,nonatomic)void (^callBack)(NSString *);

//函数参数
- (void)callbackAsAParameter:(void (^)(NSString *print))callBack {
    callBack(@"i am alone");
}

[self callbackAsAParameter:^(NSString *print) {
    NSLog(@"here is %@",print);
}];
    
//typedef
typedef void (^callBlock)(NSString *status);
CallBlock block = ^void(NSString *str){
    NSLog(@"%@",str);
};

​ Block的强大之处:在声明它的范围里,所有变量都可以为其所捕获。就是Block里可以用该范围的所有变量。

int additional = 5;
int (^addBlock)(int a, int b) = ^(int a, int b) {
    return a+b+additional;
}
int add = addBlcok(2, 5); //add = 12

​ 如果需要修改Block所捕获的变量,需要加上__block。

2.1.1 函数指针

​ 为了更好说明Block,这里说明下函数指针。

​ 函数指针是指向函数的指针变量。 因而“函数指针”本身首先应是指针变量,只不过该指针变量指向函数。这正如用指针变量可指向整型变量、字符型、数组一样,这里是指向函数。如前所述,C在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样,在这些概念上是大体一致的。函数指针有两个用途:调用函数和做函数的参数。下面是个实例:

#include<stdio.h>
int max(int x,int y){return (x>y? x:y);}
int main()
{
    int (*ptr)(int, int);
    int a, b, c;
    ptr = max;		//ptr = &max;
    scanf("%d%d", &a, &b);
    c = (*ptr)(a,b);
    printf("a=%d, b=%d, max=%d", a, b, c);
    return 0;
}

2.1.2 Block的内部结构

​ Block本身是个对象,在存放Block的内存区域里,第一个个变量是指向Class对象的指针,该指针叫做isa,其余内存里含有对象正常运转所需的各种信息:

Block内部结构

  • Impl 是个结构体。内部有个FuncPtr指向Block的实现代码,此参数代表Block。Block实现了把原来标准C语言中需要“不透明的void指针”传递状态变的透明,而且简单易用。

  • descriptor是指向结构体的指针,每个Block都包含该结构体。其中声明了copy及dispose这两个辅助函数所对应的函数指针。辅助函数在Block拷贝或者丢弃Block对象是运行。

    • size:Block的大小;
    • copy:辅助函数,保留捕获的对象;
    • dispose:辅助函数,释放捕获的对象;
  • Block会将其所捕获的所有变量都拷贝一份,置于descriptor之后,要注意的是,拷贝的并不是对象本身,而是指向这些对象的指针变量。

    invoke函数为何需要把Block作为对象参数传进来呢?原因在于,执行Block时,要从内存中把这些捕获到的变量读出来。

2.1.3 全局Block、栈Block和堆Block

​ 定义Block时,其所占的内存区域是分配在栈中的,即Block只在定义它的那个范围内有效。如:

void (^block)();
if(//) {
   block = ^{
       NSLog(@"Block A");
    };
} else {
   block = ^{
      NSLog(@"Block B");
   };
}

​ if/else中定义的Block,都是在栈中,当离开了相应的范围后,该栈内存有可能会被覆写。所以在运行时,有可能正确运行,也有可能发生崩溃。这取决于编译器是否覆写了该Block内存。

栈内存中的Block对象,无需考虑对象的释放,因为栈内存是系统管理的,系统会保证回收对象。

​ 为了解决该问题,可以给Block对象发送copy消息,以执行拷贝。就可把Block对象从栈内存拷贝到堆内存。

堆内存中的Block对象,同普通对象一致,有引用计数,拷贝是递增引用计数,在ARC时无需手动释放,在引用计数为0时自动释放等。

void (^block)();
if(//) {
   [block = ^{
   NSLog(@"Block A");
   } copy];
} else {
   [block = ^{
            NSLog(@"Block B");
   } copy];
}

​ 除了上面的“栈Block”和“堆Block”,还有一类叫做“全局Block”。全局Block,有下面几个特点:

  • 不会捕捉任何状态,比如外围的变量等,运行时也无需有状态来参与;

  • Block所使用的是整个内存区域,在编译器已经完全确定,因此全局Block可以声明在全局内存里,而不需要每次用到的时候在栈中创建;

  • 全局Block的拷贝操作是个空操作,因为全局Block决不可能为系统所回收;

  • 全局Block相当于单例;

    下面是个全局Block:

void (^block) = ^{
    NSLog(@"This is a block");
};

​ 由于运行该Block所需的全部信息都能在编译器确定,所以可把它做成全局Block,这完全是种优化技术。若把如此简单的Block当做复杂的Block来处理,那就会在复制或者丢弃该Block执行一些无谓的操作。

2.2 用handler Block降低代码分散程度

​ 委托代理能很大程度上实现异步回调处理这样的事,但是委托代理这种模式却会使得代码极度分散。

​ 用handler来集中代码,是个不错的选择。

//风格一:
HONetworkFetcher *fetcher = [HONetworkFetcher alloc] initWirhURL:url];
[fetcher startWithCompletionHandler:^(NSData* data){
    //handle success
} failureHandler:^(NSError *error){
	    //handle failure
}];

//风格二:
HONetworkFetcher *fetcher = [HONetworkFetcher alloc] initWirhURL:url];
[fetcher startWithCompletionHandler:^(NSData* data,NSError *error){
  	if(error){
  	 	//handle success
  	}else{
      	//handle failure
  	}
}];

风格一:代码易懂,将成功与失败的逻辑分开来写,必要时可以忽略成功或者失败的处理情形。

风格二:

  • 缺点:需要检测error,且全部逻辑都在一起,可能会令Block比较长,且比较复杂。
  • 优点:更为灵活,比如数据下载到一半时,网络故障,此时可以把数据即相关的错误传给Block,以便保存已下载数据及对错误进行处理。另外一个优点就是,调用API的代码可能会在处理处理成功的响应过程中发现错误。此时可以把成功中的错误处理同真正的错误一并处理,而不会造成代码冗余。假如分开处理,那么就会有两份一样的错误处理代码,而进一步,抽取成公共方法,又失去了原本要降低代码分散的初衷。

总结:

  • 在创建对象时,可以使用内联的handler Block将相关业务逻辑一并声明。

  • 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用handler Block来实现,则可直接将Block与相关对象放在一起;

  • 设计API时,如果遇到handler Block,那么可以新增一个参数,使调用者可以通过该参数来决定应该把Block安排在哪个队列上执行。

    - (id <NSObject>)addObserverForName:(nullable NSNotificationName)name object:(nullable id)obj queue:(nullable NSOperationQueue *)queue usingBlock:(void (^)(NSNotification *note))block NS_AVAILABLE(10_6, 4_0);
    

2.3 使用Block中发生的循环引用

如下代码:

@interface HONetworkFetcher()

@property (nonatomic ,strong ,readwrite) NSURL *url;
@property (nonatomic ,copy)HONetworkFetcherCompletionHadler completionHandler;
@property (nonatomic ,strong)NSData *downloadedData;

@end
@implementation HONetworkFetcher

- (instancetype)initWithURL:(NSURL *)url {
    if (self = [super init]) {
        _url = url;
    }
    return self;
}

- (void)startWithCompletionHandler:(HONetworkFetcherCompletionHadler)completion
{
    self.completionHandler = completion;
        //start the request
        //request sets downloadedData property
        //When request is finished ,p_requestCompleted is called
}

- (void)p_requestCompleted
{
    if (_completionHandler) {
        _completionHandler(_downloadedData);
    }
}

@end

某个类作了如下的调用:

@interface HOClass : NSObject

@end

@interface HOClass()
{
    HONetworkFetcher *_networkFetcher;
    NSData *_fetchData;
}

@end
@implementation HOClass

- (void)downloadData
{
    NSURL *url = [NSURL URLWithString:@"www.com"];
    _networkFetcher = [[HONetworkFetcher alloc] initWithURL:url];
    [_networkFetcher startWithCompletionHandler:^(NSData *data) {
        _fetchData = data;
    }];
}
@end

分析下场景:

​ HOClass的实例对象实例变量_networkFetcher引用获取器,_networkFetcher持有completionHandler,completionHandler又引用_fetchData,相当于持有HOClass的实例对象,所以就造成了循环引用。

​ 解除循环引用的方式很简单,打破这个三角循环,要么是使得_networkFetcher不再引用,要么获取器不再持有completionHandler。

​ 下面是一种解决方式:

- (void)downloadData
{
    NSURL *url = [NSURL URLWithString:@"www.com"];
    _networkFetcher = [[HONetworkFetcher alloc] initWithURL:url];
    [_networkFetcher startWithCompletionHandler:^(NSData *data) {
        _fetchData = data;
        _networkFetcher = nil;
    }];
}

另外一种情况是:completion handler所引用的对象最终又引用了这个Block本身。其中获取器持有completion handler,而completion handler中又对获取器的url进行引用。

- (void)downloadData
{
    NSURL *url = [NSURL URLWithString:@"www.com"];
    HONetworkFetcher * networkFetcher = [[HONetworkFetcher alloc] initWithURL:url];
    [_networkFetcher startWithCompletionHandler:^(NSData *data) {
        NSLog(@"Request URL %@ finished",networkFetcher.url)
        _fetchData = data;
    }];
}

​ 上面这种保留环,打破也很简单:

- (void)p_requestCompleted
{
    if (_completionHandler) {
        _completionHandler(_downloadedData);
    }
    self.completionHandler = nil;
}

  • 如若Block所捕获的对象直接或间接地保留了Block本身,那么就得担心保留环问题;
  • 一定要找个恰当的时机解除保留环,而不能把责任推给API的调用者。

2.4 多用派发队列,少用同步锁

​ 在Objective-C中,多线程执行同一份代码,使用锁来实现某种同步机制,在GCD之前,有两种办法:

​ 其一是“同步Block”:

- (void)synchronizedMethod
{
	//此处同步行为所针对的对象是self,会根据给定的对象自动创建一个锁,并等待Block中的代码执行完毕。执行到代码结尾处,锁就释放了。若对self对象频繁加锁,则会需要等到另一端与此无关的代码执行完毕才能继续执行当前的代码。
    @synchronized(self){
       //Safe
       //do whatever
    }
}

​ 另外一种就是:


_lock = [[NSlock alloc] init];
- (void)synchronizedMethod
{
    [_lock lock];
    //Safe
    [_lock unlock];
}

上面两种方法有其缺陷:极端情况下,都会导致死锁,其效率也不高。

替代方案就是:GCD。

_syncQueue = dispatch_queue_create("sync.queue", DISPATCH_QUEUE_SERIAL);

- (NSString*)someString {
    __block NSString *localSomeString;
  	  dispatch_sync(_syncQueue, ^{
         localSomeString = _someString;
    });
    return localSomeString;
}

- (void)setSomeString:(NSString *)someString {
    dispatch_sync(_syncQueue, ^{
       _someString = someString 
    });
}

​ 上面将保证所有读写的操作都在同一队列中,这相比上面加锁机制,更为高效(GCD基于底层的优化),也更为整洁(所有的同步在GCD中实现)。

​ 上面可以优化的就是,可以将取值方法,异步读取,串行队列里派发异步操作,会开启一个新线程来执行异步操作,而不是同步操作那样所有的操作在同一个线程。如下:

- (NSString*)someString {
    __block NSString *localSomeString;
    dispatch_async(_syncQueue, ^{
         localSomeString = _someString;
    });
    return localSomeString;
}

​ 但虽然是优化,不过有个优化陷进,就是执行异步派发是,需要拷贝Block。若拷贝Block的执行时间比Block执行所用的时间长,那么就是个“伪优化”,则比原来更慢。由于本例简单,所以改完之后可能更慢。

​ 多个获取方法可以并发执行,而获取方法与设置方法之间不能并发执行。可以采用栅栏函数,这次改用并发队列:

_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

- (NSString*)someString {
    __block NSString *localSomeString;
  	  dispatch_sync(_syncQueue, ^{
         localSomeString = _someString;
    });
    return localSomeString;
}

- (void)setSomeString:(NSString *)someString {
     dispatch_barrier_sync(_syncQueue, ^{
        _someString = someString 
    });
}

​ 下面是执行:

​ 并发队列如果发现接下来要处理的块是个栅栏块,那么就一直要等当前所有并发块都执行完毕,才会单独执行这个栅栏块,待栅栏块执行过后,再按正常方式继续向下处理。

​ 测试一下性能,这种做法比刚才的肯定更快。

​ 注意,设置函数也可以用同步的栅栏块来实现,那样做可能会更高效,因为异步需要拷贝代码块。

​ 要选方案,还是最好测一下实际的性能。

  • 派发队列可用来表述同步语义,这种做法要比使用@synchronizedNSLock对象更简单;
  • 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做不会阻塞执行异步派发的线程;
  • 使用同步队列及栅栏块,可以令同步行为更高效;

2.5 多用GCD,少用performSelector系列方法

​ NSObject中可以调用任何方法,最简单如下:

- (id)performSelector:(SEL)selector	

​ 如果选择子在运行期决定,就能体现出此方式的强大之处了。这就相当于在动态绑定上再次使用动态绑定:

SEL selector;
if(/*some condition */) {
    selector = @selector(bar);
} else if(/* some ohter condition */) {
    selector = @selector(foo);
} else {
    selector = @selector(baz);
}
[object performSelector:selector];

​ 使用此特性的代价是:如果在ARC下编译此代码 ,那么编译器会发出下面警告:

​ warning:performSelector may cause a leak because its selector is unknown [-Warc-performSelector-leaks]

​ 因为无法确定选择子,也就没有运用内存管理规则判断返回值是不是需要释放。ARC采用了比较谨慎的方法,就是不添加释放操作。然而这么做可能导致内存泄漏。下面是一个实例:

SEL selector;
if(/*some condition */) {
    selector = @selector(newObject);
} else if(/* some ohter condition */) {
    selector = @selector(copy);
} else {
    selector = @selector(someProperty);
}
id ret =[object performSelector:selector];

​ 这段代码,在执行第一个和第二个选择子时,需要释放ret对象,而第三个则不需要。但是这个问题很容易被忽视,或者用静态分析器也无法侦测到。

编者按:根据苹果的命名规则,第一个和第二个选择子创建对象时,会拥有对象的所有权,所以需要释放。

​ 其次,performSelector方法只返回id类型,即只能是void或者对象类型,而不能是整形等纯量类型。

​ 另外,还有几个performSelector方法如下:

- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
@interface NSObject (NSThreadPerformAdditions)

- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array;
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
	// equivalent to the first method with kCFRunLoopCommonModes

- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array NS_AVAILABLE(10_5, 2_0);
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait NS_AVAILABLE(10_5, 2_0);
	// equivalent to the first method with kCFRunLoopCommonModes
- (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg NS_AVAILABLE(10_5, 2_0);

@end

​ 然而,上面的延时执行都可以用dispatch_after来处理:

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        //todo
 });
  • performSelector系列方法在内存管理易有疏漏,它无法确定将要执行的选择子具体是什么,所以ARC编译器也就无法插入适当的内存管理方法;
  • performSelector系列方法所能处理的选择子太过局限,选择子返回值类型及发送方给方法的参数个数都受到限制;
  • 如果想延迟执行,最好不要用performSelector系列方法,而是应该把任务封装到Block里,调用GCD来实现。

2.6 掌握GCD及操作队列的使用时机

​ 使用NSOperationNSOperationQueue

  • 取消某个操作;
  • 指定操作间的依赖关系;
  • 通过键值观察机制监控NSOperationNSOperation对象许多属性都适合通过键值观察机制来监听,比如isCancelledisFinished
  • 指定操作的优先级;

2.7 使用dispatch_once来执行只需运行一次的线程安全代码

+ (instancetype)sharedManager {
    static HOClass *shared = nil;
    @synchronized (self) {
        if (!shared) {
            shared = [[self alloc] init];
        }
    }
    return self;
}

​ 更优的实现方式:

+ (instancetype)sharedManager {
    static HOClass *shared = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        shared = [[self alloc]init];
    });
    return shared;
}

dispatch_once可以简化代码并且彻底保证线程安全,此外更高效,它没有使用重量级的同步机制。

2.8 不要使用dispatch_get_current_queue

  • dispatch_get_current_queue函数的行为常常与开发者所预期的不同。此函数已经废弃,只应做调试只用。
  • 由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念;
  • dispatch_get_current_queue函数用于解决由不可重入代码说引发的死锁,然而此函数解决的问题,通常也能改用“队列特定数据”来解决。

2.9 通过Dispatch Group机制,根据系统资源状况来执行任务

dispatch_group能够把任务分组,调用者可以等待这组任务执行完毕,也可以提供回调函数之后继续往下执行,这组任务完成时,调用者会得到通知。

  • 一系列的任务可归入一个dispatch_group中,开发者可以在这组任务完毕时获得通知;
  • 通过dispatch_group,可以在并发时派发队列同时执行多项任务。此时GCD会根据系统资源状况来调度这些并发执行的任务。开发者若自己来实现此功能,则需要编写大量代码。