Using DispatchGroup in SwiftUI

This post covers how to use the Grand Central Dispatch's DispatchGroup object to coordinate separate web requests so that UI updates can be completed at the same time, even when asynchronous requests aren't guaranteed to return in a predictable order.

This post is primarily a tutorial of using GCD's DispatchGroup, and for the most part applies to any iOS or macOS application. However, it's presented in the context of controlling the timing of updates to @Published properties in a SwiftUI application, so some understanding of SwiftUI is helpful though not necessary.

What's Grand Central Dispatch?

Grand Central Dispatch, or GCD for short, is an intermediate abstraction layer over the iOS/macOS threading model. GCD provides convenient classes and methods to dispatch blocks of code to a common thread pool managed by the runtime system.

Note that GCD isn't the only option for managing threads for concurrent programming in iOS/macOS. GCD is built on top of pthreads, and Operations/Operation Queues are build on top of GCD. Selecting which level of abstraction to use is beyond the scope of this post, but both GCD and Operations are commonly used in application programming, depending on the features needed by a specific use case.

You've probably used code like the following to dispatch UI updates to the main thread:

    DispatchQueue.main.async {
       // some code that changes UI state here
    }

DispatchQueue is a part of GCD, and the .main thread is one that's controlled by GCD.  When you call main.async {}, you're using GCD to dispatch a code block to be run on the main thread when GCD is able to schedule it on your behalf.

GCD has many other features and capabilities as well--and this post provides a use case for using one of them – DispatchGroup()

Why DispatchGroup?

In this post's sample app, which is available on GitHub in full, a SwiftUI app displays some demographic information about a city, shows its current weather conditions, and fetches a photo of the city to use as a background to the data the form.

To obtain the city information, weather and image, there are three web requests, all completed using asynchronous URLSession tasks on background threads:

  1. An API call to get infrequently changing location data (name, population, etc.)
  2. An API call to get frequently changing weather information (temperature, etc.)
  3. A fetch to download an image to use as a form background. There is a dependency that web fetch #3 cannot be performed until it has the image url provided in the response to request #1.

Only after all three data requests complete should the view model be signaled to update the UI. This requirement was decided so the user wouldn't perceive the UI being built piece-by-piece, but would instead see the entire form presented in its completed form.

Visually, the logic is as follows:

DispatchGroup Logic for SwiftUI App
By using DispatchGroup, we can run as many asynchronous requests as we need, allowing them to complete in any order. The UI update step (4) runs when all requests { 1, 2, 3 } are complete.

You can probably imagine that this technique has many other applications beyond solving for this update requirement, as dependencies between asynchronous operations is not uncommon.

Overall App Components

The sample application used to for this post has several components:

  1. The SwiftUI view where data is ultimately presented
  2. A View Model, where the web API requests are dispatched from and where the results are persisted for the View to display them.
  3. A DataService singleton that performs the actual web requests
  4. Model classes for the Location Information and Weather information.

The overall solution is available in GitHub for a comprehensive review. However, the important part of the threading technique lives in the View Model, so that's the part of the solution I'll discuss directly in the rest of this post.

It may help to put this in context by reviewing the entire solution on GitHub.

The ViewModel Thread Implementation

The ViewModel (see the full code here) contains two important GCD components, declared as instance-level let constants:

    let concurrentQueue = DispatchQueue(label: 
                              "com.cuvenx.fetchqueue", 
                              attributes: .concurrent)
                              
     let concurrentFetchGroup = DispatchGroup()

The concurrentQueue is a DispatchQueue, and is used as the thread queue on which each web request is dispatched. The DispatchQueue is marked as .concurrent, which allows all work it schedules to run concurrently so the requests can run at the same time.

Since we didn't specify any limit to how many requests can run concurrently, the number of concurrent threads available to the DispatchQueue is up to GCD, and will depend on the other work GCD is managing for this and other applications. The busier the hardware is, the fewer threads that may be available. No worries though--managing that complexity is outsourced to GCD!

The important declaration for this solution is the DispatchGroup. DispatchGroup works in some ways similarly to a semaphore--it uses an internal counter to keep track of how many tasks are running as part of its managed group of tasks. It does more than this, but providing a counter and a gate is essentially the value it provides to application code.

The flow works this way:

  1. A task is dispatched to the group. The group's count is increased
  2. More tasks are dispatched to the same group. The groups count is increased for each one.
  3. As tasks complete, each one decrements a count
  4. When the count returns to zero, a code block we provide is run.

In this sense, the DispatchGroup serves as a sort of "gate", which is opened only after all the tasks arrive together at the gate.

Dispatching the Weather Request

The following code is used to dispatch the weather request (see view model code here):

    concurrentFetchGroup.enter()
    
    self.concurrentQueue.async {
       DataService.sharedInstance.fetchWeather { [weak self] (response) in
          weatherData = response
          self?.concurrentFetchGroup.leave()
       }
    }

The first line of this code snippet calls the .enter() function of the DispatchGroup. Doing so essentially increases the internal counter maintained by the DispatchGroup.

Once the response is received from the .fetchWeather asynchronous method, the .leave() function is called to decrement the counter. It also saves the response in a local variable weatherData, which will be used later by the code block that runs when all web requests have completed. Stay tuned for that step.

Importantly, this block doesn't know how many other tasks are running within the same group. There may be other tasks running, but whether they are or not, this closure is only concerned with its own work.

Dispatching the City Information Request

The request for city information works almost the same way. But it does have one additional complexity:

  • After it returns, a second request is made to fetch the image at the URL provided to the closure in the city information response.
    concurrentFetchGroup.enter()
    
    self.concurrentQueue.async {
      DataService.sharedInstance.fetchLocationInfo { 
                                       [weak self] (response) in
         locationData = response
         DataService.sharedInstance.fetchImage(url: response?.imageUrl) { 
                                       [weak self] image in
              DispatchQueue.main.async {
                 self?.locationImage = image
              }
              self?.concurrentFetchGroup.leave()
         }
      }
    }

Pay particular attention to the position of the .leave() method call--after the second asynchronous web request.

The .fetchImage request can't be run concurrently with the .fetchLocationInfo request, because the url is known only after the first request returns.

Only once both requests have finished does the work dispatched to the concurrentQueue signal it's completed by calling the DispatchGroup*.leave()* function.

Executing the UI Update after all requests finish

In the preceding sections, the dispatch group was entered twice, and left twice.  So how does our application get control of the process flow once both have left?  The answer is that we registered a block of code to be run once all previous dispatched tasks finished.

This is that block:

    concurrentFetchGroup.notify(queue: DispatchQueue.main) {
      if var locData = locationData {
         locData.weather = weatherData
         self.locationInfo = locData
      }
    }

After the work was dispatched (and .enter called for each dispatched block), the above code snippet registered a block of code to be run when all previously dispatched tasks exited the group.

Specifically, this block does the following:

  1. .notify means, "call this block of code when all tasks have called .leave()"
  2. The parameter to .notify is the queue on which the block should be run after all tasks .leave the group. In this case, the next step in the process is update UI state with the data received from the web, so we pass in DispatchQueue.main. Recall that this GCD thread is required when updating the UI, so it makes sense to ask GCD to run the block on the main thread.
  3. The block itself assigns the weather data to a member within the city data object. The UI will access weather data via this property. Note this assignment cannot be reliably made until the notify block is being run—we don't know if the weather API call returns before or after city information!
  4. locationInfo is a SwiftUI @Published property. Whenever this property is assigned a new value, it triggers a UI update within the SwiftUI View code.  At that moment, the user will see the city demographic, image and weather data appear all at once!

The full ViewModel code

Putting together the entire View Model code, we arrive at this:

    class LocationViewModel : Identifiable, ObservableObject {
        var id = UUID()
        
        let concurrentQueue = DispatchQueue(label: "com.cuvenx.fetchqueue", 
                                             attributes: .concurrent)
                                             
        let concurrentFetchGroup = DispatchGroup()
        
        @Published var locationInfo: LocationInfo?
        @Published var locationImage: UIImage?
        
        func fetchDataFromApi() {
            var weatherData: Weather?
            var locationData: LocationInfo?
    
            // Dispatch fetch weather task
            concurrentFetchGroup.enter()
            self.concurrentQueue.async {
                DataService.sharedInstance.fetchWeather { [weak self] 
                                                          (response) in
                    weatherData = response
                    self?.concurrentFetchGroup.leave()
                }
            }
            
            // Dispatch fetch city inforamtion task
            concurrentFetchGroup.enter()
            self.concurrentQueue.async {
                DataService.sharedInstance.fetchLocationInfo { [weak self] 
                                                             (response) in
                    locationData = response
                    
                    // Have URL, fetch image
                    DataService.sharedInstance.fetchImage(
                                url: response?.imageUrl) { [weak self] 
                                                           image in
                        DispatchQueue.main.async {
                            self?.locationImage = image
                        }
                        self?.concurrentFetchGroup.leave()
                    }
                }
            }
            
            // Block to execute when all API requests have completed
            concurrentFetchGroup.notify(queue: DispatchQueue.main) {
                if var locData = locationData {
                    locData.weather = weatherData
                    // Assigning this observable will trigger a UI update
                    self.locationInfo = locData
                }
            }
        }
    }

The User Experience

Finally, let's look at what the user experience is for the app.  Note in the animation below you can see a progress indicator. While the indicator is showing, the background threads are running web requests.

Note that when the progress view is hidden and UI elements are displayed, all elements appear at once. This is because we used the DispatchGroup to wait for the UI update until *all * the data the user would see was available.


What would have happened if we let the UI update as data was received?  Probably the following:

  • The city and weather data probably would have appeared first, with a blank background. Recall the image is fetched only after the city data is retrieved--and probably takes enough time to fetch that the user would perceive its absence.
  • The weather data might have been displayed with blank demographic information (or vice-versa). We had no guarantee that the all the data would arrive together, or they would arrive at about the same time.

By using the DispatchGroup, we were able to avoid any distractions on the UI, and show the fully completed version of the screen to the user.