在本系列的第一部分中,你已经学到超过你想像的关于并发、线程以及GCD
如何工作的知识。通过在初始化时利用dispatch_once,你创建了一个线程安全的 PhotoManager
单例,而且你通过使用 dispatch_barrier_async 和dispatch_sync 的组合使得对
Photos 数组的读取和写入都变得线程安全了。
除了上面这些,你还通过利用 dispatch_after 来延迟显示提示信息,以及利用 dispatch_async
将 CPU 密集型任务从 ViewController 的初始化过程中剥离出来异步执行,达到了增强应用的用户体验的目的。
如果你一直跟着第一部分的教程在写代码,那你可以继续你的工程。但如果你没有完成第一部分的工作,或者不想重用你的工程,你可以下载第一部分最终的代码。
那就让我们来更深入地探索 GCD 吧!
纠正过早弹出的提示
你可能已经注意到当你尝试用 Le Internet 选项来添加图片时,一个
UIAlertView 会在图片下载完成之前就弹出,如下如所示:
问题的症结在 PhotoManagers 的 downloadPhotoWithCompletionBlock:
里,它目前的实现如下:
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
__block NSError *error;
for (NSInteger i = 0; i < 3; i++) {
NSURL *url;
switch (i) {
case 0:
url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
break;
case 1:
url = [NSURL URLWithString:kSuccessKidURLString];
break;
case 2:
url = [NSURL URLWithString:kLotsOfFacesURLString];
break;
default:
break;
}
Photo *photo = [[Photo alloc] initwithURL:url
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}
}];
[[PhotoManager sharedManager] addPhoto:photo];
}
if (completionBlock) {
completionBlock(error);
}
}
|
在方法的最后你调用了 completionBlock ——因为此时你假设所有的照片都已下载完成。但很不幸,此时并不能保证所有的下载都已完成。
Photo 类的实例方法用某个 URL 开始下载某个文件并立即返回,但此时下载并未完成。换句话说,当downloadPhotoWithCompletionBlock:
在其末尾调用 completionBlock 时,它就假设了它自己所使用的方法全都是同步的,而且每个方法都完成了它们的工作。
然而,-[Photo initWithURL:withCompletionBlock:] 是异步执行的,会立即返回——所以这种方式行不通。
因此,只有在所有的图像下载任务都调用了它们自己的 Completion Block 之后,downloadPhotoWithCompletionBlock:
才能调用它自己的 completionBlock 。问题是:你该如何监控并发的异步事件?你不知道它们何时完成,而且它们完成的顺序完全是不确定的。
或许你可以写一些比较 Hacky 的代码,用多个布尔值来记录每个下载的完成情况,但这样做就缺失了扩展性,而且说实话,代码会很难看。
幸运的是, 解决这种对多个异步任务的完成进行监控的问题,恰好就是设计 dispatch_group 的目的。
Dispatch Groups(调度组)
Dispatch Group 会在整个组的任务都完成时通知你。这些任务可以是同步的,也可以是异步的,即便在不同的队列也行。而且在整个组的任务都完成时,Dispatch
Group 可以用同步的或者异步的方式通知你。因为要监控的任务在不同队列,那就用一个 dispatch_group_t
的实例来记下这些不同的任务。
当组中所有的事件都完成时,GCD 的 API 提供了两种通知方式。
第一种是 dispatch_group_wait ,它会阻塞当前线程,直到组里面所有的任务都完成或者等到某个超时发生。这恰好是你目前所需要的。
打开 PhotoManager.m,用下列实现替换 downloadPhotosWithCompletionBlock::
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // 1
__block NSError *error;
dispatch_group_t downloadGroup = dispatch_group_create(); // 2
for (NSInteger i = 0; i < 3; i++) {
NSURL *url;
switch (i) {
case 0:
url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
break;
case 1:
url = [NSURL URLWithString:kSuccessKidURLString];
break;
case 2:
url = [NSURL URLWithString:kLotsOfFacesURLString];
break;
default:
break;
}
dispatch_group_enter(downloadGroup); // 3
Photo *photo = [[Photo alloc] initwithURL:url
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}
dispatch_group_leave(downloadGroup); // 4
}];
[[PhotoManager sharedManager] addPhoto:photo];
}
dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER); // 5
dispatch_async(dispatch_get_main_queue(), ^{ // 6
if (completionBlock) { // 7
completionBlock(error);
}
});
});
}
|
按照注释的顺序,你会看到:
因为你在使用的是同步的 dispatch_group_wait ,它会阻塞当前线程,所以你要用
dispatch_async 将整个方法放入后台队列以避免阻塞主线程。
创建一个新的 Dispatch Group,它的作用就像一个用于未完成任务的计数器。
dispatch_group_enter 手动通知 Dispatch Group
任务已经开始。你必须保证 dispatch_group_enter 和dispatch_group_leave
成对出现,否则你可能会遇到诡异的崩溃问题。
手动通知 Group 它的工作已经完成。再次说明,你必须要确保进入 Group
的次数和离开 Group 的次数相等。
dispatch_group_wait 会一直等待,直到任务全部完成或者超时。如果在所有任务完成前超时了,该函数会返回一个非零值。你可以对此返回值做条件判断以确定是否超出等待周期;然而,你在这里用
DISPATCH_TIME_FOREVER 让它永远等待。它的意思,勿庸置疑就是,永-远-等-待!这样很好,因为图片的创建工作总是会完成的。
此时此刻,你已经确保了,要么所有的图片任务都已完成,要么发生了超时。然后,你在主线程上运行completionBlock
回调。这会将工作放到主线程上,并在稍后执行。
最后,检查 completionBlock 是否为 nil,如果不是,那就运行它。
编译并运行你的应用,尝试下载多个图片,观察你的应用是在何时运行 completionBlock
的。
注意:如果你是在真机上运行应用,而且网络活动发生得太快以致难以观察 completionBlock
被调用的时刻,那么你可以在 Settings 应用里的开发者相关部分里打开一些网络设置,以确保代码按照我们所期望的那样工作。只需去往
Network Link Conditioner 区,开启它,再选择一个 Profile,“Very Bad
Network” 就不错。
如果你是在模拟器里运行应用,你可以使用 来自 GitHub 的 Network
Link Conditioner 来改变网络速度。它会成为你工具箱中的一个好工具,因为它强制你研究你的应用在连接速度并非最佳的情况下会变成什么样。
目前为止的解决方案还不错,但是总体来说,如果可能,最好还是要避免阻塞线程。你的下一个任务是重写一些方法,以便当所有下载任务完成时能异步通知你。
在我们转向另外一种使用 Dispatch Group 的方式之前,先看一个简要的概述,关于何时以及怎样使用有着不同的队列类型的
Dispatch Group :
自定义串行队列:它很适合当一组任务完成时发出通知。
主队列(串行):它也很适合这样的情况。但如果你要同步地等待所有工作地完成,那你就不应该使用它,因为你不能阻塞主线程。然而,异步模型是一个很有吸引力的能用于在几个较长任务(例如网络调用)完成后更新
UI 的方式。
并发队列:它也很适合 Dispatch Group 和完成时通知。
Dispatch Group,第二种方式
上面的一切都很好,但在另一个队列上异步调度然后使用 dispatch_group_wait
来阻塞实在显得有些笨拙。是的,还有另一种方式……
在 PhotoManager.m 中找到 downloadPhotosWithCompletionBlock:
方法,用下面的实现替换它:
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
// 1
__block NSError *error;
dispatch_group_t downloadGroup = dispatch_group_create();
for (NSInteger i = 0; i < 3; i++) {
NSURL *url;
switch (i) {
case 0:
url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
break;
case 1:
url = [NSURL URLWithString:kSuccessKidURLString];
break;
case 2:
url = [NSURL URLWithString:kLotsOfFacesURLString];
break;
default:
break;
}
dispatch_group_enter(downloadGroup); // 2
Photo *photo = [[Photo alloc] initwithURL:url
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}
dispatch_group_leave(downloadGroup); // 3
}];
[[PhotoManager sharedManager] addPhoto:photo];
}
dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ // 4
if (completionBlock) {
completionBlock(error);
}
});
}
|
下面解释新的异步方法如何工作:
在新的实现里,因为你没有阻塞主线程,所以你并不需要将方法包裹在 async
调用中。
同样的 enter 方法,没做任何修改。
同样的 leave 方法,也没做任何修改。
dispatch_group_notify 以异步的方式工作。当 Dispatch
Group 中没有任何任务时,它就会执行其代码,那么completionBlock 便会运行。你还指定了运行
completionBlock 的队列,此处,主队列就是你所需要的。
对于这个特定的工作,上面的处理明显更清晰,而且也不会阻塞任何线程。
太多并发带来的风险
既然你的工具箱里有了这些新工具,你大概做任何事情都想使用它们,对吧?
看看 PhotoManager 中的 downloadPhotosWithCompletionBlock
方法。你可能已经注意到这里的 for 循环,它迭代三次,下载三个不同的图片。你的任务是尝试让 for 循环并发运行,以提高其速度。
dispatch_apply 刚好可用于这个任务。
dispatch_apply 表现得就像一个 for 循环,但它能并发地执行不同的迭代。这个函数是同步的,所以和普通的
for 循环一样,它只会在所有工作都完成后才会返回。
当在 Block 内计算任何给定数量的工作的最佳迭代数量时,必须要小心,因为过多的迭代和每个迭代只有少量的工作会导致大量开销以致它能抵消任何因并发带来的收益。而被称为跨越式(striding)的技术可以在此帮到你,即通过在每个迭代里多做几个不同的工作。
译者注:大概就能减少并发数量吧,作者是提醒大家注意并发的开销,记在心里!
那何时才适合用 dispatch_apply 呢?
自定义串行队列:串行队列会完全抵消 dispatch_apply 的功能;你还不如直接使用普通的
for 循环。
主队列(串行):与上面一样,在串行队列上不适合使用 dispatch_apply
。还是用普通的 for 循环吧。
并发队列:对于并发循环来说是很好选择,特别是当你需要追踪任务的进度时。
回到 downloadPhotosWithCompletionBlock:
并用下列实现替换它
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
__block NSError *error;
dispatch_group_t downloadGroup = dispatch_group_create();
dispatch_apply(3, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^(size_t i) {
NSURL *url;
switch (i) {
case 0:
url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
break;
case 1:
url = [NSURL URLWithString:kSuccessKidURLString];
break;
case 2:
url = [NSURL URLWithString:kLotsOfFacesURLString];
break;
default:
break;
}
dispatch_group_enter(downloadGroup);
Photo *photo = [[Photo alloc] initwithURL:url
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}
dispatch_group_leave(downloadGroup);
}];
[[PhotoManager sharedManager] addPhoto:photo];
});
dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{
if (completionBlock) {
completionBlock(error);
}
});
}
|
你的循环现在是并行运行的了;在上面的代码中,在调用 dispatch_apply 时,你用第一次参数指明了迭代的次数,用第二个参数指定了任务运行的队列,而第三个参数是一个
Block。
要知道虽然你有代码保证添加相片时线程安全,但图片的顺序却可能不同,这取决于线程完成的顺序。
编译并运行,然后从 “Le Internet” 添加一些照片。注意到区别了吗?
在真机上运行新代码会稍微更快的得到结果。但我们所做的这些提速工作真的值得吗?
实际上,在这个例子里并不值得。下面是原因:
1.你创建并行运行线程而付出的开销,很可能比直接使用 for 循环要多。若你要以合适的步长迭代非常大的集合,那才应该考虑使用
dispatch_apply。
2.你用于创建应用的时间是有限的——除非实在太糟糕否则不要浪费时间去提前优化代码。如果你要优化什么,那去优化那些明显值得你付出时间的部分。你可以通过在
Instruments 里分析你的应用,找出最长运行时间的方法。看看 如何在 Xcode 中使用 Instruments
可以学到更多相关知识。
3.通常情况下,优化代码会让你的代码更加复杂,不利于你自己和其他开发者阅读。请确保添加的复杂性能换来足够多的好处。
记住,不要在优化上太疯狂。你只会让你自己和后来者更难以读懂你的代码。
GCD 的其他趣味
等一下!还有更多!有一些额外的函数在不同的道路上走得更远。虽然你不会太频繁地使用这些工具,但在对的情况下,它们可以提供极大的帮助。
阻塞——正确的方式
这可能听起来像是个疯狂的想法,但你知道 Xcode 已有了测试功能吗?:]
我知道,虽然有时候我喜欢假装它不存在,但在代码里构建复杂关系时编写和运行测试非常重要。
Xcode 里的测试在 XCTestCase 的子类上执行,并运行任何方法签名以
test 开头的方法。测试在主线程运行,所以你可以假设所有测试都是串行发生的。
当一个给定的测试方法运行完成,XCTest 方法将考虑此测试已结束,并进入下一个测试。这意味着任何来自前一个测试的异步代码会在下一个测试运行时继续运行。
网络代码通常是异步的,因此你不能在执行网络获取时阻塞主线程。也就是说,整个测试会在测试方法完成之后结束,这会让对网络代码的测试变得很困难。也就是,除非你在测试方法内部阻塞主线程直到网络代码完成。
注意:有一些人会说,这种类型的测试不属于集成测试的首选集(Preferred
Set)。一些人会赞同,一些人不会。但如果你想做,那就去做。
导航到 GooglyPuffTests.m 并查看 downloadImageURLWithString:,如下:
- (void)downloadImageURLWithString:(NSString *)URLString
{
NSURL *url = [NSURL URLWithString:URLString];
__block BOOL isFinishedDownloading = NO;
__unused Photo *photo = [[Photo alloc]
initwithURL:url
withCompletionBlock:^(UIImage *image, NSError *error) {
if (error) {
XCTFail(@"%@ failed. %@", URLString, error);
}
isFinishedDownloading = YES;
}];
while (!isFinishedDownloading) {}
}
|
这是一种测试异步网络代码的幼稚方式。 While 循环在函数的最后一直等待,直到 isFinishedDownloading
布尔值变成 True,它只会在 Completion Block 里发生。让我们看看这样做有什么影响。
通过在 Xcode 中点击 Product / Test 运行你的测试,如果你使用默认的键绑定,也可以使用快捷键
?+U 来运行你的测试。
在测试运行时,注意 Xcode debug 导航栏里的 CPU 使用率。这个设计不当的实现就是一个基本的
自旋锁 。它很不实用,因为你在 While 循环里浪费了珍贵的 CPU 周期;而且它也几乎没有扩展性。
|