skip to Main Content

Say I have this code:

class Presenter {
    var viewToUpdate: UIView!
    
    func updateUI() {
        viewToUpdate.backgroundColor = .red
    }
}

class ShinyNewAsyncAwaitClass {
    func doAsyncAwaitThing() async {
        // make network call or something
    }
}

class OtherClassThatICantUpdateToAsyncAwaitYet {
    func doOldClosureBasedThing(completion: @escaping () -> Void) {
        // make network call or something
        completion()
    }
}

class TheClassThatUsesAllThisStuff {
    var newClass: ShinyNewAsyncAwaitClass!
    var oldClass: OtherClassThatICantUpdateToAsyncAwaitYet!
    var presenter: Presenter!
    
    func doSomethingWithNewClass() {
        Task {
            await self.newClass.doAsyncAwaitThing()
            
            // ---->>> What do I do here? <<<<----
            await self.presenter.updateUI()
        }
    }
    
    func doSomethingWithOldClass() {
        oldClass.doOldClosureBasedThing {
            DispatchQueue.main.async {
                self.presenter.updateUI()
            }
        }
    }
    
    func justUpdateTheView() {
        self.presenter.updateUI()
    }
}

In short, I have three classes. One I can update to async/await, the other I can’t, and one that uses both. Both need access to a function that updates UI, both will need to access that function on the main thread.

I saw somewhere I can add @MainActor to the updateUI function, but in cases where I’m already on the main thread and I just want to call updateUI, like in justUpdateTheView I get this error:

Call to main actor-isolated instance method ‘updateUI()’ in a synchronous nonisolated context

Add ‘@MainActor’ to make instance method ‘justUpdateTheView()’ part of global actor ‘MainActor’

I can’t define justUpdateTheView as @MainActor, because we’re trying to update our project to the new concurrency stuff slowly and this would cause a chain reaction of changes that need to be made.

What to do for the best? Can I do something like this:

func doSomethingWithNewClass() {
    Task {
        await self.newClass.doAsyncAwaitThing()
        
        DispatchQueue.main.async {
            self.presenter.updateUI()
        }
    }
}

It compiles, but are there any gotchas to be aware of?

2

Answers


  1. You can do something like this to run the UI code on the MainActor:

    func doSomethingWithNewClass() {
        Task {
            await self.newClass.doAsyncAwaitThing()
            
            await MainActor.run {
                self.presenter.updateUI()
            }
        }
    }
    
    Login or Signup to reply.
  2. In GCD, we used to encumber the caller with the burden of making sure it dispatched the call to the right queue.

    In Swift concurrency, we isolate the called function to the correct actor, and the compiler will automatically warn us if we ever attempt to call it directly from a function not isolated to the same actor. E.g., as you noted:

    I saw somewhere I can add @MainActor to the updateUI function …

    Yes, that is prudent. So you might do:

    class Presenter {
        var viewToUpdate: UIView!
    
        @MainActor
        func updateUI() {
            viewToUpdate.backgroundColor = .red
        }
    }
    

    Or, better, in the case of a “presenter” (whose whole purpose is to interact with the view), isolate the whole type to the main actor:

    @MainActor
    class Presenter {
        var viewToUpdate: UIView!
    
        func updateUI() {
            viewToUpdate.backgroundColor = .red
        }
    }
    

    You then said:

    … but in cases where I’m already on the main thread and I just want to call updateUI, like in justUpdateTheView I get this error:

    Call to main actor-isolated instance method ‘updateUI()’ in a synchronous nonisolated context

    Add ‘@MainActor’ to make instance method ‘justUpdateTheView()’ part of global actor ‘MainActor’

    So, perhaps needless to say, the above actor-isolated updateUI that solves your doSomethingWithNewClass question, reducing it simply to:

    func doSomethingWithNewClass() {
        Task {
            await newClass.doAsyncAwaitThing()
            await presenter.updateUI()        
        }
    }
    

    No manual DispatchQueue.main.async {…} or MainActor.run {…} is needed.

    And then, re justUpdateTheView, you can do:

    func justUpdateTheView() {
        Task {
            await presenter.updateUI()
        }
    }
    

    Or, as the compiler suggested:

    @MainActor
    func justUpdateTheView() {
        presenter.updateUI()
    }
    

    (Personally, I would lean towards the former rather than sprinkling various service classes with @MainActor, but both work.)

    Anyway, once you have isolated updateUI to the main actor, the MainActor.run {…} is no longer necessary. IMHO, when I find myself using MainActor.run {…}, I treat it as code-smell. (The exception to this rule is where you want to call a whole series of separate functions isolated to the main actor, you might wrap the group of them within a MainActor.run {…} to have one context switch as opposed to a series of them.)


    Two asides:

    1. Until such point that you have an opportunity to refactor that legacy API, I would go ahead and give it an async wrapper for the time being. Xcode actually provides a “Refactor” tool to do this for you:

      enter image description here

      Then you end up with:

      enter image description here

      Or, if this is in a codebase that you can’t edit, move this wrapper into an extension:

      extension OtherClassThatICantUpdateToAsyncAwaitYet {
          func doOldClosureBasedThing() async {
              await withCheckedContinuation { continuation in
                  doOldClosureBasedThing() {
                      continuation.resume(returning: ())
                  }
              }
          }
      }
      

      Sure, you undoubtedly want to properly refactor the old API to use Swift concurrency, but the above provides a temporary wrapper that you can use until you get around to that. It only takes a few seconds and it saves you from encumbering your new code with legacy patterns.

    2. I would advise caution in using Task {…} as that is unstructured concurrency (meaning that you lose task cancelation). The Task {…} is useful if you have to bridge from a synchronous context to an asynchronous one, but otherwise, use it with discretion.

      E.g., using our above wrapper and updateUI that is isolated to the main actor, it reduces TheClassThatUsesAllThisStuff to something that stays within structured concurrency:

      class TheClassThatUsesAllThisStuff {
          var newClass: ShinyNewAsyncAwaitClass!
          var oldClass: OtherClassThatICantUpdateToAsyncAwaitYet!
          var presenter: Presenter!
      
          func doSomethingWithNewClass() async {
              await newClass.doAsyncAwaitThing()
              await presenter.updateUI()              // automatically isolated to main actor
          }
      
          func doSomethingWithOldClass() async {
              await oldClass.doOldClosureBasedThing() // using our wrapper for now
              await presenter.updateUI()              // automatically isolated to main actor
          }
      
          func justUpdateTheView() async {
              await presenter.updateUI()              // automatically isolated to main actor
          }
      }
      

    For what it is worth, in WWDC 2021 video Swift concurrency: Update a sample app, they walk us through the transition from DispatchQueue.main.async {…} to MainActor.run {…} to @MainActor-isolation. They even walk through the aforementioned edge-case where one might still use MainActor.run {…}. By the way, while I tried to drop you into the relevant portion of that video, the whole video is a nice, practical example of refactoring a legacy codebase.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search