Design Patterns

[Design Patterns] ReactorKit

thoonk: 2023. 1. 10. 16:28
반응형

ReactorKit에 관해 정리한 것을 기록합니다. 

ReactorKit

반응형 및 단방향 Swift 애플리케이션 아키텍처를 위한 프레임워크

옵저버블 스트림을 통하여 각 User Action & View States 레이어에 전달

View만 Action을 방출하며, Reactor만 States를 방출함

ReactorKit’s Design Goal

Testablility

→ 뷰에서 비즈니스 로직을 분리하여 테스트하기 쉬워짐.

 

Start Small

→ ReactorKit은 애플리케이션 전체가 이를 따를 필요 없이 부분적으로 적용할 수 있음.

→ ReactorKit을 사용하기 위해 전체 코드를 재작성할 필요 없음.

 

Less Typing

→ ReactorKit은 단순한 일에 복잡한 코드를 회피하며, 다른 아키텍처에 비해 적은 코드를 지향함. → 간결한 코드

 

View → Action → Reactor → State의 순환 구조로 이루어져 있음.

View

View는 사용자 입력을 Action에 바인딩하여 Reactor에 전달하고 State를 UI Component에 바인딩함.

View Layer에는 비즈니스 로직이 없고 단지 Action과 State를 어떻게 맵핑하는지에 대해 정의되어 있음.

View를 정의하려면, View 프로토콜을 기존 뷰컨트롤러에 채택하면 됨.

→ 해당 뷰컨트롤러는 reactor 프로퍼티를 자동으로 갖게 됨. (이 프로퍼티는 뷰 밖에서 설정됨.)

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

profileViewController.reactor = UserViewReactor() // inject reactor

reactor 프로퍼티각 변경되면, bind(reactor:) 메서드가 호출됨.

Action과 State의 바인딩을 이 메서드에서 구현하면 됨.

Storyboard Support

스토리보드를 사용하고 있다면, StoryboardView 프로토콜을 채택하면 됨.

StoryboardView는 viewDidLoad() 호출 후에 bind(reactor:) 메서드가 호출되는 점이 View와 다름.

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

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

Reactor

view의 state를 관리하는 UI에 독립적인 Layer임.

view로부터 control flow를 분리하는 것이 reactor의 가장 중요한 역할

모든 view는 view에 대응하는 reactor가 있으며, 모든 로직을 reactor에 위임함.

reactor는 view에 의존성이 없어 쉽게 테스트할 수 있음.

Reactor프로토콜을 채택하여 reactor를 정의할 수 있음.

Reactor프로토콜은 Action 및 Mutation, State 세 개의 타입을 필요로 하며,

추가적으로 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()
}

mutate()

Action을 인자로 받으며 Mutation 옵저버블 타입 리턴

비동기 작업 또는 API 콜을 해당 메서드에서 처리

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)
      }
  }
}

reduce()

이전 State와 Mutation을 인자로 받아 새로운 State 리턴

새로운 State를 동기적으로 리턴해야 함, Side Effects 관련 작업은 하면 안 됨.

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
  }
}

transform()

각 스트림을 transform하거나 combine하는 메서드

global event 스트림을 mutation 스트림에 combine하기 좋은 메서드

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

Advanced

Global States

Redux와는 다르게, ReactorKit에는 global app state가 정의되어 있지 않음. → 강제하지 않음.

Action → Mutation → State 흐름에 Global State는 없음. → Global State를 Mutation으로 변환하기 위해서 transform(mutation:)을 사용할 수 있음.

View Communication

View의 통신은 Closure와 Delegate 패턴으로 하는 것이 익숙하지만, ReactorKit은 Reactive Extensions 사용을 권장함.

ControlEvent(UIButton.rx.tap)와 같이 CustomView를 UIButton 또는 UILabel처럼 취급하는 것이 중요한 컨셉임.

 

ChatViewController에 메시지를 표시한다고 가정하면, ChatViewController의 MessageInputView가 사용자의 입려을 받아 전송 버튼을 눌렀을 때, 그 텍스트는 ChatViewController로 보내질 것이고 ChatViewController는 Reactor의 Action에 바인딩할 것임.

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

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

Testing

테스트 대상:

View

  → State: 다음 State에서 View 속성이 제대로 설정되었는가

  → Action: 주어진 User Interaction으로 적절한 Action이 Reactor로 전송되었는가

Reactor

  → State: Action에 따라 State가 적절하게 바뀌었는가

View Testing

view는 stub reactor로 테스트할 수 있음.

reactor의 stub 프로퍼티를 통해 Actions를 기록하고 states를 강제로 변경할 수 있음.

reactor의 stub이 활성화되면, mutate()과 reduce()함수는 실행되지 않음.

stub은 다음 3개의 프로퍼티를 가지고 있음.

var state: StateRelay<Reactor.State> { get }
var action: ActionSubject<Reactor.Action> { get }
var actions: [Reactor.Action] { get } // recorded actions

Example

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

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

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

  // 4. assert actions
  // stub에 기록된 마지막 action 비교
  XCTAssertEqual(reactor.stub.actions.last, .refresh)
}

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

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

  // 3. set a stub state
	// stub의 상태 값을 강제로 변경함.
  reactor.stub.state.value = MyReactor.State(isLoading: true)

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

Reactor Testing

Reactor는 독립적으로 테스트할 수 있음.

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

하나의 action으로 여러 번 state가 변경되는 경우가 있음.

예를 들자면, .refresh action은 state.isLoading 값을 true로 처음에 변경했다가 리프레싱 후에 false로 변경함.

이러한 경우에 state.isLoading 값을 currentState로 테스트하는데 어려움이 있음.

RxTest 또는 RxExpect를 사용하여 이를 해결할 수 있음.

func testIsLoading() {
  // given
  let scheduler = TestScheduler(initialClock: 0)
  let reactor = MyReactor()
  let disposeBag = DisposeBag()

  // when
  scheduler
    .createHotObservable([
      .next(100, .refresh) // send .refresh at 100 scheduler time
    ])
    .subscribe(reactor.action)
    .disposed(by: disposeBag)

  // then
  let response = scheduler.start(created: 0, subscribed: 0, disposed: 1000) {
    reactor.state.map(\\.isLoading)
  }
  XCTAssertEqual(response.events.map(\\.value.element), [
    false, // initial state
    true,  // just after .refresh
    false  // after refreshing
  ])
}

Scheduling

state stream을 observing하는데 사용할 scheduler를 정의함.

이 큐는 직렬 큐로 해야 하며, default scheduler는 MainScheduler.

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
  }
}

Pulse

변경된 경우에만 diff를 가짐. (diff: 두 개의 파일 간 차이에 대한 정보)

var messagePulse: Pulse<String?> = Pulse(wrappedValue: "Hello tokijh")

let oldMessagePulse: Pulse<String?> = messagePulse
messagePulse.value = "Hello tokijh" // add valueUpdatedCount +1

oldMessagePulse.valueUpdatedCount != messagePulse.valueUpdatedCount // true
oldMessagePulse.value == messagePulse.value // true

같은 값을 가진 새로운 값이 할당되어도 이벤트를 받고 싶을 때 사용함.

 

Example.

아래 testRiseValueUpdatedCountWhenSetNewValue() 함수는 state의 값이 변경될 때마다 valueUpdatedCount가 증가하는 것을 볼 수 있음.

testSet0WhenValueUpdatedCountIsOverflowed() 함수는 valueUpdatedCount가 unsigned Int 최대 값 설정 후 pulse 값을 변경하여 오버플로우가 발생된 것을 볼 수 있음.

//
//  PulseTests.swift
//  ReactorKitTests
//
//  Created by 윤중현 on 2021/01/10.
//

import XCTest

import RxSwift
@testable import ReactorKit

final class PulseTests: XCTestCase {
  func testRiseValueUpdatedCountWhenSetNewValue() {
    // given
    struct State {
      @Pulse var value = 0
    }

    var state = State()

    // when & then
    XCTAssertEqual(state.$value.valueUpdatedCount, 0)
    state.value = 10
    XCTAssertEqual(state.$value.valueUpdatedCount, 1)
    XCTAssertEqual(state.$value.valueUpdatedCount, 1) // same count because no new values are assigned.
    state.value = 20
    XCTAssertEqual(state.$value.valueUpdatedCount, 2)
    state.value = 20
    XCTAssertEqual(state.$value.valueUpdatedCount, 3)
    state.value = 20
    XCTAssertEqual(state.$value.valueUpdatedCount, 4)
    XCTAssertEqual(state.$value.valueUpdatedCount, 4) // same count because no new values are assigned.
    state.value = 30
    XCTAssertEqual(state.$value.valueUpdatedCount, 5)
    state.value = 30
    XCTAssertEqual(state.$value.valueUpdatedCount, 6)
  }

  func testSet0WhenValueUpdatedCountIsOverflowed() {
    // given
    var pulse = Pulse<Int>(wrappedValue: 0)

    // make to full
    pulse.valueUpdatedCount = UInt.max
    XCTAssertEqual(pulse.valueUpdatedCount, UInt.max)

    // when & then
    pulse.value = 1 // when valueUpdatedCount is overflowed
    XCTAssertEqual(pulse.valueUpdatedCount, 0)

    pulse.value = 2
    XCTAssertEqual(pulse.valueUpdatedCount, 1)
  }
}

 

참고할만한 Example Projects

https://github.com/ReactorKit/ReactorKit#examples:~:text=is%20not%20called-,Examples,-Counter%3A%20The%20most

 

부족한 점 피드백해주시면 감사합니다 :)

 

Ref. 

https://github.com/ReactorKit/ReactorKit

https://ios-development.tistory.com/782

 

반응형