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.
In terms of the general question of how to access properties from the timer closure:
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.)
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.
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) {
…
}
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
}
}
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()
}
}
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.