#apps
Jun 6, 2023

Handling Feedback Loops in your RxSwift Code

Handling Feedback Loops in your RxSwift Code

It has been several years since we started using RxSwift in our Showmax iOS app. Similar to many other teams, we needed to move away from imperative programming and towards a more declarative approach. Although Rx can be a powerful tool, it's crucial to use it correctly – otherwise, it could do more harm than good. Changing your mindset from an imperative approach to a declarative one is a challenging process and it can take time to truly take root.

The article that follows will outline our progress from initially using RxSwift in an imperative manner to fully embracing a declarative approach. The reader should have a basic understanding of RxSwift.

All of the code discussed in the article is available in this github repository.

Simple reactive player

The player is the central focus of our app, and therefore, it serves as an ideal example to illustrate our point. The player state is constantly changing based on the video played which makes it a natural fit for reactive programming. Let's consider a basic player with a custom HUD (heads up display) that displays the player's controls as follows:

Post image

As any other HUD, it needs to be shown when the screen is tapped. Because we would like to unit test every aspect of the player, we want to have it driven by the view model. This is how we would represent it using RxSwift:

class PlayerViewModel {
    struct State {
        let isPlaying: Bool
        let isHUDVisible: Bool
    }


   // ….


    private lazy var stateDriver: Driver<State> = {
        Observable.combineLatest(
            isPlaying,
            isHUDVisible
        ) {
            State(isPlaying: $0, isHUDVisible: $1)
        }
        .asDriver(onErrorDriveWith: .empty())
    }()
}

The isHUDVisible is an Observable that will be controlling whether HUD is shown on top of the player. So how do we define it?

Imperative approach

The first choice of any imperative programmer would be BehaviorSubject, or its equivalent, BehaviorRelay, which avoids error emissions. When didTapHUD is called from the View it will set it to true.

let isHUDVisible = BehaviorRelay(value: false) 

func didTapHUD() {
   isHUDVisible.accept(true)
}

However, this approach has a few problems:

  • We're now mixing imperative and declarative programming. In reality there will be many more ways to bring up and hide the HUD. E.g. when the movie ends, when a certain gesture is invoked, etc. That will scatter the isHUDVisible.accept(...) calls throughout our code. To get a complete picture of what is driving the hud visibility we need to check all these calls plus we also need to check the reactive code. No bueno.
  • Let's say we would like to hide the HUD after 3 seconds. However, any interaction with the UI should reset the timer and wait another 3 seconds. An imperative programmer might want to try this approach:
var hideTask: Task<Void, Error>?

private func didInteractWithUI() {
             // cancel previous hide
             self.hideTask?.cancel()

             // hide after 3 seconds
             self.hideTask = Task {
                 try await Task.sleep(for: .seconds(3))
                 self.isHUDVisible.accept(false)
             }
           }

The function didInteractWithUI will be called any time the user interacts with the player. We are again mixing imperative and reactive code and it's getting even harder to understand. Plus we need to bother with cancellation of the previous hide task which adds extra unnecessary complexity.

The code for the implementation is available in  PlayerViewModel_01.swift.

Round two

To solve some of the problems mentioned above let's make the code more reactive. First let's define two PublishRelays that will be invoked on hud tap and on interaction with UI. The key distinction between PublishRelay and BehaviorRelay is that PublishRelay does not hold any state but only forwards events (e.g. tap on HUD) to the reactive code.

private let didInteractWithUIRelay = PublishRelay<Void>()
private let didTapHUDRelay = PublishRelay<Void>()

Then we can define isHUDVisible as a single stream.

private var isHUDVisible: Observable<Bool> {
        Observable.merge(
            // When HUD is tapped we want show the HUD
            didTapHUDRelay.map { true },

            // Hide HUD 3 seconds after last interaction
            didInteractWithUIRelay.flatMapLatest {
                Observable.just(false).delay(.seconds(3), scheduler: MainScheduler.instance)
            }
        )
    }

In the above code we merge two streams. The first will set isHUDVisible to true which will make the HUD appear. The second will wait 3 seconds after the user interacts with the UI and hides the HUD by sending the false value. This is a step in the right direction because:

  • All state related logic is contained in the same place. In fact, the state can't be mutated from anywhere else but here.
  • The logic for canceling hide upon UI interaction is replaced by a single flatMapLatest operator.

However, you might notice there's one glaring problem with our HUD. When the HUD is visible and the user taps it, we would expect it to hide immediately. But in the current implementation you would always have to wait 3 seconds. To put it simply, we would like to toggle the isHUDVisible value as such:

didTapHUDRelay.map { !oldIsHUDVisible }.

But how do we do it when we don't have access to the previous value?

Introducing ... scan

Let's refactor our code a little bit. First let's define what actions we can do with our isHUDVisible state.

enum HUDVisibilityEvent {
   case hide
   case toggle
}

Then let's update the isHUDVisible stream a little bit:

private var isHUDVisible: Observable<Bool> {
        Observable<HUDVisibilityAction>.merge(
            // Toggle HUD on tap
            didTapHUDRelay.map { .toggle },

            // Hide HUD 3 seconds after last interaction
            didInteractWithUIRelay.flatMapLatest {
                Observable.just(.hide).delay(.seconds(3), scheduler: MainScheduler.instance)
            }
        )
        .scan(false) { oldState, action in
            switch action {
               case .hide:
                  return false
               case .toggle:
                  // that’s what we are after! 
                  return !oldState
             }
        }
        .startWith(false)
    }

There’s a lot to unpack here. First of all, we keep the same observables in the merge but instead of mapping them into Bool we are mapping them to an HUDVisibilityEvent.

On the next line we use the reactive scan operator. It helps us accumulate value over time and use it in our next calculation. The old state value is passed in the first parameter and the event is passed as second. When the HUD is tapped we can just toggle the previous state value which gives us exactly what we are after.

For the full context see  PlayerViewModel_02.swift in the GitHub repository.

Reducer architecture

If you have spent any time researching the functional programming paradigm online, this pattern might seem familiar to you. In fact, it is the very same Reducer pattern that is used in The Composable Architecture (TCA), ReSwift, React and many many others.

Post image

This diagram defines the broad idea of Reducer architecture. The view will send actions (in our case they are called events) to reducers which will in turn update the state in store. The view is then updated . We usually see this applied as app-wide architecture but nobody says you can't apply it to distinct sub-problems like this one.

Reducer

The reducer part of the diagram can be defined as follows. It always takes the current state and action as arguments. It should always be a pure function (a function whose output value follows solely from its input values, without any observable side effects).

private static func hudVisibilityReducer(state: Bool, event: HUDVisibilityEvent) -> Bool {
        switch action {
        case .hide:
                  return false
        case .toggle: 
           return !oldState
        }
 }

and we can change our scan to become:

.scan(false, accumulator: Self.hudVisibilityReducer)

Action

The Action part of the diagram is defined with our HUDVisibilityEvent. It represents “something that happened” and based on which the state should be mutated.

State

State is a simple Bool here but can be much more complicated.

Store
The scan operator essentially provides us with the store functionality. It stores the previous state value and applies the reducer with the new action on it.

Round three

This pattern by itself can be a very powerful way to represent the state of your application. The code that handles state mutation is contained within the pure reducer function which can be tested very easily. Chances are you already know and use this pattern within your reactive code. But it has its limitations as I describe in the following example.

Skip intro

For our binge-watching users, the skip intro button has become indispensable. What it does is fairly self-explanatory – when a series intro starts a button will appear which will let users skip it. In fact, after we introduced the button we noticed that most users will choose to skip as soon as it appears. So let’s make it even easier for them and skip the intro automatically if the user doesn’t choose otherwise in the first 10 seconds.

Post image

Let’s define our skipIntroState :

enum SkipIntroState {
        case hidden
        case showing(secondsLeft: Int)
        case skipping
    }

... 

private var hasIntroStarted: Observable<Bool> {
        player.rx.time
            .map { $0 > Constants.introStart }
            .distinctUntilChanged()
    }

...

private lazy var skipIntroState: Observable<SkipIntroState> = {
        hasIntroStarted.flatMapLatest { hasCreditsStarted -> Observable<SkipIntroState> in
            if hasCreditsStarted {
                return Observable.timer(countingDownFrom: 10)
                    .map { secondsLeft in
                        if secondsLeft > 0 {
                            return .showing(secondsLeft: secondsLeft)
                        } else {
                            return .skipping
                        }
                    }
            } else {
                return .just(.hidden)
            }
        }
        .share(replay: 1)
    }()

After the intro starts (based on player time) we will start a timer with a period of one second. On every tick it will update state until we reach 0 where we switch to `skipping` state.

To actually skip the intro we will need to call player.rx.seek(...) which returns Completable (Observable that can only complete). Seeking is an asynchronous operation (due to network latency). We only want to hide the skip intro button after the seek is completed.. But how do we do that?

Creating a loop with a relay

One way to achieve this would be with a side-effect in a separate subscription on skipIntroState. The subscription would be set-up during view model initialization:

skipIntroState
    .flatMapLatest { [player] state -> Observable<Void> in
        guard case .skipping = state else { return .empty() }
        return player.rx.seek(to: Constants.introEnd).andThen(.just(()))
    }
    .subscribe(
        onNext: { [weak self] _ in
            self?.didSkipIntro.accept(())
        }
    )
.disposed(by: bag)

When the state switches to  “.skipping” we will perform the seek to the intro’s end. When it is concluded we will use the new didSkipIntroRelay to inform the state:

private let didSkipIntroRelay = PublishRelay<Void>()
...
private lazy var skipIntroState: Observable<SkipIntroState> = {
        Observable.merge(
            ...,

            // Change to hidden state when intro was skipped
            didSkipIntro.map { .hidden }
        )
        .share(replay: 1)
}()

As always, you can check the full implementation at PlayerViewModel_03.swift

Here is a diagram illustrating the data flow:

Post image

The line that goes from the seek side-effect through the relay back to state shows that we now have a loop in our code. State updates to skipping which will start the seek which then changes the state again.

The solution works but it hurts readability quite a bit by splitting the code in two parts. And when you have multiple loops affecting one state the code can become very hard to understand.

Introducing ... RxFeedback

RxFeedback is a simple framework from the creators of RxSwift. It provides us with the new system operator for Observable. It is basically a super-powered version of scan. It takes an initial state, reducer function, scheduler and an array of feedbacks as parameters.
Feedback is nothing else but a closure with this signature: (Observable<State>) -> Observable<Event>. In Feedback we are now able to take state Observable, perform a side effect and return an event as result. Here’s the implementation of the system operator:

public static func system<State, Event>(
        initialState: State,
        reduce: @escaping (State, Event) -> State,
        scheduler: ImmediateSchedulerType,
        feedback: [Feedback<State, Event>]
    ) -> Observable<State> {
        return Observable<State>.deferred {
            // This is roughly equivalent to PublishRelay
            let replaySubject = ReplaySubject<State>.create(bufferSize: 1)

           // Map the array of feedbacks to observables and pass them the state
            let events: Observable<Event> = Observable.merge(
                feedback.map { feedback in
                    return feedback(replaySubject.asObservable())
                }
            )

            ...
            
            // use scan as we did in previous example
            return events.scan(initialState, accumulator: reduce)
                .do(
                    onNext: { output in
                        // when new state is emitted feed it back to the system using the replay subject
                        // this is what creates the feedback loop!
                        replaySubject.onNext(output)
                    }, onSubscribed: {
                        // we need to start the subject with initial state upon subscription
                        replaySubject.onNext(initialState)
                    }
                )
                .startWith(initialState)
        }
    }

From the code we can see that it is very similar to our previous example. Only where we had used didSkipIntro relay to create our feedback loop, RxFeedback feeds the state back to the system using replaySubject and thus creates the feedback loop.

And what would our code look like with RxFeedback?

enum SkipIntroAction {
        case hasIntroStarted(Bool)
        case secondsLeftChanged(Int)
        case didSkipIntro
}

private lazy var skipIntroState: Observable<SkipIntroState> = {
        Observable.system(
            initialState: SkipIntroState.hidden,
            reduce: Self.skipIntroReducer,
            scheduler: MainScheduler.instance,
            feedback: [
                // 1 
                { [hasIntroStarted] _ in
                    hasIntroStarted.map { SkipIntroAction.hasIntroStarted($0) }
                },
                // 2 
                { [player] state -> Observable<SkipIntroAction> in
                    state.flatMapLatest {
                        guard case .showing(let secondsLeft) = $0 else { return .empty() }
                        return Observable.just(.secondsLeftChanged(secondsLeft - 1)).delay(.seconds(1), scheduler: MainScheduler.instance)
                    }
                },
                // 3 
                { [player] state in
                    state.flatMapLatest {
                        guard case .skipping = $0 else { return .empty() }
                        return player.rx.seek(to: Constants.introEnd).andThen(.just(.didSkipIntro))
                    }
                }
            ]
        )
        .share(replay: 1)
}()

private static func skipIntroReducer(state: SkipIntroState, action: SkipIntroAction) -> SkipIntroState {
        switch action {
        case .didSkipIntro:
            return .hidden
        case .hasIntroStarted(let hasIntroStarted):
            return hasIntroStarted ? .showing(secondsLeft: Constants.skipIntroTime) : .hidden
        case .secondsLeftChanged(let secondsLeft):
            return secondsLeft == 0 ? .skipping : .showing(secondsLeft: secondsLeft)
        }
 }

You can also check the full implementation here: PlayerViewModel_04.swift.  Again, here’s a diagram representing the data flow:

Post image

In the code above we have created three feedbacks:

  1. The first feedback is where we bind all inputs into the system. Typically it would have Observable.merge with all the inputs but in this case we only have one input. This is actually not a true feedback loop since it doesn’t use the state Observable. But we still represent it as a feedback loop since in RxFeedback every input into the system must be done like this.
  2. The second feedback reacts to the state and will start counting down when the intro is showing
  3. The third feedback reacts to the state and when it switches to skipping it will seek to the end of the intro. Once the seek is done, it emits the didSkipIntro event.

While at first sight it might seem like a lot of added boilerplate, we've found that it really helps us to break down these complex state machines in our code base into something that can be understood more easily. It also makes debugging easier since you can simply make a Higher Order Reducer that will print every state change into the console. 

Using library feedbacks

RxFeedback also comes with a few prepared feedbacks that will save us some boilerplate. For our example, our second and third feedback react to the change of the state. It works because our state is very simple but in real life the state will contain more parts and we could start our feedback multiple times by accident. To fix it, we could add distinctUntilChanged like so:

state
.map {
     guard case .showing(let secondsLeft) = $0 else { return nil }
     return secondsLeft
}
.distinctUntilChanged()
.compactMap { $0 }
.flatMapLatest { secondsLeft in 
   return Observable.just(.secondsLeftChanged(secondsLeft - 1)).delay(.seconds(1), scheduler: MainScheduler.instance)
}

However, we would find ourselves repeating this code all over again. It is better to use the react feedback included in RxFeedback.

react(
    request: {
        // here we define under which conditions we want to start the feedback
        // if we don’t care about this change return nil and it will be ignored 
        // distinctUntilChanged will be applied on the output
        guard case .showing(let secondsLeft) = $0 else { return nil }
        return secondsLeft
    },
    effects: { secondsLeft in
	 // here we start our side-effect
        Observable.just(.secondsLeftChanged(secondsLeft - 1)).delay(.seconds(1), scheduler: MainScheduler.instance)
    }
),

You can see the final implementation in PlayerViewModel_05.swift

Conclusion

Once you start using RxFeedback you will see feedback loops everywhere in your code. It will help you formalize them and also provide you with a unified way to handle them across the codebase.

While RxFeedback was meant to replace ViewModels as the new architecture, it lacks some key features like reducer composition that would be required to use it on a larger scale. For that purpose, other architectures like TCA would be better suited.

For apps like Showmax, which is written entirely using MVVM in RxSwift, but which would still like to enjoy the benefits of Reducers to handle state, it can be an ideal choice.

Share article via: