GCD学习(下)

143 阅读6分钟

欢迎来到GCD学习的下半段。

申明:本文章是抄写自原文,主要目的是为了学习,抄写一遍加深印象,中途可能加上自己的一些理解。如果感觉读者理解有问题请直接去查看原文。

如果文中有不对的地方,请在评论区指出,万分感谢。

原文地址

在本系列的第一部分中我已经学到了,并发,线程,以及GCD如果工作的知识。通过初始化时利用dispatch_once,创建了一个线程安全的PhotoManager单例。通过dispatch_barrier_async和dispatch_sync的组合使得对Photos数组的读取和写入都变的线程安全。

除了上面的你还是用dispatch_after来延迟展示提示信息。

如果你跟着第一部分教程在写代码,那么你可以继续你的工程,如果你没有完成你第一部分的工作,或者不想重用你的工作,你可以下载第一部分最终的代码

好了继续学习。

纠正弹窗过早弹出的问题

你可能已经发现,当你使用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,这是假设走到这里的时候图片已经加载完成。但是很不幸,此时并不能保证所有的下载都已完成。

我们应该怎么做?

我们应该在所有的下载任务都完成之后再调用completionBlock,问题是你怎么监控并发的异步事件?你不知道他们何时完成,而且他们完成的顺序是不确定的。

或许你可以写一些比较Hacky的代码,用多个Bool值来记录多个下载的完成情况,但这样就缺失了扩展性,而且说实话,代码会很难看。

幸运的是,解决这种,多个任务完成情况的监控问题,恰好就是设计dispatch_group的目的。swift的DispatchWorkItem也可以做同样的事情。

dispatch group(调度组)

dispatch_group会在整个组的任务都完成后通知你。这些任务可以是同步的,也可以是异步的。即使在不同的队列也行。而且在整个组的任务都完成时,Dispatch Group 可以通过同步的或者异步的方式通知你。因为要监控的任务在不同的队列,那就用一个dispatch_group_t的实例来记下这些不同的任务。

当组中所有的任务都完成时,GCD的API提供了两种通知方式。

第一种是dispatch_group_wait,他会阻塞当前线程,直到所有的任务都完成或者等到某个超时发生。这恰好是你目前所需要的。

打开PhotoManagers 并用下面的代码替换downloadPhotoWithCompletionBlock:

- (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);
            }
        });
    });
}

按照注释的顺序你会看到:

  1. 因为你是用的是dispatch_group_wait他会阻塞当前线程,所以你要用dispatch_async将整个方法放入后台队列,以避免阻塞主线程。
  2. 创建一个Dispatch Group,他的作用就像是一个用于未完成任务的计数器。
  3. dispatch_group_enter手动通知Dispatch Group任务已经开始,你必须保证dispatch_group_enterdispatch_group_leave成对出现,否则你会遇到诡异的崩溃问题。
  4. 手动通知Group他的任务已经完成。再次说明,你要确保进入Group和离开Group的次数相等。
  5. dispatch_group_wait会一直等待,直到任务全部完成或者等待超时。如果在所有的任务完成前超时了,该函数会返回一个非零值。你可以对此返回值做条件判断以确定是都超出等待周期。然而这里用了DISPATCH_TIME_FOREVER他会永远等待。他的意思毋庸置疑,永—远—等—待。这样很好,因为图片的创建工作总会完成。
  6. 此时你确保了,要么所有图片任务都已完成,要么发生了超时。然后,你在主线程上运行completionBlock回调,这会将工作放到主线程,并稍后执行。
  7. 最后检查 completionBlock是否为空,如果不为空执行它。

编译并运行你的应用,尝试下载多张图片,观察你的应用时何时运行completionBlock的。

注意:如果你是在真机上运行应用,而且网络活动发生得太快以致难以观察 completionBlock 被调用的时刻,那么你可以在 Settings 应用里的开发者相关部分里打开一些网络设置,以确保代码按照我们所期望的那样工作。只需去往 Network Link Conditioner 区,开启它,再选择一个 Profile,“Very Bad Network” 就不错。

如果你是在模拟器里运行应用,你可以使用 来自 GitHub 的 Network Link Conditioner 来改变网络速度。它会成为你工具箱中的一个好工具,因为它强制你研究你的应用在连接速度并非最佳的情况下会变成什么样。

目前解决方案还不错,但是总体来说,最好还是要避免线程阻塞。你的下一个任务是重写一些方法,以便在所有任务完成时异步通知你。

我们在使用另一种Dispatch Group之前,先看一个简要的概述。关于如何以及怎样使用不同队列类型的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);
        }
    });
}

下面解释新的异步方法如何工作:

  1. 因为在新的视线里,你并没有阻塞主线程,所以你并不需要将方法包裹在async调用中。
  2. 同样的 enter 方法,没做任何修改。
  3. 同样的 leave 方法,也没做任何修改。
  4. dispatch_group_notify以异步的方式工作,当Dispatch Group中没有任何任务时,他就是执行其代码。你还制定了执行completionBlock的队列,此时,主队列就是你所需要的。

对于这个特定的工作,上面处理明显更清晰,而且不会阻塞主线程。

太多并发带来的风险

既然你的工具箱里有这么多工具,你大概做任何事情都想使用它们,是吧?

看看 PhotoManager.m 中的 downloadPhotosWithCompletionBlock: 方法,里面有一个for循环,他迭代三次,下载三个不同的图片。你的任务是让for循环并发运行,以提高速度。

刚好dispatch_apply能做这样的事情。

dispatch_apply表现得就像一个for循环,它能并发的执行不同的迭代。这个函数式同步的,所以他和普通for循环一样,在只会在所有任务都完成后返回。

在Block内部计算给定数量的工作的最佳迭代数量是,必须要小心,过多的迭代和每个迭代只有少量的工作,会抵消任务并发对来的收益。而被称为跨越式(striding)的技术可以在此帮到你,即通过在每个迭代里多做几个不同的工作。

译者注:大概就能减少并发数量吧,作者是提醒大家注意并发的开销,记在心里!

那何时才适用dispatch_apply呢?

  • 自定义串行队列:串行队列会完全抵消dispatch_apply的功能,还不如直接使用普通的for循环呢。
  • 主队列(串行):和上面一样。还是使用普通的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” 添加一些照片。注意到区别了吗?

在真机上运行新代码会稍微更快的得到结果。但我们所做的这些提速工作真的值得吗?

实际上,在这个例子里并不值得。下面是原因:

  • 你创建并行线程所付出的开销,可能要比直接使用for循环要多。若你要以何时的步长迭代非常多的集合,那才应该考虑使用dispatch_apply
  • 你用于创建应用的时间是有限的,除非实在太糟糕,否者不要浪费时间提前去优化代码。如果你要优化,应当先去优化那些明显值得你的付出时间的部分。你可以通过instruments里分析你的应用,找出运行时间最长的方法。看看 如何在 Xcode 中使用 Instruments 可以学到更多相关知识。
  • 通常情况下,代码优化会让你的代码更加复杂,不利于你和其他开发者阅读。请确保添加的复杂度能带来足够多的收益。

记住,不要在代码优化上太疯狂,否者指回让你自己和后来者更加难读懂你的代码。

原文章还有一部分Test测试的内容,奈何看了两遍没看懂,所以不敢写。后续吧test测试弄懂之后,再记录一下学习过程。

原文地址