Hi all,
I'm working on an app which uses FFI (not sure if it's relevant, but it's Rust) and I'm struggling with updating my view models correctly and getting SwiftUI to reflect those changes.
Right now, I'm using Unmanaged.passRetained(self).toOpaque()
to pass a pointer to Rust. Then, when I want to trigger an update from Rust, I call a function from Swift that reconstructs the class reference from the pointer and calls the appropriate model method. The part I'm having trouble with is making this view model @Observable
and implementing @Sendable
in a safe way. For example:
In this class, I'm currently using @unchecked Sendable
to conform to Sendable
. Here’s a case where I’m working with a progress
property on the view model which should be thread-safe.
private let __progress: Mutex<Double> = .init(0)
private(set) var progress: Double {
get {
access(keyPath: \.progress)
return __progress.withLock { $0 }
}
set {
withMutation(keyPath: \.progress) {
self.__progress.withLock { $0 = newValue }
}
}
}
And a method that updates it:
func updateProgress(_ newProgress: Double) {
self.__progress.withLock { progress in
withAnimation(.bouncy) {
withMutation(keyPath: \.progress) {
progress = newProgress
}
}
}
}
With this, I'm running into issues where the main thread gets blocked, waits too long, or— in some instances like when using withAnimation completion— the mutex gets locked on the main thread and crashes when the computed property's getter tries to acquire the lock again on the same thread.
This pattern of using a mutex and a computed property does feel a bit weird, but I'm not sure how else I can make this thread-safe and reactive. If there's a better, safer, and more efficient way to implement this, I'm happy to make any changes.
To be clear, you're saying that your view model may be updated from non-main threads?
I'm quite surprised that withAnimation()
is not marked as MainActor-isolated. I would not trust it being nonisolated.
With this assumption in mind, IMO, the best approach would be to:
- Mark VM as
@MainActor
-isolated, which also implies sendable.
- Make
progress
property @MainActor
-isolated (by default inside @MainActor
-isolated class)
- Call
withAnimation()
in @MainActor
-isolated function.
- Provide another
nonisolated
wrapper for the first method that enqueues call to the previous method on the DispatchQueue.main
.
This works because compiler has a hack that knows that closures enqueued to the main queue are @MainActor
-isolated. Alternatively you can create Task
s to enqueue to the queue, but keep in mind that tasks are quite memory hungry - each Task
allocated about 1KiB of memory for asynchronous call stack even if it really just runs a synchronous work.
If progress is updated too often and you need to implement throttling logic, then you can create two distinct properties, e.g.
displayedProgress
- observable, isolated to @MainActor
.
realProgress
- not observable, protected by the lock.
And then enqueue MainActor-work only if realProgress
have changed sufficiently.
2 Likes
Yes. On the Rust side I use Tokio to spawn some threads and at some point they need to pass a value to swift and update the UI.
Yes, so from what I've tested it seems like the completion
callback from withAnimation
will always run on the main thread although I can't find this to be documented anywhere in apple's docs.
I think the @MainActor
approach can work great for most VMs that are not insanely complicated. I was a bit unsure whether there would be performance penalties from marking every VM as @MainActor
, but I will definitely experiment with the methods you suggested.
I also wrote a version using AsyncStream.
@Observable
final class ViewModel: @unchecked Sendable {
// TODO: Write safety
private let (stream, continuation) = AsyncStream<StreamUpdate>.makeStream()
private(set) var code: ConnectCode?
private(set) var progress: Double = 0
private(set) var strokeWidth: Double = 10
private(set) var isColorVisible = true
func startRecvTask() -> Task<(), Never> {
return Task.detached(priority: .high) { [weak self] in
for await update in self?.stream ?? AsyncStream(unfolding: { nil }) {
guard let self = self else { break }
await MainActor.run {
switch update {
case .progress(let progress):
withAnimation(.bouncy) {
self.progress = progress
}
case .newCode(let code):
// Reset progress before showing the new code
self.isColorVisible = false
self.progress = 1
withAnimation(.bouncy(duration: 0.35)) {
self.code = code
self.strokeWidth = 11
self.isColorVisible = true
} completion: {
self.strokeWidth = 10
}
}
}
}
}
}
func updateProgress(_ newProgress: Double) {
self.continuation.yield(StreamUpdate.progress(newProgress))
}
func updateCode(_ newCode: ConnectCode) {
self.continuation.yield(StreamUpdate.newCode(newCode))
}
deinit {
self.continuation.finish()
}
}
private enum StreamUpdate {
case progress(_ value: Double)
case newCode(_ code: ConnectCode)
}
So far it seems to work, but since you mentioned Tasks being memory hungry I'm going to have to monitor that too.
1 Like