swiftswiftuiswift-concurrency

How can I update a @Published value from a Timer block without warnings


I have the following code:

class Recorder: ObservableObject {
    @Published var recordingTimeSeconds: TimeInterval = 0
    private var timer: Timer?
    
    init() {}
    
    func startRecording() {
        timer = Timer(timeInterval: 1, repeats: true, block: { t in  
            self.recordingTimeSeconds = t.timeInterval
        })
        
        RunLoop.main.add(timer!, forMode: .common)
    }
}

I have the -warn-concurrency compiler flag enabled in my project. The line self.recordingTimeSeconds gives me the following warning: Capture of 'self' with non-sendable type 'Recorder' in a '@Sendable' closure

One solution that does not give warnings is:

EDIT: The solutions below do give the same warning. XCode just didn't want to show them to me at the time of writing this question

Task { @MainActor [timeInterval = t.timeInterval] in
    self.recordingTimeSeconds = timeInterval
}

Alternatively

DispatchQueue.main.async { [seconds = t.timeInterval] in
    self.recordingTimeSeconds = seconds
}

But I would like to see an alternative solution since I feel like using a DispatchQueue or Task might introduce issues later. The timer in my case needs to update the value without any possible delays.


Solution

  • In terms of the general question of how to access properties from the timer closure:

    1. You can use Task { @MainActor in … } pattern:

      @MainActor                                                  // Apple recommends if you're publishing for UI, isolate the whole ObservableObject to the main actor
      class Recorder: ObservableObject {
          @Published var recordingTimeSeconds: TimeInterval = 0
          private weak var timer: Timer?                          // weak
      
          func startRecording() {
              timer = .scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
                  Task { @MainActor [interval = timer.timeInterval] in
                      self.recordingTimeSeconds = interval
                  }
              }
          }
      }
      

      Or, better, use MainActor.assumeIsolated:

      @MainActor
      class Recorder: ObservableObject {
          @Published var recordingTimeSeconds: TimeInterval = 0
          private weak var timer: Timer?                          // weak
      
          func startRecording() {
              timer = .scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
                  MainActor.assumeIsolated { [interval = timer.timeInterval] in
                      self.recordingTimeSeconds = interval
                  }
              }
          }
      }
      

      A few unrelated observations:

      • Apple advises isolating ObservableObject to the main actor. See WWDC 2021’s Discover concurrency in SwiftUI.

        The key observation is that both the Task {…} and the property have to be isolated to the same actor. In your example, the property is not actor-isolated at all, hence the warning. Isolating the entire ObservableObject to the main actor, as Apple recommends, is the easiest way to accomplish this.

      • If you do that, you can just schedule a timer and it will be added to the main run loop for you, simplifying the code.

      • I personally always use weak reference to the timer. The runloop keeps the strong reference for us. As soon as the timer is invalidated, we want our timer reference to become nil.

      • Note, I am capturing the timeInterval explicitly, which avoids warnings about whether Timer is Sendable or not. (For more information on Sendable types, see WWDC 2022 video Eliminate data races using Swift Concurrency.)

    2. Personally, I now avoid using Timer, and remain within the Swift concurrency system. Sometimes a simple loop with Task.sleep is sufficient. E.g.:

      @MainActor
      class Recorder: ObservableObject {
          @Published var recordingTimeSeconds: TimeInterval = 0
      
          func startRecording() async throws {
              while true {
                  try await Task.sleep(for: .seconds(1))
                  …
              }
          }
      }
      

      This probably is not relevant in this particular “recording” example, but often when you want to run something roughly every second, the above is sufficient.

      Regardless, by remaining in Swift concurrency, if this asynchronous task is canceled, Task.sleep will throw CancellationError and you will exit this loop. And unlike legacy sleep API, which are best avoided, the Task.sleep is non-blocking.

      Anyway, given that startRecording is async and is isolated to the main actor, this simultaneously will avoid blocking the actor/thread, but give you timer-related behaviors.

      Clearly, you will need to calculate the interval yourself, though.

    3. Perhaps more elegant than the above, is to use AsyncTimerSequence from Apple’s Swift Async Algorithms package:

      @MainActor
      class Recorder: ObservableObject {
          @Published var recordingTimeSeconds: TimeInterval = 0
      
          func startRecording() async throws {
              for await tick in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
                  …
              }
          }
      }
      

      Again, you may need to calculate the interval yourself (e.g., with ContinuousClock).

      And if you want to minimize the latitude you offer the OS regarding when this fires, you can specify a tolerance of .zero:

      for await tick in AsyncTimerSequence(interval: .seconds(1), tolerance: .zero, clock: .continuous) {
          …
      }
      
    4. FWIW, with these Swift concurrency patterns, you have to contemplate how to cancel a previously started timer. If you are starting this in a .task view modifier and want it stopped when you leave, it will cancel for you automatically.

      But if you are starting this manually (e.g., when the “record” button is tapped), you might want to hang on to this Task for future reference, so you can cancel it later. E.g.:

      @MainActor
      class Recorder: ObservableObject {
          @Published var recordingTimeSeconds: TimeInterval = 0
          private var timerTask: Task<Void, Error>?
      
          func startRecording() {
              timerTask = Task {
                  for await tick in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
                      …
                  }
              }
          }
      
          func stopRecording() {
              timerTask?.cancel()
              timerTask = nil
          }
      }
      
    5. Since we are talking about cancelation, you have similar concerns with the block-based solution outlined in point 1, above:

      @MainActor
      class Recorder: ObservableObject {
          @Published var recordingTimeSeconds: TimeInterval = 0
          private weak var timer: Timer?
      
          func startRecording() {
              timer?.invalidate() // in case we accidentally call this twice, make sure to invalidate prior timer so you don’t keep it running, but lose any reference to it
              timer = .scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
                  guard let self else {
                      timer.invalidate()
                      return
                  }
      
                  MainActor.assumeIsolated { [interval = timer.timeInterval] in
                      self.recordingTimeSeconds = interval
                  }
              }
          }
      
          func stopRecording() {
              timer?.invalidate()
          }
      }
      
    6. You might not want to use timers at all. E.g., depending upon how/what you are “recording”, there might be an alternative method to keep track of the progress. E.g., AVFoundation has delegate methods for reporting the CMTime as the recording progresses. Where appropriate, you might want to see if your “recording” API offers native progress information.