[iOS] Effective Objective-C ——block与GCD

37. 理解block这一概念

块与函数类似,只不过是直接定义在另一个函数里,和定义他的那个函数共享一个范围内的东西。块用“^”符号来表示,后面根这一对花括号,括号里面是块的实现代码。

^{
    //Block implementation here
}

块其实就是个值,而且有其相关类型。与int、float或Objective-C对象一样,也可以把块赋值给变量,然后像使用其他变量那样使用它。块类型的语法与函数指针近似。

void (^someBlock)() = ^{
    //Block implementation here
};

块类型的语法结构如下:
return_type (^block_name)(parameters)

块的强大之处是:在声明它的范围里,所有变量都可以为其所捕获。这也就是说,那个范围里的全部变量,在块里依然可用。

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

int add = addBlock(2, 5);

默认情况下,为块所捕获的变量,是不可以在块里修改的。在本例中,假如块内的代码改动了additional变量的值,那么编译器就会报错。不过,声明变量的时候可以加上__block修饰符,这样就可以在块内修改了。

块总能修改实例变量,所以在声明时无须加__block。不过,如果通过读取或写入操作捕获了实例变量,那么也会自动把self变量一并捕获了,因为实例变量是与self所指代的实例关联在一起的。(容易引起retain cycle哈)

注意即使block里面用下划线的方式访问实例变量,也是持有了self哈

如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放。这就引出了一个与块有关的重要问题。块本身可视为对象。实际上,在其他Objective-C对象所能相应的选择子中,有很多是块也可以响应的。而最重要之处则在于,块本身也和其他对象一样,有引用计数。当最后一个指向块的引用移走之后,块就回收了。回收时也会释放块所捕获的变量,以便平衡捕获时所执行的保留操作。


※ block的内部结构

block结构

在内存布局中,最重要的就是invoke变量,这是个函数指针,指向块的实现代码。函数原型至少要接受一个void*型的参数,此参数代表块。block其实就是一种代替函数指针的语法结构,原来使用函数指针时,需要用“不透明的void指针”来传递状态。而改用块之后,则可以把原来用标准C语言特性所编写的代码封装成简明且易用的接口。

descriptor变量是指向结构体的指针,每个块里都包含此结构体。块还把会它所捕获的所有变量都拷贝一份。这些拷贝放在descriptor变量后面,捕获了多少个变量,就要占据多少内存空间。请注意,拷贝的并不是对象本身,而是指向这些对象的指针变量。

对基本类型的变量,捕获意味着程序会拷贝变量的值,并用Block对象内的局部变量保存。对指针类型的变量,Block对象会使用强引用。这意味着凡是Block对象用到的对象,都会被保留。所以在相应的Block对象被释放前,这些对象一定不会被释放(这也是Block对象和函数之间的差别,函数无法做到这点)。

NSMutableString *str = [@"ssss" mutableCopy];
    
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    NSLog(@"str: %@", str);
});

str = [@"huihui" mutableCopy];

输出:
str: ssss

如果换成实例变量:

str = [@"ssss" mutableCopy];
    
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    NSLog(@"str: %@", self->str);
});

str = [@"huihui" mutableCopy];

输出:
str: huihui

鉴于实例变量在block里面可以修改,而且改了以后block里面可以感知更新,看起来好像对于实例变量block并没有复制到自己的内存里面。


※ 堆or栈?以及全局block

这部分和copy有点关联,我之前也写过:

总结一下大概就是MRC时代block是在栈里面的,函数执行完就会被释放掉,即使用了strong也没有拷贝到堆区,只是增加了指向,使用时可能会有野指针crash。

ARC下在生成的block也是栈块,只是当赋值给strong对象时,系统会主动对其进行copy,从栈区自动拷贝到堆区,所以其实只有两个区,全局区和堆区,不会出现野指针的问题,故而ARC用strong/copy没有太大区别。

---MRC分割线---

定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义他的那个范围内有效。例如,下面这段代码就有危险:(这里其实我觉得好像木有问题诶,毕竟block声明的作用域没有过,是不会出栈的叭)

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

因为block的定义在stack里面的时候,定义的有效期只在if{}或者else{}里面,退出大括号的时候会做出栈的操作。于是,只能保证在对应的if或else语句范围内block定义有效。这样写出来的代码可以编译,但是运行起来时而正确,时而错误。若编译器未覆写待执行的块,则程序照常运行,若覆写,则程序崩溃。

为解决此问题,可给块对象发行copy消息以拷贝之。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象了。后续的复制操作都不会真的执行复制,只是递增对象的引用计数。

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

---全局block分割线---

除了“栈块”和“堆块”之外,还有一类块叫做“全局块”(global block)。这种块不会捕捉任何状态(比如外围的变量等),运行时也无须有状态来参与。块所使用的整个内存区域,在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。另外,全局块的拷贝操作是个空操作,因为全局块绝不可能为系统所回收。这种块实际上相当于单例。

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

由于运行该块所需的全部信息都能在编译期确定,所以可把它做成全局块。这几种block的区分可以参考:https://www.jianshu.com/p/0900fa7029a7

  • 如果一个block中引用了全局变量,或者没有引用任何外部变量(属性、实例变量、局部变量),那么该block为全局块。
  • 其它引用情况(局部变量,实例变量,属性)为栈块。

38. 为常用的block类型创建typedef

每个块都具备其“固有类型”,因而可将其赋给适当类型的变量。这个类型由块所接受的参数及其返回值组成。

int (^variableName) (BOOL flag, int value) = ^(BOOL flag, int value) {
    return value + 1;
};

块类型语法:

return_type (^block_name) (parameters)

为隐藏复杂的块类型,用C语言中“类型定义”的特性,typedef关键字给类型起个易读的别名。

typedef int (^EOCSomeBlock) (BOOL flag, int value);

上面是向系统中新增一个名为EOCSomeBlock的类型。
// 使用新类型
EOCSomeBlock block = ^(BOOL flag, int value) {
    return value + 1;
};

使用块的API:

- (void)startWithCompletionHandler:(void (^)(NSData *data, NSError *error))completion;

使用typedef修改后:
typedef void(^EOCCompletionHandler)(NSData *data, NSError *error);

- (void)startWithCompletionHandler:(EOCCompletionHandler)completion;

这样的话将来想加减传入的参数都很方便,不用把所有代码中用到块的地方都改掉,只要该typedef就好啦。

如果block的签名相同,用途不同,不妨为同一个块签名定义多个类型别名。如果要重构的代码使用了块类型的某个别名,那么只需修改相应typedef中的块签名即可,无须改动其他typedef,例如:

typedef void (^ACAccountStoreSaveCompletionHandler)(BOOL success, NSError *error);
typedef void (^ACAccountStoreRequestAccessCompletionHandler)(BOOL granted, NSError *error);

39. 用handler块降低代码分散程度

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

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

如果有success也有failure情况的时候,最好用一个handler处理。

推荐:
NSURL *url = [[NSURL alloc] initWithString:@"http:www.baidu.com"];
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data) {
    _fetchedFooData = data;
}];

================
不推荐:
typedef void (^EOCNetworkFetcherCompletionHandler)(NSData *data);
typedef void (^EOCNetworkFetcherErrorHandler)(NSError *error);

@interface EOCNetworkFetcher : NSObject

- (id)initWithURL:(NSURL *)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion failureHandler:(EOCNetworkFetcherErrorHandler)failure;

@end

EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data, NSError *error) {
    if (error) {
        //Handler failure
    } else {
        //Handler success
    }
}];

主要是放到一个里面更加灵活,交给调用者更多空间,他可以自己拿到数据判断要怎么处理,可能他认为的success和API提供者认为的是不一样的。

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

某些代码必须运行在特定的线程上。因此,最好能由调用API的人来决定handler应该运行在那个线程上。NSNotificationCenter就属于这种API,它提供了一个方法,调用者可以经由此方法来注册想要接收的通知,等到相关事件发生时,通知中心就会执行注册好的那个块。调用者可以指定某个块应该安排在哪个执行队列里,然而这不是必需的。若没有指定队列,按默认方式执行。

- (id )addObserverForName:(nullable NSString *)name object:(nullable id)obj queue:(nullable NSOperationQueue *)queue usingBlock:(void (^)(NSNotification *note))block

40. 用block引用其所属对象时不要出现retain cycle

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

41. 多用派发队列,少用同步锁

※ synchronized

在Objective-C中,如果有多个线程要执行同一份代码,那么有时可能会出问题。这种情况下,通常要使用锁来实现某种同步机制。在GCD出现之前,有两种办法,第一种是采用内置的“同步块”(synchronization block):

- (void)synchronizedMethod
{
    @synchronized (self) {
        //Safe
    }
}

这种写法会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。在本例中,同步行为所针对的对象是self。这么写通常没错,因为它可以保证每个对象实例都能不受干扰地运行其synchronizedMethod方法。然而,滥用@synchronized (self)则会降低代码效率,因为共用同一个锁的那些同步块,都必须按顺序执行。

※ NSLock、NSRecursiveLock

另一个办法是直接使用NSLock对象:

_lock = [[NSLock alloc]init];

- (void)synchronizedMethod
{
    [_lock lock];
    //Safe
    [_lock unlock];
}

但是NSLock容易产生死锁,例如下面这样,第二次lock因为第一个还没有释放,永远拿不到锁,于是NSLog也执行不到:

NSLock *lock = [[NSLock alloc] init];
[lock lock];
[lock lock];
NSLog(@"发生了死锁");
[lock unlock];
[lock unlock];

可以使用NSRecursiveLock这种“递归锁”(recursize lock),线程能够多次持有该锁,而不会出现死锁(deadlock)现象,可参考:https://www.jianshu.com/p/777c28eface5

NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];
[lock lock];
[lock lock];
NSLog(@"没有死锁");
[lock unlock];
[lock unlock];

它可以允许同一线程多次加锁,而不会造成死锁。递归锁会跟踪它被lock的次数。每次成功的lock都必须平衡调用unlock操作。只有所有达到这种平衡,锁最后才能被释放,以供其它线程使用。


这两种方法都很好,不过也有其缺陷。比方说,在极端情况下,同步块会导致死锁,另外,其效率也不见得很高,而如果直接使用锁对象的话,一旦遇到死锁,就会非常麻烦。

- (NSString *)something{
  @synchronized (self) {
    return _something;
  }
}

- (void)setSomething:(NSString *)something{
  @synchronized (self) {
    _something = something;
  }
}

刚才说过,滥用@synchronized (self)会很危险,因为所有同步块都会彼此抢夺同一个锁。要是有很多个属性都这么写的话,那么每个属性的同步块都要等其他所有同步块执行完毕才能执行。这也许并不是开发者想要的效果。我们只是想令每个属性各自独立地同步。


※ GCD改写

(1)串行队列+同步等待

_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NULL);

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


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

(2)串行队列+异步设置
设置方法并不一定非得是同步的。设置实例变量所有的块,并不需要向设置方法返回什么值。

- (void)setSomeString:(NSString *)someString
{
    dispatch_async(_syncQueue, ^{
        self.someString = someString;
    });
}

这次只是把同步派发改成了异步派发,从调用者的角度来看,这个小改动可以提升设置方法的执行速度,而读取操作与写入操作依然会按顺序执行。但这么该有个坏处:可能会发现这种写法比原来慢,因为执行异步派发时,需要拷贝块。弱拷贝块所用的时间明显超过执行块所花的时间,则这种做法将比原来更慢。然而,若是派发给队列的块要执行更为繁重的任务,那么仍然可以考虑这种备选方法。

(3)并行队列+同步等待+栅栏任务
多个获取方法可以并发执行,而获取方法与设置方法之间不能并发执行,利用这个特点,还能写出更快一些的代码来。

_syncQueue = dispatch_queue_create(DISPATCH_QUEUE_PRIORITY_DEFAULT, NULL);

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

- (void)setSomeString:(NSString *)someString
{
    dispatch_barrier_async(_syncQueue, ^{
        self.someString = someString;
    });
}

测试一下性能,你就会发现,这种做法肯定比使用串行队列要快。

42. 多用GCD,少用performSelector系列方法

NSObject定义了几个方法,令开发者可以随意调用任何方法。这几个方法可以推迟执行方法调用,也可以指定运行方法所用的线程。这些功能原来很有用,但是在出现了大中枢派发及块这样的新技术之后,就显得不那么必要了。虽说有些代码还是会经常用到它们,但笔者劝你还是避开为妙。
这其中最简单的是performSelector:。该方法与直接调用选择子等效。所以下面两行代码的执行效果相同:

[self performSelector:@selector(selectorName)];
[self selectorName];

如果选择子是在运行期决定的,那么就能体现出此方式的强大之处了。这就等于在动态绑定之上再次使用动态绑定,因而可以实现出下面这种功能:

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

编译器并不知道将要调用的选择子是什么,因此也就不了解其方法签名及返回值,甚至连是否有返回值都不清楚。而且,由于编译器不知道方法名,所以就没办法运用ARC的内存管理规则来判定返回值是不是应该释放,鉴于此,ARC采用了比较谨慎的做法,就是不添加释放操作。然而这么做可能导致内存泄漏,因为方法在返回对象时 可能已经将其保留了。

如果调用的是两个选择子之一,那么ret对象应由这段代码来释放,如果是第三个选择子,则无须释放。这个问题很容易忽视,而且就算用静态分析器,也很难侦测到随后的内存泄漏。performSelector系列的方法之所以要谨慎使用,这就是其中一个原因。

而且,performSelector方法的返回值类型毕竟是id。如果想返回整数或浮点数等类型的值,那么就需要执行一些复杂的转换操作了,而这种转换很容易出错。而且同系列方法很多参数类型是id,所以传入的参数必须是对象才行。如果选择子所接受的参数是整数或浮点数,那就不能采用这些方法了,例如:

- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;

最主要的替代方案就是使用block。而且,performSelector系列方法所提供的线程功能,都可以通过在大中枢派发机制中使用块来实现。延后执行可以用dispatch_after来实现,在另一个线程上执行任务则可通过dispatch_sync及dispatch_async来实现。

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

GCD是纯C的API,而NSOperation与NSOperationQueue是基于 GCD 更高一层的封装,是Objective-C的对象,但其实NSOperation的底层是用GCD来实现的。

GCD技术的同步机制非常优秀,对于那些只需执行一次的代码来说,使用GCD最方便。但在执行后台任务时,还可以使用操作队列(NSOperationQueue)。

操作队列的优势:

  • 运行任务之前,可以在NSOperation对象上调用cancel方法,即可取消操作,不过,已经启动的任务无法取消,而GCD把块安排到队列就无法取消。
  • 可以指定操作间的依赖关系,使特定操作必须在另一个操作执行完毕后方可执行。
  • 可以通过KVO(键值观察)来监控NSOperation对象的属性变化(isCancelled,isFinished等)
  • 可以指定操作的优先级
  • 可以通过重用NSOperation对象来实现更丰富的功能
  • 可以设定并发数限制(自己的经验哈)

区别还可参考:https://blog.csdn.net/Setoge/article/details/52134247

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

串行队列用dispatch_async其实就可以监测之前的任务都完成了,不用偏要dispatch_group_notify。

dispatch_apply会循环执行指定次数,但是会阻塞,可能会引发死锁。

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

标准单例写法:(参考:https://www.jianshu.com/p/96fa3c93df19)

#import "MySingle2.h"

@implementation MySingle2
+(instancetype)shareInstance
{
    static MySingle2 *_mySingle = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _mySingle = [[super allocWithZone:NULL] init];
    });
    return _mySingle;
}

+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
    return [self shareInstance];
}

- (id)copyWithZone:(NSZone *)zone
{
    return self;
}

- (id)mutableCopyWithZone:(NSZone *)zone
{
    return self;
}

当我们调用alloc方法时(为了方式外部调用alloc init而不调用sharedInstance),OC内部会调用allocWithZone这个方法来申请内存,我们覆写这个方法,然后在这个方法中调用shareInstance方法返回单例对象,这样就可以达到我们的目的。

由于每次调用时都必须使用完全相同的标记,所以标记要声明成static。把该变量定义在static作用域中,可以保证编译器在每次执行shareInstance方法时都会复用这个变量,而不会创建新变量。dispatch_once采用“原子访问”(atomic access)来查询标记,以判断其所对应的代码原来是否已经执行过。

注意如果用以下的方式,first和second都会打印滴:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSLog(@"first");
    });
}

- (void)viewWillAppear:(BOOL)animated {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSLog(@"second");
    });
}

46. 不要使用dispatch_get_current_queue

该函数有种典型的错误用法(antipattern, “反模式”),就是用它检测当前队列是不是某个特定的队列,试图以此来避免执行同步派发时可能遭遇的死锁问题。

但这并不靠谱,例如:

-(void)demo2{
    dispatch_queue_t queueA = dispatch_queue_create("com.sky.queueA", NULL);
    dispatch_queue_t queueB = dispatch_queue_create("com.sky.queueB", NULL);
    
    dispatch_sync(queueA, ^{
        dispatch_sync(queueB, ^{
            dispatch_block_t block = ^{};
            if (dispatch_get_current_queue() == queueA) {
                block();
            } else {
                dispatch_sync(queueA, block);
            }
        });
    });
}

dispatch_get_current_queue获取到的当前队列是queueB,所以结果依然执行针对queueA的同步派发操作,依然死锁。

正确做法是:不要把存取方法做成可重入的,而是应该确保操作同步操作所用的队列绝不会访问属性,也就是绝对不会调用 someString 方法。


此外,队列之间会形成一套层级体系,这意味着排在某条队列中的块,会在其上级队列(parent queue,也叫“父队列”)里执行。层级里地位最高的那个队列总是 “全局并发队列”(global concurrentqueue)图描绘了一套简单的队列体系。

[iOS] Effective Objective-C ——block与GCD_第1张图片
队列层级

排在队列B或队列C中的块,稍后会在队列A里依序执行。于是,排在队列A、B、C 中的块总是要彼此错开执行。然而,安排在队列D 中的块,则有可能与队列A 里的块(也包括队列B 与 队列C 里的块)并行,因为A 与 D 的目标队列是个并发队列。若有必要,并发队列可以用多个线程并行执行多个块,而是否会这样做,则需要根据 CPU 的核心数量等系统资源状况来定。

由于队列间有层级关系,所以 “检查当前队列是否为执行同步派发所用的队列”这种办法,并不总是奏效。


※ dispatch_queue_set_specific标识当前队列

要解决这个问题,最好的办法就是通过 GCD 所提供的功能来设定“队列特有数据”(queue-specific data),此功能可以把任意数据以键值对的形式关联到队列里。最重要之处在于,假如根据指定的键获取不到关联数据,那么系统就会沿着层级体系向上查找,直至找到数据或到达根队列为止。笔者这么说,大家也许还不太明白其用法,所以看下面这个例子:

dispatch_queue_t queueA = dispatch_queue_create("com.sky.queueA", NULL);
dispatch_queue_t queueB = dispatch_queue_create("com.sky.queueB", NULL);
dispatch_set_target_queue(queueB, queueA);

static int specificKey;
CFStringRef specificValue = CFSTR("queueA");
dispatch_queue_set_specific(queueA,
                            &specificKey,
                            (void*)specificValue,
                            (dispatch_function_t)CFRelease);

dispatch_sync(queueB, ^{
    dispatch_block_t block = ^{
            //do something
    };
    CFStringRef retrievedValue = dispatch_get_specific(&specificKey);
    if (retrievedValue) {
        block();
    } else {
        dispatch_sync(queueA, block);
    }
});

此函数的首个参数表示待设置数据队列,其后面两个参数是键与值。键与值都是不透明的void 指针。对于键来说,有个问题一定要注意:函数是按指针值来比较键的,而不是按照其内容。所以,“队列特定数据”的行为与 NSDictionary 对象不同,后者是比较键的 “对象等同性”。“队列特定数据”更像是关联引用。值(在函数原型里叫做 “context”(中文称为“上下文”、“语境”、“环境参数”等))也是不透明的void 指针,于是可以在其中存放任意数据。然而,必须管理该对象的内存。这使得在ARC 环境下很难使用Objective-C 对象作为值。范例代码使用 coreFoundation 字符串作为值,因为ARC 并不会自动管理CoreFoundation 对象的内存。所以说,这种对象非常适合充当“队列特定数据”,它们可以根据需要与相关的Objective-C Foundation 类无缝衔接。

函数的最后一个参数是“析构函数”(destructor function),对于给定的键来说,当队列所占内存为系统所回收,或者有新的值与键相关联时,原有的值对象就会移除,而析构函数也会于此时运行。dispatch_function_t 类型的定义如下:

typedef void (*dispatch_function_t) (void *)

由此可知,析构函数只能带有一个指针参数且返回值必须为 void。范例代码采用 CFRelease 做析构函数,此函数符合要求,不过也可以采用开发者自定义的函数,在其中调用 CFRelease 以清理旧值,并完成其他必要的清理工作。

你可能感兴趣的:([iOS] Effective Objective-C ——block与GCD)