Grand Central Dispatch In-Depth: Part 2/2

Derek Selander
Learn about concurrency in this Grand Central Dispatch in-depth tutorial series.

Learn about concurrency in this Grand Central Dispatch in-depth tutorial series.

Update note: Check out our updated version of this Grand Central Dispatch tutorial in Swift and running on iOS 8!

Welcome to the second and final part of this Grand Central Dispatch in-depth tutorial series!

In the first part of this series, you learned way more than you ever imagined about concurrency, threading, and how GCD works. You made the PhotoManager singleton thread safe for instantiating using dispatch_once and made the reading and writing of Photos thread safe using a combination of dispatch_barrier_async and dispatch_sync.

In addition to all that, you enhanced the UX of the app through the timing of a prompt with dispatch_after, and offloaded the work from the instantiation of a view controller to perform a CPU intensive task with dispatch_async.

If you have been following along, you can pick up where you left off with the sample project form Part 1. If you haven’t completed Part 1 or don’t want to reuse your project you can download the finished project from the first part of this tutorial here.

It’s time to explore some more GCD!

Correcting the Premature Popup

You may have noticed that when you try to add photos with the Le Internet option, a UIAlertView pops up well before the images have finished downloading, as shown in the screenshot below:

Premature Completion Block

The fault lies with PhotoManagers’s downloadPhotoWithCompletionBlock: which has been reproduced below:

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

Here you call the completionBlock at the end of the method — you're assuming all of the photo downloads have completed. But unfortunately, there's no guarantee that all the downloads have finished by this point.

The Photo class's instantiation method starts downloading a file from a URL and returns immediately before the download completes. In other words, downloadPhotoWithCompletionBlock: calls its own completion block at the end, as if its own method body were all straight-line synchronous code and every method call that completed had finished its work.

However, -[Photo initWithURL:withCompletionBlock:] is asynchronous and returns immediately — so this approach won't work.

Instead, downloadPhotoWithCompletionBlock: should call its own completion block only after all the image download tasks have called their own completion blocks. The question is: how do you monitor concurrent asynchronous events? You don’t know when they will complete, and they can finish in any order.

Perhaps you could write some hacky code that uses multiple BOOLs to keep track of each download, but that doesn't scale well, and frankly, it makes for pretty ugly code.

Fortunately, this type of multiple asynchronous completion monitoring is exactly what dispatch groups were designed for.

Dispatch Groups

Dispatch groups notify you when an entire group of tasks completes. These tasks can be either asynchronous or synchronous and can even be tracked from different queues. Dispatch groups also notify you in synchronous or asynchronous fashion when all of the group's events are complete. Since items are being tracked on different queues, an instance of dispatch_group_t keeps track of the different tasks in the queues.

The GCD API provides two ways to be notified when all events in the group have completed.

The first one, dispatch_group_wait, is a function that blocks your current thread and waits until either all the tasks in the group have completed, or until a timeout occurs. This is exactly what you want in this case.

Open PhotoManager.m and replace downloadPhotosWithCompletionBlock: with the following implementation:

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

Taking each numbered comment in turn, you'll see the following:

  1. Since you're using the synchronous dispatch_group_wait which blocks the current thread, you use dispatch_async to place the entire method into a background queue to ensure you don't block the main thread.
  2. This creates a new dispatch group which behaves somewhat like a counter of the number of uncompleted tasks.
  3. dispatch_group_enter manually notifies a group that a task has started. You must balance out the number of dispatch_group_enter calls with the number of dispatch_group_leave calls or else you'll experience some weird crashes.
  4. Here you manually notify the group that this work is done. Again, you're balancing all group enters with an equal amount of group leaves.
  5. dispatch_group_wait waits until either all of the tasks are complete or until the time expires. If the time expires before all events complete, the function will return a non-zero result. You could put this into a conditional block to check if the waiting period expired; however, in this case you specified for it to wait forever by supplying DISPATCH_TIME_FOREVER. This means, unsurprisingly, it'll wait, forever! That's fine, because the completion of the photos creation will always complete.
  6. At this point, you are guaranteed that all image tasks have either completed or timed out. You then make a call back to the main queue to run your completion block. This will append work onto the main thread to be executed at some later time.
  7. Finally, check if the completion block is nil, and if not, run the completion block.

Build and run your app, attempt to download multiple images and notice how your app behaves with the completion block in place.

Note: If the network activities occur too quickly to discern when the completion block should be called and you're running the app on a device, you can make sure this really works by toggling some network settings in the Developer Section of the Settings app. Just go to the Network Link Conditioner section, enable it, and select a profile. "Very Bad Network" is a good choice.

If you're are running on the Simulator, you can use a network link conditioner from GitHub to change your network speed. This is a good tool to have in your arsenal because it forces you to be conscious of what happens to your apps when connection speeds are less than optimal.

This solution is good so far, but in general it's best to avoid blocking threads if at all possible. Your next task is to rewrite the same method to notify you asynchronously when all the downloads have completed.

Before we head on to another use of dispatch groups, here's a brief guide on when and how to use dispatch groups with the various queue types:

  • Custom Serial Queue: This is a good candidate for notifications when a group of tasks completes.
  • Main Queue (Serial): This is a good candidate as well in this scenario. You should be wary of using this on the main queue if you are waiting synchronously for the completion of all work since you don't want to hold up the main thread. However, the asynchronous model is an attractive way to update the UI once several long-running tasks finish such as network calls.
  • Concurrent Queue: This as well is a good candidate for dispatch groups and completion notifications.

Dispatch groups, take two

That's all well and good, but it's a bit clumsy to have to dispatch asynchronously onto another queue and then block using dispatch_group_wait. There's another way...

Find downloadPhotosWithCompletionBlock: in PhotoManager.m and replace it with this implementation:

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

Here's how your new asynchronous method works:

  1. In this new implementation you don't need to surround the method in an async call since you're not blocking the main thread.
  2. This is the same enter method; there aren't any changes here.
  3. This is the same leave method; there aren't any changes here either.
  4. dispatch_group_notify serves as the asynchronous completion block. This code executes when there are no more items left in the dispatch group and it's the completion block's turn to run. You also specify on which queue to run your completion code, here, the main queue is the one you want.

This approach is much cleaner way to handle this particular job and doesn't block any threads.

The Perils of Too Much Concurrency

With all of these new tools at your disposal, you should probably thread everything, right!?

Thread_All_The_Code_Meme

Take a look at downloadPhotosWithCompletionBlock in PhotoManager. You might notice that there's a for loop in there that cycles through three iterations and downloads three separate images. Your job is to see if you can run this for loop concurrently to try and speed it up.

This is a job for dispatch_apply.

dispatch_apply acts like a for loop which executes different iterations concurrently. This function is sychronous, so just like a normal for loop, dispatch_apply returns only when all of the work is done.

Care must be taken when figuring out the optimal amount of iterations for any given amount of work inside the block, since many iterations and a small amount of work per iteration can create so much overhead that it negates any gains from making the calls concurrent. The technique known as striding helps you out here. This is where for each iteration you do multiple pieces of work.

When is it appropriate to use dispatch_apply?

  • Custom Serial Queue: A serial queue would completely negate the use of dispatch_apply; you might as well just use a normal for loop.
  • Main Queue (Serial): Just as above, using this on a serial queue is a bad idea. Just use a normal for loop.
  • Concurrent Queue: This is a good choice for concurrent looping, especially if you need to track the progress of your tasks.

Head back to downloadPhotosWithCompletionBlock: and replace it with the following implementation:

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

Your loop is now running concurrently; in the code above, in the call to dispatch_apply, you supply the amount of iterations with the first parameter, the queue to perform the tasks on in the second parameter and the block action in the third parameter.

Be aware that although you have code that will add the photos in a thread safe manner, the ordering of the images could be different depending on which thread finishes first.

Build & run, then add some photos from "Le Internet". Notice anything different?

Running this new code on the device will occasionally produce marginally faster results. But was all this work worth it?

Actually, it's not worth it in this case. Here's why:

  • You've probably created more overhead running the threads in parallel, than just running the darn for loop in the first place. You should use dispatch_apply for iterating over very large sets along with the appropriate stride length.
  • Your time to create an app is limited — don't waste time pre-optimizing code that you don't know is broken. If you're going to optimize something, optimize something that is noticeable and worth your time. Find the methods with the longest execution times by profiling your app in Instruments. Check out How to Use Instruments in Xcode to learn more.
  • Typically, optimizing code makes your code more complicated for yourself and for other developers coming after you. Make sure the added complication is worth the benefit.

Remember, don't go crazy with optimizations. You'll only make it harder on yourself and others who have to wade through your code.

Miscellaneous GCD Fun

But wait! There’s more! Here are some extra functions that are a little farther off the beaten path. Although you won't use these tools nearly as frequently, they can be tremendously helpful in the right situations.

Blocking - the Right Way

This might sound like a crazy idea, but did you know that Xcode has testing functionality? :] I know, sometimes I like to pretend it's not there, but writing and running tests is important when building complex relationships in code.

Testing in Xcode is performed on subclasses of XCTestCase and runs any method in its method signature that begins with test. Testing is measured on the main thread, so you can assume that every test happens in a serial manner.

As soon as a given test method completes, XCTest methods will consider a test to be finished and move onto the next test. That means that any asynchronous code from the previous test will continue to run while the next test is running.

Networking code is usually asynchronous, since you don't want to block the main thread while performing a network fetch. That, coupled with the fact that tests finish when the test method finishes, can make it hard to test networking code. That is, unless you block the main thread inside the test method until the networking code finishes.

Note: There are some who will say that this type of testing doesn't fall into the preferred set of integration tests. Some will agree; some won't. If it works for you, then do it.

Gandalf_Semaphore

Navigate to GooglyPuffTests.m and check out downloadImageURLWithString: , reproduced below:

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

This is a naïve approach to testing the asynchronous networking code. The while loop at the end of the function waits until the isFinishedDownloading Boolean becomes true, which happens in the completion block. Let's see what impact that has.

Run your tests by clicking on Product / Test in Xcode or use ⌘+U if you have the default key bindings.

As the tests run, pay attention to the CPU usage within Xcode on the debug navigator. This poorly designed implementation is known as a basic spinlock. It's not practical here because you are wasting valuable CPU cycles waiting in the while loop; it doesn't scale well either.

You may need to use the network link conditioner, as explained previously, to see this problem better. If your network is too fast then the spinning happens only for a very short period of time.

You need a more elegant, scalable solution to block a thread until a resource is available. Enter semaphores.

Semaphores

Semaphores are an old-school threading concept introduced to the world by the ever-so-humble Edsger W. Dijkstra. Semaphores are a complex topic because they build upon the intricacies of operating system functions.

If you want to learn more about semaphores, check out this link which discusses semaphore theory in more detail. If you're the academic type, a classic software development problem that uses semaphores is the Dining Philosophers Problem.

Semaphores lets you control the access of multiple consumers into a finite amount of resources. For example, if you created a semaphore with a pool of two resources, at most only two threads could access the critical section at the same time. Other items that want to use the resource must wait in a...have you guessed it?... FIFO queue!

Let's use semaphores!

Open GooglyPuffTests.m and replace downloadImageURLWithString: with the following implementation:

- (void)downloadImageURLWithString:(NSString *)URLString
{
    // 1
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

    NSURL *url = [NSURL URLWithString:URLString];
    __unused Photo *photo = [[Photo alloc]
                             initwithURL:url
                             withCompletionBlock:^(UIImage *image, NSError *error) {
                                 if (error) {
                                     XCTFail(@"%@ failed. %@", URLString, error);
                                 }

                                 // 2
                                 dispatch_semaphore_signal(semaphore);
                             }];
    
    // 3
    dispatch_time_t timeoutTime = dispatch_time(DISPATCH_TIME_NOW, kDefaultTimeoutLengthInNanoSeconds);
    if (dispatch_semaphore_wait(semaphore, timeoutTime)) {
        XCTFail(@"%@ timed out", URLString);
    }
}

Here's how the semaphores work in your code above:

  1. Create the semaphore. The parameter indicates the value the semaphore starts with. This number is the number of things that can access the semaphore without having to have something increment it first. (Note that incrementing a semaphore is known as signalling it).
  2. In the completion block you tell the semaphore that you no longer need the resource. This increments the semaphore count and signals that the semaphore is available to other resources that want it.
  3. This waits on the semaphore, with a given timeout. This call blocks the current thread until the semaphore has been signalled. A non-zero return code from this function means that the timeout was reached. In this case, the test is failed because it is deemed that the network should not take more than 10 seconds to return -- a fair point!

Run the tests again. Provided you have a working network connection, the tests should succeed in a timely manner. Pay particular attention to the CPU usage in this case, compared to the earlier spinlock implementation.

Disable your connection and run the tests again; if you are running on a device, put it in airplane mode. If you're running on the simulator then simply turn off your connection. The tests complete with a fail result, after 10 seconds. Great, it worked!

These are rather trivial tests, but if you are working with a server team then these basic tests can prevent a wholesome round of finger-pointing of who is to blame for the latest network issue.

Working With Dispatch Sources

A particularly interesting feature of GCD is Dispatch Sources, which are basically a grab-bag of low-level functionality helping you to respond to or monitor Unix signals, file descriptors, Mach ports, VFS Nodes, and other obscure stuff. All of this is far beyond the scope of this tutorial, but you'll get a small taste of it by implementing a dispatch source object and using it in a rather peculiar way.

First-time users of dispatch sources can get quite lost on how to use a source, so the first thing you need to understand how dispatch_source_create works. This is the function prototype for creating a source:

dispatch_source_t dispatch_source_create(
   dispatch_source_type_t type,
   uintptr_t handle,
   unsigned long mask,
   dispatch_queue_t queue);

The first parameter is dispatch_source_type_t. This is the most important parameter as it dictates what the handle and mask parameters will be. You'll need to refer to the Xcode documentation to see what options are available for each dispatch_source_type_t parameter.

Here you'll be monitoring for DISPATCH_SOURCE_TYPE_SIGNAL. As the documentation shows:

A dispatch source that monitors the current process for signals. The handle is a signal number (int). The mask is unused (pass zero for now).

A list of these Unix signals can found in the header file signal.h. At the top there are a bunch of #defines. From that list of signals, you will be monitoring the SIGSTOP signal. This signal is sent when a process receives an unavoidable suspend instruction. This is the same signal that's sent when you debug your application using the LLDB debugger.

Go to PhotoCollectionViewController.m and add the following code to the top of viewDidLoad, underneath [super viewDidLoad]:

- (void)viewDidLoad
{
  [super viewDidLoad];

  // 1
  #if DEBUG
      // 2
      dispatch_queue_t queue = dispatch_get_main_queue();

      // 3
      static dispatch_source_t source = nil;

      // 4
      __typeof(self) __weak weakSelf = self;

      // 5
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
          // 6
          source = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, SIGSTOP, 0, queue);

          // 7
          if (source)
          {
              // 8
              dispatch_source_set_event_handler(source, ^{
                  // 9
                  NSLog(@"Hi, I am: %@", weakSelf);
              });
              dispatch_resume(source); // 10
          }
      });
  #endif

  // The other stuff

The code is a little involved, so step through the code one comment at a time to see what's going on:

  1. It's best to only compile this code while in DEBUG mode since this could give "interested parties" a lot of insight into your app. :]
  2. Just to mix things up, you create an instance variable of dispatch_queue_t instead of supplying the function directly in the parameter. When code gets long, it's sometimes better to split things up to improve legibility.
  3. source needs to survive outside the scope of the method, so you use a static variable.
  4. You're using weakSelf to ensure that there are no retain cycles. This isn't completely necessary for PhotoCollectionViewController since it stays alive for the lifetime of the app. However, if you had any classes that disappeared, this would still ensure that there would be no retain cycles.
  5. Use the tried and true dispatch_once to perform the dispatch source's one-time setup.
  6. Here you instantiate the source variable. You indicate that you're interested in signal monitoring and provided the SIGSTOP signal as the second parameter. Additionally, you use the main queue for handling received events — you'll discover why shortly.
  7. A dispatch source object won't be created if you provide malformed parameters. As a result, you should make sure you have a valid dispatch source object before working on it.
  8. dispatch_source_set_event_handler invokes when you receive the signal you're monitoring for. You then set the appropriate logic handler in the block parameter.
  9. This is a basic NSLog call that prints the object to the console.
  10. By default, all sources start in the suspended state. You must tell the source object to resume when you want to start monitoring for the events.

Build and run your app; pause in the debugger and resume the app immediately. Check out the console, and you'll see that this function from the dark arts actually worked. You should see something like this in the debugger:

2014-03-29 17:41:30.610 GooglyPuff[8181:60b] Hi, I am: <PhotoCollectionViewController: 0xa637c50>

You app is now debugging-aware! That's pretty awesome, but how would you use this in real life?

You could use this to debug an object and display data whenever you resume the app; you could also give your app custom security logic to protect itself (or the user's data) when malicious attackers attach a debugger to your application.

An interesting idea is to use this approach as a stack trace tool to find the object you want to manipulate in the debugger.

What_Meme

Think about that situation for a second. When you stop the debugger out of the blue, you're almost never on the desired stack frame. Now you can stop the debugger at anytime and have code execute at your desired location. This is very useful if you want to execute code at a point in your app that's tedious to access from the debugger. Try it out!

I_See_What_You_Did_Meme

Put a breakpoint on the NSLog statement in viewDidLoad in the event handler you just added. Pause in the debugger, then start again.; the app will then hit the breakpoint you added. You're now deep in the depths of your PhotoCollectionViewController method. Now you can access the instance of PhotoCollectionViewController to your heart's content. Pretty handy!

Note: If you haven't already noticed which threads are which in the debugger, take a look at them now. The main thread will always be the first thread followed by libdispatch, the coordinator for GCD, as the second thread. After that, the thread count and remaining threads depend on what the hardware was doing when the app hit the breakpoint.

In the debugger, type the following:
po [[weakSelf navigationItem] setPrompt:@"WOOT!"]

Then resume execution of the app. You'll see the following:

Dispatch_Sources_Xcode_Breakpoint_Console
Dispatch_Sources_Debugger_Updating_UI

With this method, you can make updates to the UI, inquire about the properties of a class, and even execute methods — all while not having to restart the app to get into that special workflow state. Pretty neat.

Where to Go From Here?

You can download the final project here.

I hate to bang on this subject again, but you really should check out the How to Use Instruments tutorial. You'll definitely need this if you plan on doing any optimization of your apps. Be aware that Instruments is good for profiling relative execution: comparing which areas of code takes longer in relation to other areas. If you're trying to figure out the actual execution time of a method, you might need to come up with a more home-brewed solution.

Also check out How to Use NSOperations and NSOperationQueues, a concurrency technology that is built on top of GCD. In general, it's best practice to use GCD if you are using simple fire-and-forget tasks. NSOperations offers better control, an implementation for handling maximum concurrent operations, and a more object-oriented paradigm at the cost of speed.

Remember, unless you have a specific reason to go lower, always try and stick with a higher level API. Only venture into the dark arts of Apple if you want to learn more or to do something really, really "interesting". :]

Good luck and have fun! Post any questions or feedback in the discussion below!

Other Items of Interest

Black Friday Sale

Starts in…

0
:
0
:
0

raywenderlich.com Weekly

Sign up to receive the latest tutorials from raywenderlich.com each week, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

PragmaConf 2016 Come check out Alt U

Our Books

Our Team

Video Team

... 20 total!

iOS Team

... 78 total!

Android Team

... 27 total!

Unity Team

... 12 total!

Articles Team

... 15 total!

Resident Authors Team

... 20 total!

Podcast Team

... 7 total!

Recruitment Team

... 9 total!