[Swift] Reactor Kit

Charlie·2022년 8월 6일
0

사이드 프로젝트에서 Reactor Kit을 사용하기로 결정됐다.
Reactor Kit은 반응형 단방향 프레임워크로 다양한 회사에서 사용하고 있기도 하고, 이해하고 적용하는데 크게 어려움이 없을 것 같아 적용하기로 했다.

Reactor Kit

Reactor Kit은 크게 View, Reactor로 구성된다.

View

ViewController, Cell 등을 포함하며 상태를 표현한다.
비즈니스 로직을 수행하지 않으며 사용자와의 상호작용을 추상화하여 Reactor로 전달하고, Reactor로부터 전달받은 상태를 View component에 바인드한다.
View 프로토콜을 채택하면 View를 정의할 수 있고, DisposeBag 프로퍼티와 bind(reactor: ) 메소드를 필수적으로 정의해야 한다.
또한 View 프로토콜을 채택하면 reactor 프로퍼티가 자동으로 생성되고, 이 프로퍼티에 새로운 값이 지정이 되면 bind 메소드가 자동으로 호출된다.

Reactor

View의 상태를 관찰한다. View로부터 Action을 전달받아 비즈니스 로직을 수행한 후 상태를 변경하여 다시 View에 전달한다.
Reactor 프로토콜을 채택하여 정의하며 사용자와의 상호작용을 표현하는 Action, 상태를 변경하는 Mutation, View의 상태를 표현하는 State, 최초의 상태를 나타내는 initialState를 필수적으로 정의해야 한다.

Example

ReactorKit으로 구현한 프로젝트 가운데 간단한 Counter 예시를 통해 자세히 알아보자.

위와 같이 -버튼과 +버튼을 통해 0.5초의 딜레이를 두고 숫자를 증감시켜 나타내는 프로젝트이다.

예시 프로젝트에서의 Action은 increase, decrease
State는 isLoading, value 가 될 수 있다.

AppDelegate

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let viewController = self.window?.rootViewController as! CounterViewController
    viewController.reactor = CounterViewReactor()
    return true
  }
}

AppDelegate에서 ViewController에 reactor를 주입해준다.
앞서 View에서 reactor 프로퍼티의 값이 지정되면 bind 메소드가 자동으로 호출된다고 하였다. 따라서 AppDelegate에서 View Controller의 reactor에 새로운 값을 지정해주므로 이 때 bind메소드도 호출된다.

Reactor

Reactor는 Reactor 프로토콜을 채택해야 하고, Action, Mutation, State, initialState 를 필수적으로 정의해주어야 한다고 했다.
Action 이나 State 와 달리 Mutation 은 Reactor 클래스 외부에 노출되지 않고, ActionState 를 연결하는 역할을 한다.
Action 이 Reactor로 전달되면 2단계에 거쳐 View의 상태를 변경시킨다.

mutate() 메소드에서는 Action 스트림을 Mutation 스트림으로 변환하고, 네트워킹 또는 비동기 로직등의 Side Effect를 처리한다.
그 결과로 Observable<Mutation>이 반환되고 reduce() 메소드로 전달된다.
reduce() 메소드에서는 이전 상태와 Mutation을 받아서 다음 상태를 반환한다.

import ReactorKit
import RxSwift

final class CounterViewReactor: Reactor {
  // Action is an user interaction
  enum Action {
    case increase
    case decrease
  }

  // Mutate is a state manipulator which is not exposed to a view
  enum Mutation {
    case increaseValue
    case decreaseValue
    case setLoading(Bool)
    case setAlertMessage(String)
  }

  // State is a current view state
  struct State {
    var value: Int
    var isLoading: Bool
    @Pulse var alertMessage: String?
  }

  let initialState: State

  init() {
    self.initialState = State(
      value: 0, // start from 0
      isLoading: false
    )
  }

  // Action -> Mutation
  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case .increase:
      return Observable.concat([
        Observable.just(Mutation.setLoading(true)),
        Observable.just(Mutation.increaseValue).delay(.milliseconds(500), scheduler: MainScheduler.instance),
        Observable.just(Mutation.setLoading(false)),
        Observable.just(Mutation.setAlertMessage("increased!")),
      ])

    case .decrease:
      return Observable.concat([
        Observable.just(Mutation.setLoading(true)),
        Observable.just(Mutation.decreaseValue).delay(.milliseconds(500), scheduler: MainScheduler.instance),
        Observable.just(Mutation.setLoading(false)),
        Observable.just(Mutation.setAlertMessage("decreased!")),
      ])
    }
  }

  // Mutation -> State
  func reduce(state: State, mutation: Mutation) -> State {
    var state = state
    switch mutation {
    case .increaseValue:
      state.value += 1

    case .decreaseValue:
      state.value -= 1

    case let .setLoading(isLoading):
      state.isLoading = isLoading

    case let .setAlertMessage(message):
      state.alertMessage = message
    }
    return state
  }
}

View

해당 예시는 Storyboard를 사용하였기 때문에 View 프로토콜 대신 StoryboardView 프로토콜을 채택하였다. (StoryboardView를 채택했을 경우에는 View가 load된 이후 binding을 해주는 것 이외에 View를 채택한 경우와 차이가 없다.)

import UIKit

import ReactorKit
import RxCocoa
import RxSwift

// Conform to the protocol `View` then the property `self.reactor` will be available.
final class CounterViewController: UIViewController, StoryboardView {
  @IBOutlet var decreaseButton: UIButton!
  @IBOutlet var increaseButton: UIButton!
  @IBOutlet var valueLabel: UILabel!
  @IBOutlet var activityIndicatorView: UIActivityIndicatorView!
  var disposeBag = DisposeBag()

  // Called when the new value is assigned to `self.reactor`
  func bind(reactor: CounterViewReactor) {
    // Action
    increaseButton.rx.tap               // Tap event
      .map { Reactor.Action.increase }  // Convert to Action.increase
      .bind(to: reactor.action)         // Bind to reactor.action
      .disposed(by: disposeBag)

    decreaseButton.rx.tap
      .map { Reactor.Action.decrease }
      .bind(to: reactor.action)
      .disposed(by: disposeBag)

    // State
    reactor.state.map { $0.value }   // 10
      .distinctUntilChanged()
      .map { "\($0)" }               // "10"
      .bind(to: valueLabel.rx.text)  // Bind to valueLabel
      .disposed(by: disposeBag)

    reactor.state.map { $0.isLoading }
      .distinctUntilChanged()
      .bind(to: activityIndicatorView.rx.isAnimating)
      .disposed(by: disposeBag)

    reactor.pulse(\.$alertMessage)
      .compactMap { $0 }
      .subscribe(onNext: { [weak self] message in
        let alertController = UIAlertController(
          title: nil,
          message: message,
          preferredStyle: .alert
        )
        alertController.addAction(UIAlertAction(
          title: "OK",
          style: .default,
          handler: nil
        ))
        self?.present(alertController, animated: true)
      })
      .disposed(by: disposeBag)
  }
}

Reference

ReactorKit Github
[iOS] ReactorKit 알아보기
ReactorKit 시작하기

profile
Hello

0개의 댓글