Search code examples
iosswiftreactive-cocoareactive-swift

How to multi-cast the results of an in progress Action or start a new one


I have the following scenario - I am using ReactiveSwift's Action to trigger a network request in my app. This network request is potentially expensive due to the processing that is done on it's response. So, when a caller tries to apply the Action I would like to do the following:

  • Determine if the Action is already in progress
    • If it is, return a SignalProducer that observes the results of the in progress Action
    • If it is not, return a SignalProducer that will apply the action when started

Ideally the solution would be thread safe, as callers may try to apply the Action from different threads.

Now I've managed to cobble together something that works using examples of caching in ReactiveSwift, but I'm almost certain I'm doing something wrong particularly in how I'm having to reset my MutableProperty to nil when the Action completes. Note that I'm also using static variables to ensure my multiple instances of the UseCase can't bypass my intended behaviour. Also, my example signals Never output but in the real world they may:

class UseCase {
  private static let sharedAction = Action<Void, Never, AnyError> {
    return SignalProducer.empty.delay(10, on: QueueScheduler.main).on(completed: {
      print("Done")
      UseCase.sharedProducer.value = nil
    })
  }
  private static let sharedProducer = MutableProperty<SignalProducer<Never, AnyError>?>(nil)

  func sync() -> SignalProducer<Never, AnyError> {
    let result = UseCase.sharedProducer.modify { value -> Result<SignalProducer<Never, AnyError>, NoError> in
        if let inProgress = value {
          print("Using in progress")
          return Result(value: inProgress)
        } else {
          print("Starting new")
          let producer = UseCase.sharedAction.apply().flatMapError { error -> SignalProducer<Never, AnyError> in
              switch error {
              case .disabled:                   return SignalProducer.empty
              case .producerFailed(let error):  return SignalProducer(error: error)
              }
            }.replayLazily(upTo: 1)

          value = producer
          return Result(value: producer)
        }
    }

    guard let producer = result.value else {
      fatalError("Unexpectedly found nil producer")
    }

    return producer
  }
}

Solution

  • This also might a bit lengthy, but it should at least be a bit easier to follow. Feel free to ask any questions.

    NOTE: I made this object start processing on its own, rather than return a SignalProducer that the caller would start. Instead, I added a read-only property that listeners can observe without starting the processing.

    I try to make my observers as passive as possible, thus making them more "reactive" than "proactive". This pattern should suit your needs, even though it's a bit different.

    I tried to make this example include:

    1. Shared results from a single unit of work.
    2. Error handling.
    3. Best practices for signal retention.
    4. General explanations in comments, since good tutorials are very hard to find.
      • Sorry if I'm explaining things you already know. I wasn't sure how much to assume you already know.
    5. Mocked processing delay (remove in production code).

    It's far from perfect, but should provide a solid pattern you can modify and expand.

    struct MyStruct {}
    
    final class MyClass {
        // MARK: Shared Singleton
        static let shared = MyClass()
    
        // MARK: Initialization
        private init() {}
    
        // MARK: Public Stuff
        @discardableResult
        func getValue() -> Signal<MyStruct, NoError> {
    
            if !self.isGettingValue {
                print("Get value")
                self.start()
            } else {
                print("Already getting value.")
            }
    
            return self.latestValue
                .signal
                .skipNil()
        }
        var latestValue: Property<MyStruct?> {
            // By using a read-only property, the listener can:
            // 1. Choose to take/ignore the previous value.
            // 2. Choose to listen via Signal, SignalProducer, or binding operator '<~'
            return Property(self.latestValueProperty)
        }
    
        // MARK: Private Stuff
        private var latestValueProperty = MutableProperty<MyStruct?>(nil)
    
        private var isGettingValue = false {
            didSet { print("isGettingValue: changed from '\(oldValue)' to '\(self.isGettingValue)'") }
        }
    
        private func start() {
            // Binding with `<~` automatically starts the SignalProducer with the binding target (our property) as its single listener.
            self.latestValueProperty <~ self.newValueProducer()
    
                // For testing, delay signal to mock processing time.
                // TODO: Remove in actual implementation.
                .delay(5, on: QueueScheduler.main)
    
                // If `self` were not a Singleton, this would be very important.
                // Best practice says that you should hold on to signals and producers only as long as you need them.
                .take(duringLifetimeOf: self)
    
                // In accordance with best practices, take only as many as you need.
                .take(first: 1)
    
                // Track status.
                .on(
                    starting: { [unowned self] in
                        self.isGettingValue = true
                    },
                    event: { [unowned self] event in
                        switch event {
                        case .completed, .interrupted:
                            self.isGettingValue = false
                        default:
                            break
                        }
                    }
                )
        }
    
        private func newValueProducer() -> SignalProducer<MyStruct?, NoError> {
            return SignalProducer<MyStruct?, AnyError> { observer, lifetime in
    
                // Get Struct with possible error
                let val = MyStruct()
    
                // Send and complete the signal.
                observer.send(value: val)
                observer.sendCompleted()
    
                }
    
                // Don't hold on to errors longer than you need to.
                // I like to handle them as close to the source as I can.
                .flatMapError { [unowned self] error in
                    // Deal with error
                    self.handle(error: error)
    
                    // Transform error type from `AnyError` to `NoError`, to signify that the error has been handled.
                    // `.empty` returns a producer that sends no values and completes immediately.
                    // If you wanted to, you could return a producer that sends a default or alternative value.
                    return SignalProducer<MyStruct?, NoError>.empty
            }
        }
    
        private func handle(error: AnyError) {
    
        }
    }
    

    TEST

    // Test 1: Start processing and observe the results.
    MyClass.shared
        .getValue()
        .take(first: 1)
        .observeValues { _ in
            print("Test 1 value received.")
    }
    
    // Test 2: Attempt to start (attempt ignored) and observe the same result from Test 1.
    MyClass.shared
        .getValue()
        .take(first: 1)
        .observeValues { _ in
            print("Test 2 value received.")
    }
    
    // Test 3: Observe Value from Test 1 without attempting to restart.
    MyClass.shared
        .latestValue
        .signal
        .skipNil()
        .take(first: 1)
        .observeValues { _ in
            print("Test 3 value received.")
    }
    
    // Test 4: Attempt to restart processing and discard signal
    MyClass.shared.getValue()
    

    Output:

    Get value
    isGettingValue: changed from 'false' to 'true'
    Already getting value.
    Already getting value.
    

    (5 seconds later)

    Test 1 value received.
    Test 2 value received.
    Test 3 value received.
    isGettingValue: changed from 'true' to 'false'