Combining @Observable and @Sendable

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 Tasks 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