How to make an Actor's isolated state observable

76 Views Asked by At

Suppose there's an actor like below

actor MyActor<State> {
    private var state: State
    
    private init(initialState: State) {
        self.state = initialState
    }
    
    public func message(param: Int) {
        // mutates state
        ...
    }
}

Now I want to observe the isolated property state somehow, so that it can be used for example in SwiftUI, where it provides the view's state - or elsewhere.

For now, the @Observable macro from the Observation framework is out of question, since it does not support actors at this time.

Another idea would be to make the actor a Combine Publisher which publishes the state isolated property:

So, I would then change it like:

actor MyActor<State> {
    private var _state: CurrentValueSubject<State, Never>
    
    private init(initialState: State) {
        self._state = .init(initialState)
    }
    
    public func message(param: Int) {
        // mutates _state
        ...
    }
}

and in order to make a publisher:

extension MyActor: Publisher {
    public typealias Output = State
    public typealias Failure = Never

    public func receive<S>(
        subscriber: S
    ) where S : Subscriber, Never == S.Failure, State == S.Input {
        _state.receive(subscriber: subscriber)
    }
}

However, the compiler complains (reasonable) with this error in function receive:

Actor-isolated instance method 'receive(subscriber:)' cannot be used 
to satisfy nonisolated protocol requirement

One way to silence this error is for example:

extension MyActor: Publisher {
    public typealias Output = State
    public typealias Failure = Never

    nonisolated
    public func receive<S>(
        subscriber: S
    ) where S : Subscriber, Never == S.Failure, State == S.Input {
        // Note: probably should declare `_state` nonisolated
        assumeIsolated { this in
            this._state.receive(subscriber: subscriber)
        }
    }
}

Now, with some minor effort and applying a receive(on:) operator to dispatch the value delivery on the main thread, I could potentially integrate this actor into an @Observable type or a type conforming to ObservableObject and use this "model" as usual in SwiftUI.

However, it looks quite hackish and I'm unsure whether this is correct code regarding thread-safety.

Any other ideas to make an actor "observable" are welcome, too.

Maybe, the conclusion is, "Don't use actors, use @MainActor and final class"?

2

There are 2 best solutions below

0
Rob Napier On

Maybe, the conclusion is, "Don't use actors, use @MainActor and final class"?

Yes. Remember, a final class marked @MainActor is an actor.

1
Scott Thompson On

You could do something like this:

import UIKit
import Combine

actor MyActor {
  public let observableState: AnyPublisher<Int, Never>
  private var state: CurrentValueSubject<Int, Never>

  public init(initialState: Int) {
    self.state = .init(initialState)
    self.observableState = self.state.eraseToAnyPublisher()
  }

  public func message(param: Int) {
    state.value = param
  }
}

await MainActor.run {
  let actor = MyActor(initialState: 3)

  let subscription = actor.observableState.sink { value in
    print("The new state is \(value)")
  }

  Task {
    await actor.message(param:10)
    await actor.message(param:20)
    await actor.message(param:30)
    await actor.message(param:40)
  }
}

But people have run into many problems trying to mix async Swift and Combine. It's generally better to use one or the other. To use async swift then state would be a async sequence and you would monitor it from outside of the actor using a for loop over the sequence.