ReactorKit is a responsive, unidirectional Swift application framework. Let’s introduce the basic concepts and methods of using ReactorKit.

directory

  • The basic concept
    • Design goals
    • View
    • Reactor
  • Advanced usage
    • Global States
    • View Communication
    • Testing the test
    • The Scheduling Scheduling
  • The sample
  • Rely on
  • other

The basic concept

ReactorKit is a hybrid of Flux and Reactive Programming. User actions and the state of the view are passed to the layers through an observable stream. These flows are unidirectional: views emit only action flows, and reactors emit only States flows.

Design goals

  • Testability: ReactorKit’s primary goal is to separate the business logic from the view. This makes the code easy to test. A reactor does not depend on any view. This only tests the binding of the reactor to the View data. Click to view the test method.
  • Small intrusions: ReactorKit does not require this framework for the entire application. For some special views, ReactorKit can be partially used. For existing projects, ReactorKit can be used directly without having to rewrite anything.
  • Less typing: For some simple functions, ReactorKit can reduce code complexity. ReactorKit requires less code than other frameworks. You can start with a simple feature and gradually expand the scope of your use.

View

View is used to display data. Both the View Controller and the cell can be viewed as a view. A view needs to do two things :(1) bind the action flow entered by the user, and (2) bind the status flow to the UI element corresponding to the view. The View layer has no business logic and is only responsible for binding the action flow and the status flow.

To define a view, you simply need to conform an existing class to the protocol view. Then the class automatically has a reactor property. This property of a view is usually set from the outside.

class ProfileViewController: UIViewController.View {
  var disposeBag = DisposeBag()
}

profileViewController.reactor = UserViewReactor(a)// inject reactor
Copy the code

When this reactor property is set (or modified), the bind(reactor:) method is automatically called. A view uses bind(reactor) to bind the operation flow to the status flow.

func bind(reactor: ProfileViewReactor) {
  // action (View -> Reactor)
  refreshButton.rx.tap.map { Reactor.Action.refresh }
    .bind(to: reactor.action)
    .disposed(by: self.disposeBag)

  // state (Reactor -> View)
  reactor.state.map{$0.isFollowing }
    .bind(to: followButton.rx.isSelected)
    .disposed(by: self.disposeBag)
}
Copy the code

The support of the Storyboard

If you start a View Controller with a storyboard, you need to use the StoryboardView protocol. The only difference between the StoryboardView protocol and the View protocol is that the StoryboardView protocol is bound after the View has been loaded.

let viewController = MyViewController()
viewController.reactor = MyViewReactor(a)// will not executes `bind(reactor:)` immediately

class MyViewController: UIViewController.StoryboardView {
  func bind(reactor: MyViewReactor) {
    // this is called after the view is loaded (viewDidLoad)}}Copy the code

Reactor Reactor

The Reactor layer, independent of the UI, controls the state of a view. The most important function of reactor is to separate the operation flow from the view. Each view has its corresponding reactor and delegates all its logic to its reactor.

When a reactor is defined, the reactor protocol must be followed. This protocol requires defining three types: Action, Mutation, and State, and it requires defining an attribute called initialState.

class ProfileViewReactor: Reactor {
  // represent user actions
  enum Action {
    case refreshFollowingStatus(Int)
    case follow(Int)}// represent state changes
  enum Mutation {
    case setFollowing(Bool)}// represents the current view state
  struct State {
    var isFollowing: Bool = false
  }

  let initialState: State = State()}Copy the code

Action represents the user Action, State represents the State of the view, and Mutation is the transition bridge between Action and State. To convert an action stream to a state stream, reactor requires two steps: mutate() and Reduce ().

mutate()

Mutate () takes an Action and generates an Observable

.

func mutate(action: Action) -> Observable<Mutation>
Copy the code

All side effects should be performed within this method, such as asynchronous operations, or API calls.

func mutate(action: Action) -> Observable<Mutation> {
  switch action {
  case let .refreshFollowingStatus(userID): // receive an action
    return UserAPI.isFollowing(userID) // create an API stream
      .map { (isFollowing: Bool) - >Mutation in
        return Mutation.setFollowing(isFollowing) // convert to Mutation stream
      }

  case let .follow(userID):
    return UserAPI.follow()
      .map { _ -> Mutation in
        return Mutation.setFollowing(true)}}}Copy the code

reduce()

Reduce () generates a new State from the current State and an Mutation.

func reduce(state: State, mutation: Mutation) -> State
Copy the code

This is supposed to be an easy one. It should just synchronously return a new State. Do not perform any side effects in this method.

func reduce(state: State, mutation: Mutation) -> State {
  var state = state // create a copy of the old state
  switch mutation {
  case let .setFollowing(isFollowing):
    state.isFollowing = isFollowing // manipulate the state, creating a new state
    return state // return the new state}}Copy the code

transform()

Transform () is used to transform each stream. Three transforms() methods are included here.

func transform(action: Observable<Action>) -> Observable<Action>
func transform(mutation: Observable<Mutation>) -> Observable<Mutation>
func transform(state: Observable<State>) -> Observable<State>
Copy the code

These methods allow streams to be transformed or merged with other streams. For example: When merging global event streams, it is best to use the transform(mutation:) method. Click to see more information about global status.

Alternatively, you can test through these methods.

func transform(action: Observable<Action>) -> Observable<Action> {
  return action.debug("action") // Use RxSwift's debug() operator
}
Copy the code

Advanced usage

Global States

Unlike Redux, ReactorKit does not require a global app state, which means you can manage global state using any type, such as BehaviorSubject, or PublishSubject, or even a Reactor. ReactorKit does not require a global state, so you can use ReactorKit no matter how special your application is.

In the Action → Mutation → State stream, no global State is used. You can use transform(mutation:) to convert a global state to a mutation. For example, we use a global BehaviorSubject to store the currently authorized User, and when currentUser changes, the behavior.setuser (User?) is emitted. , the following scheme can be adopted:

var currentUser: BehaviorSubject<User> // global state

func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
    return Observable.merge(mutation, currentUser.map(Mutation.setUser))
}
Copy the code

In this way, each time the VIEW generates an action or currentUser change to the REACTOR, a mutation is sent.

View Communication

When multiple views communicate, the callback closure or proxy mode is often used. ReactorKit recommends reactive Extensions. The most common example of ControlEvent is UIbutton.rx.tap. The key idea is to turn your custom view into something like a UIButton or a UIILabel.

Suppose we have a ChatViewController to display messages. The ChatViewController has a MessageInputView, and when the user clicks the send button on the MessageInputView, the text is sent to the ChatViewController, Then the ChatViewController is bound to the corresponding Reactor action. The following is an example of the Reactive Extensions of the MessageInputView:

extension Reactive where Base: MessageInputView {
    var sendButtonTap: ControlEvent<String> {
        let source = base.sendButton.rx.tap.withLatestFrom(...)
        return ControlEvent(events: source)
    }
}
Copy the code

So you can use this extension in the ChatViewController. Such as:

messageInputView.rx.sendButtonTap
  .map(Reactor.Action.send)
  .bind(to: reactor.action)
Copy the code

Testing the test

ReactorKit has a built-in feature for testing. You can easily test a View and reactor with this guide.

The test content

First, you need to determine what to test. Two aspects need to be tested, one is view or one is reactor.

  • View
    • Action: Can the Action be sent to the reactor based on the given user interaction?
    • State: Does the view set the property correctly according to the given State?
  • Reactor
    • State: Can State be modified according to the action?

View test

The view can be tested against the Stub Reactor. Reactor has a stub property that prints actions and forces states to be changed. If reactor stubs are enabled, mutate() and Reduce () will not be executed. Stub has the following properties:

var isEnabled: Bool { get set }
var state: StateRelay<Reactor.State> { get }
var action: ActionSubject<Reactor.Action> { get }
var actions: [Reactor.Action] { get } // recorded actions
Copy the code

Here are some test examples:

func testAction_refresh(a) {
  // 1. prepare a stub reactor
  let reactor = MyReactor()
  reactor.stub.isEnabled = true

  // 2. prepare a view with a stub reactor
  let view = MyView()
  view.reactor = reactor

  // 3. send an user interaction programatically
  view.refreshControl.sendActions(for: .valueChanged)

  // 4. assert actions
  XCTAssertEqual(reactor.stub.actions.last, .refresh)
}

func testState_isLoading(a) {
  // 1. prepare a stub reactor
  let reactor = MyReactor()
  reactor.stub.isEnabled = true

  // 2. prepare a view with a stub reactor
  let view = MyView()
  view.reactor = reactor

  // 3. set a stub state
  reactor.stub.state.value = MyReactor.State(isLoading: true)

  // 4. assert view properties
  XCTAssertEqual(view.activityIndicator.isAnimating, true)}Copy the code

Test Reactor

Reactor can be tested separately.

func testIsBookmarked(a) {
    let reactor = MyReactor()
    reactor.action.onNext(.toggleBookmarked)
    XCTAssertEqual(reactor.currentState.isBookmarked, true)
    reactor.action.onNext(.toggleBookmarked)
    XCTAssertEqual(reactor.currentState.isBookmarked, false)}Copy the code

An action sometimes causes the state to change multiple times. For example, a.refresh action sets state.isloading to true first and false after the refresh is complete. In this case, it is difficult to test the state change process with currentState for isLoading of state. In this case, you can use RxTest or RxExpect. Here’s a test case using RxExpect:

func testIsLoading(a) {
  RxExpect("it should change isLoading") { test in
    let reactor = test.retain(MyReactor())
    test.input(reactor.action, [
      next(100, .refresh) // send .refresh at 100 scheduler time
    ])
    test.assert(reactor.state.map{$0.isLoading })
      .since(100) // values since 100 scheduler time
      .assert([
        true.// just after .refresh
        false.// after refreshing])}}Copy the code

The Scheduling Scheduling

Define the Scheduler property to specify the Scheduler for the state flows that are emitted and observed. Note: This queue must be a serial queue. The default value for scheduler is CurrentThreadScheduler.

final class MyReactor: Reactor {
  let scheduler: Scheduler = SerialDispatchQueueScheduler(qos: .default)

  func reduce(state: State, mutation: Mutation) -> State {
    // executed in a background thread
    heavyAndImportantCalculation()
    return state
  }
}
Copy the code

The sample

  • Counter: The most simple and basic example of ReactorKit
  • GitHub Search: A simple application which provides a GitHub repository search
  • RxTodo: iOS Todo Application using ReactorKit
  • Cleverbot: iOS Messaging Application using Cleverbot and ReactorKit
  • Drrrible: Dribbble for iOS using ReactorKit (App Store)
  • Passcode: Passcode for iOS RxSwift, ReactorKit and IGListKit example
  • Flickr Search: A simple application which provides a Flickr Photo search with RxSwift and ReactorKit
  • ReactorKitExample

Rely on

  • RxSwift > = 5.0

other

For more information, see Github