포스트

삐삐의 핵심 뼈대인 ReactorKit 뿌셔보기

본 글은 미디엄에 게시된 글을 그대로 옮겨온 글입니다.

안녕하세요. 디프만 14기의 삐삐팀으로 활동 중인 iOS 팀원 김건우입니다.

삐삐(Bibbi)는 트렌드로 떠오르고 있는 반응형 프로그래밍(Reactive)을 기반으로 한 프로젝트입니다. 반응형 프로그래밍은 데이터의 흐름 및 변경 사항을 전파는 데 중점을 둔 프로그래밍 패러다임으로, 덕분에 Delegate 패턴이나 클로저를 전달하지 않고도 화면 간 데이터 흐름을 직관적으로 파악할 수 있게 되었습니다.

다음으로 우리 팀은 RxSwift를 프로젝트에 어떻게 적용할 지 고민에 빠졌습니다. 가장 보편적인 Input-Output 패턴으로 로직을 구현할 수 있겠지만, 조금 더 잘 규격화하기를 원했습니다. 프로젝트 규모가 커지더라도 (다른 팀원이 작성한 코드라도) 로직의 흐름이 한 눈에 보여 유지 보수를 용이하게 해주는 장치가 필요했습니다. 이때 기웃거리며 관심갖게 된 라이브러리가 바로 ReactorKit이었습니다.

ReactorKit의 디자인 목표

ReactorKit은 ReSwift와 Flux를 융합하여 만든 아키텍처입니다. ReactorKit은 로직을 단방향으로 흐르게 만들어 줍니다. 사용자가 만들어 낸 ActionMutate를 통해 State를 업데이트하고, 업데이트된 State를 UI에 반영합니다. ReSwift와 Flux 아키텍처와 크게 다르지 않습니다.

ReactorKit 공식 문서에 따르면 본 아키텍처의 디자인 목표를 아래와 같이 설명하고 있습니다.

  • 테스트 용이: ReactorKit의 주요 목표는 뷰로부터 비즈니스 로직을 분리하는 것입니다. 이를 통해 테스트 용이한 코드를 작성할 수 있습니다. Reactor는 뷰에 아무런 의존성이 없으며, 그저 테스트 Reactor를 테스트 뷰에 바인딩시키면 됩니다. 자세한 내용은 여기를 참조해주세요.
  • 유연한 적용: ReactorKit은 전체 애플리케이션이 하나의 아키텍처를 따르게 할 필요가 없습니다. ReactorKit은 단일 또는 다중 뷰에 부분적으로 적용될 수 있습니다. 기존 프로젝트에서 모든 코드에 ReactorKit을 적용할 필요가 없습니다.
  • 적은 타이핑: ReactorKit은 복잡함을 피하는 데 중점을 두었습니다. ReactorKit은 다른 아키텍처와 비교해 적은 코드를 필요로 합니다.

프로젝트를 하며 추가로 느낀 장점은 로직 흐름을 파악하기 쉽다는 점이었습니다. 불가피하게 다른 팀원이 작성한 코드를 수정해야 할 때가 있는데, 이러한 단방향 로직 흐름은 코드를 파악하기 휠씬 쉽게 만들어주었습니다.

ReactorKit은 어떻게 작동하나요?

Reactor는 네트워크 통신・디스크 I/O 등 뷰에 필요한 로직을 처리하고, UI를 업데이트시켜주는 레이어입니다. 즉, Reactor는 뷰로부터 제어 흐름(Control Flow)를 분리하고, 로직을 위임하는 역할을 합니다. 우리에게 익순한 MVVM패턴의 ViewModel을 대체하는 녀석입니다.

아래 그림은 Reactor의 제어 흐름이 어떻게 흐르는지 보여줍니다.

2

흐름은 매우 단순합니다. Action은 버튼 클릭과 같이 UI 반응을 나타냅니다. State는 뷰의 상태를 의미합니다. MutationActionState를 이어주는 징검다리로 이어주는 역할을 합니다. Mutation에서 발생한 Action을 바탕으로 네트워크 통신・디스크 I/O 등 사이드 이펙트(Side Effect)를 수행해야 합니다.

3

아래 예제는 Reactor의 기본적인 제어 흐름을 보여줍니다.

Mutate

사용자가 만들어 낸 Actionmutate(action:) 메서드로 전달되어 Observable<Mutate>를 반환해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case let .didTapFollowButton(userId):
        return UserService.executeFollowUser(userId)
            .flatMap { success -> Observable<Mutation> in
                if success {
                    return Observable.concat(
                        Observable<Mutation>.just(.presentSuccessToastMessage),
                        Observable<Mutation>.just(.setFollowButtonTintColor(UIColor.Red))
                    )
                }
                return Observable<Mutation>.just(.presentFailureToastMessage)
            }
    }
}

위 예제는 팔로우 버튼 클릭 Action이 받게되면 흐르게 되는 로직을 보여줍니다. 팔로우 버튼을 클릭하면 전달된 userId를 바탕으로 서버와 통신을 하게 됩니다. 팔로우에 성공하면 성공 토스트 메시지(presentSuccessToastMessage)를 띄우고, 버튼의 색상을 빨강색으로(setFollowButtonTintColor) 바꿉니다. 팔로우에 실패하면 실패 토스트 메시지(presentFailureToastMessage)를 띄우게 됩니다.

Reduce

그리고 Mutationreduce(state:mutate:) 메서드로 전달되어 State를 반환해야 합니다. 이렇게 업데이트된 State는 UI와 바인딩되어 UI를 업데이트합니다.

1
2
3
4
5
6
7
8
9
10
11
12
func reduce(state: State, mutation: Mutation) -> State {
    var newState = state
    switch mutation {
    case .setSuccessToastMessage:
        newState.shouldPresentSucessToastMessage = true
    case .setFailureToastMessage:
        newState.shouldPresentFailureToastMessage = true
    case let .setFollowButtonTintColor(color):
        newState.followButtonTintColor = color
    }
    return newState
}

ReactorKit을 처음 접할 무렵 Mutation의 필요성에 많은 의문이 들었습니다. ActionState의 중간에 위치한 Mutation을 거치지 않고 곧바로 State로 향해도 별로 문제가 없어 보였습니다. 앞서 Mutation 예제 코드를 보시면 아시다피시, Mutation은 받은 Action을 토대로 해야 할 UI 업데이트를 쪼개주는 역할을 하고 있습니다. 코드의 중복을 줄이고, 유연함을 더하는 효과가 있습니다.

Reactor의 특징을 간략하게 살펴보았습니다. 이제 직접 Reactor를 작성해보며 조금 더 자세한 이야기를 해볼까 합니다.

직접 Reactor 작성해보기

텍스트 필드(TextField)에 소문자를 입력하면 대문자로 변환된 문자열과 문자열의 길이를 레이블(Label)에 출력하는 간단한 앱을 만들어 보겠습니다. 예제 프로젝트에 적용한 라이브러리는 ReactorKit, RxSwift, RxCocoa, SnapKit, Then입니다.

먼저 Reactor부터 작성하겠습니다. 뷰 컨트롤러(ViewController)에 바인딩되는 Reactor는 Reactor 프로토콜을 채택해야 합니다. Reactor 프로토콜은 Action, Mutation, State 엔터티와 intialState 프로퍼티를 가지고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public protocol Reactor: AnyObject {
  /// An action represents user actions.
  associatedtype Action

  /// A mutation represents state changes.
  associatedtype Mutation = Action

  /// A State represents the current state of a view.
  associatedtype State

  typealias Scheduler = ImmediateSchedulerType

  /// The action from the view. Bind user inputs to this subject.
  var action: ActionSubject<Action> { get }

  /// The initial state.
  var initialState: State { get }

  /// The current state. This value is changed just after the state stream emits a new state.
  var currentState: State { get }

  /// The state stream. Use this observable to observe the state changes.
  var state: Observable<State> { get }

  // <...이하 후략...>
}

우리가 작성할 Reactor는 텍스트 필드 입력 하나와 대문자로 변환된 문자열, 문자열의 길이라는 출력 두 개를 가집니다. 하나의 Action과 두 개의 State를 가지는 거죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// TextFieldViewReactor.swift

import UIKit

import ReactorKit
import RxSwift

final class TextFieldViewReactor: Reactor {
    // MARK: - Action
    enum Action {
        case inputField(String)
    }
    
    // MARK: - Mutation
    enum Mutation {
        case setLengthOfString(Int)
        case setCapitalizedString(String)
    }
    
    // MARK: - State
    struct State {
        var capitalizedString: String?
        var lengthOfString: Int?
    }
    
    // MARK: - Properties
    var initialState: State
    
    // MARK: - Intializer
    init() {
        self.initialState = State()
    }
    
    // MARK: - Mutate
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case let .inputField(input):
            let lengthOfString = input.count
            let capitalizedString = input.uppercased()
            return Observable<Mutation>.concat(
                Observable.just(.setLengthOfString(lengthOfString)),
                Observable.just(.setCapitalizedString(capitalizedString))
            )
        }
    }
    
    // MARK: - Reduce
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        switch mutation {
        case let .setLengthOfString(length):
            newState.lengthOfString = length
        case let .setCapitalizedString(string):
            newState.capitalizedString = string
        }
        return newState
    }
}

특정 Reactor와 바인딩하기 위해 뷰 컨트롤러는 View 프로토콜을 채택해야 합니다. View 프로토콜은 reactor 프로퍼티와 bind(reactor:) 메서드를 가지고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public protocol View: AnyObject {
  associatedtype Reactor: ReactorKit.Reactor

  /// A dispose bag. It is disposed each time the `reactor` is assigned.
  var disposeBag: DisposeBag { get set }

  /// A view's reactor. `bind(reactor:)` gets called when the new value is assigned to this property.
  var reactor: Reactor? { get set }

  /// Creates RxSwift bindings. This method is called each time the `reactor` is assigned.
  ///
  /// Here is a typical implementation example:
  ///
  /// ```
  /// func bind(reactor: MyReactor) {
  ///   // Action
  ///   increaseButton.rx.tap
  ///     .bind(to: Reactor.Action.increase)
  ///     .disposed(by: disposeBag)
  ///
  ///   // State
  ///   reactor.state.map { $0.count }
  ///     .bind(to: countLabel.rx.text)
  ///     .disposed(by: disposeBag)
  /// }
  /// ```
  ///
  /// - warning: It's not recommended to call this method directly.
  func bind(reactor: Reactor)
}

reactor 프로퍼티에 Reactor를 할당하거나 수정하면 bind(reactor:) 메서드가 자동으로 호출되면서 Reactor와 뷰 컨트롤러가 바인딩됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SceneDelegate.swift

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(
      _ scene: UIScene, 
      willConnectTo session: UISceneSession, 
      options connectionOptions: UIScene.ConnectionOptions
      ) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        
        let reactor = TextFieldViewReactor()
        let textfieldViewController = TextFieldViewController(reactor: reactor)
        
        window?.windowScene = windowScene
        window?.rootViewController = UINavigationController(rootViewController: textfieldViewController)
        window?.makeKeyAndVisible()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// TextFieldViewController.swift

import UIKit

import SnapKit
import Then
import ReactorKit
import RxSwift
import RxCocoa

final class TextFieldViewController: UIViewController, View {

    // MARK: - Typealias
    typealias Reactor = TextFieldViewReactor
    
    // MARK: - Views
    var textfield: UITextField = UITextField()
    var capitalizedStringLabel: UILabel = UILabel()
    var lengthOfStringLabel: UILabel = UILabel()
    
    var stackView: UIStackView = UIStackView()
    
    // MARK: - Properties
    var disposeBag: DisposeBag = DisposeBag()
    
    // MARK: - Intializer
    convenience init(reactor: TextFieldViewReactor) {
        self.init()
        self.reactor = reactor
    }
    
    // MARK: - LifeCycles
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupAutoLayout()
        setupAttributes()
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        textfield.resignFirstResponder()
    }
    // MARK: - Reactor
    func bind(reactor: TextFieldViewReactor) {
        textfield.rx.text.orEmpty 
            .map { Reactor.Action.inputField($0) }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
        
        reactor.state.map { $0.capitalizedString }
            .distinctUntilChanged()
            .bind(to: capitalizedStringLabel.rx.text)
            .disposed(by: disposeBag)
        
        reactor.state.compactMap { $0.lengthOfString }
            .map { "\($0)" }
            .distinctUntilChanged()
            .bind(to: lengthOfStringLabel.rx.text)
            .disposed(by: disposeBag)
    }
    
    // MARK: - Attributes
    func setupUI() {
        view.addSubview(stackView)
        stackView.addArrangedSubview(capitalizedStringLabel)
        stackView.addArrangedSubview(lengthOfStringLabel)
        stackView.addArrangedSubview(textfield)
    }
    
    func setupAutoLayout() {
        stackView.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.horizontalEdges.equalToSuperview().inset(10)
        }
    }
    
    func setupAttributes() {
        view.backgroundColor = UIColor.white
        
        stackView.do { view in
            view.axis = .vertical
            view.alignment = .fill
            view.distribution = .fillProportionally
            view.spacing = 10
        }
        
        textfield.do { view in
            view.borderStyle = .bezel
        }
        textfield.becomeFirstResponder()
    }
}

4

짜잔🎉 이렇게 앱 하나가 완성되었습니다. 이제 끝이라고 하고 싶지만, 그 전에 우리가 살펴보아야 할 중요한 포인트가 몇 가지 있습니다.

Reactor는 intialState도 방출

Reactor는 뷰 컨트롤러에서 bind(reactor:) 메서드를 호출하면 곧바로 intialState를 방출합니다. 이를 통해 뷰가 보여질 때(viewIsAppearing) 최초 상태를 정할 수 있습니다. 아래 예제에서 TextFieldViewController의 배경 색상(BackgroundColor)을 변경하였습니다. 만약 바인딩 시 initialState가 방출되지 않기를 원한다면 State의 프로퍼티에 nil을 할당하거나, skip 연산자로 스트림을 한 번 무시해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SceneDelegate.swift 

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // <...중략...>
        
        let reactor = TextFieldViewReactor(backgroundColor: UIColor.systemTeal)
        let textfieldViewController = TextFieldViewController(reactor: reactor)
        
        window?.windowScene = windowScene
        window?.rootViewController = UINavigationController(rootViewController: textfieldViewController)
        window?.makeKeyAndVisible()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// TextFieldViewController.swift

final class TextFieldViewController: UIViewController, View {

    // <...전략...>
    
    // MARK: - Reactor
    func bind(reactor: TextFieldViewReactor) {
        // <...중략...>
        
        // ⭐️
        reactor.state.map { $0.backgroundColor }
            .distinctUntilChanged()
            .bind(to: view.rx.backgroundColor)
            .disposed(by: disposeBag)
    }
    
    // <...후략...>
}

5

DistinctUntilChanged 연산자

위 예제를 보면 모든 스트림에 distinctUntilChanged 연산자가 붙어있습니다. distinctUntilChanged 연산자는 방출하고자 하는 항목이 이전에 방출한 항목과 동일하다면 스트림을 막는 연산자입니다. 이러한 연산자가 붙어있는 이유는 Reactor의 독특한 특성에 기인합니다. Reactor는 State의 프로퍼티 중 하나라도 값이 재할당되면 다른 프로퍼티의 값 할당 유무에 상관없이 전체 State를 항목으로 방출(Observable<State>)합니다. 즉, 위 예제에서 capitalizedString 프로퍼티에만 값이 재할당되더라도, 이와 상관없는 lengthOfString 프로퍼티도 함께 전체 State 항목으로 방출됩니다.

즉, 새로운 값이 할당되지 않은 State는 굳이 UI를 업데이트해 줄 필요가 없기 때문에, 불필요한 스트림을 막기 위해 distinctUntilChanged 연산자를 사용합니다.

하지만, 무조건 값이 바뀌어야만 UI를 업데이트해야 할까요? 세상일이 으레 그렇듯이 꼭 그래야만 하는 법은 없습니다.

Reactor 흐름을 제어하는 @Pulse 키워드

@Pulse는 Reactor의 흐름을 제어할 수 있는 프로퍼티 래퍼(Property Wrapper)입니다. @Pulse 프로퍼티 래퍼로 선언된 State의 프로퍼티는 새로운 값이 (심지어 동일한 값이더라도) 할당될 때만 해당 프로퍼티를 항목으로 방출합니다.

앞서 예제에서 텍스트 필드에 영문자가 아닌 숫자가 입력되면 경고창(Alert)을 띄우도록 기능을 추가해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// TextFieldViewReactor.swift

final class TextFieldViewReactor: Reactor {
    // <...전략...>
    
    // MARK: - Mutate
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case let .inputField(input):
            // ⭐️
            if input.isNumber {
                return Observable<Mutation>.just(.showAlertMessage("소문자를 입력할 수 없습니다."))
            } else {
                let lengthOfString = input.count
                let capitalizedString = input.uppercased()
                return Observable<Mutation>.concat(
                    Observable.just(.setCapitalizedString(capitalizedString)),
                    Observable.just(.setLengthOfString(lengthOfString))
                )
            }
        }
    }
    
    // MARK: - Reduce
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        switch mutation {
        case let .setCapitalizedString(string):
            newState.capitalizedString = string
        case let .setLengthOfString(length):
            newState.lengthOfString = length
        // ⭐️
        case let .showAlertMessage(message):
            newState.alertMessage = message
        }
        return newState
    }
}

// ⭐️
extension String {
    var isNumber: Bool {
        return self.range(
            of: ".*[0-9]+.*",
            options: .regularExpression) != nil
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// TextFieldViewController.swift

final class TextFieldViewController: UIViewController, View {

    // <...전략...>
    
    // MARK: - Mutation
    enum Mutation {
        case setCapitalizedString(String)
        case setLengthOfString(Int)
        
        // ⭐️
        case showAlertMessage(String)
    }
    
    // MARK: - State
    struct State {
        var backgroundColor: UIColor?
        
        var lengthOfString: Int?
        var capitalizedString: String?
        
        // ⭐️
        @Pulse var alertMessage: String?
    }

    // <...중략...>

    // MARK: - Reactor
    func bind(reactor: TextFieldViewReactor) {
        // <...중략...>
        
        // ⭐️
        reactor.pulse(\.$alertMessage)
            .compactMap { $0 }
            .bind(with: self) {
                $0.showWarningAlert($1)
            }
            .disposed(by: disposeBag)
        
        // <...중략...>
    }
    
    // <...후략...>
}

// MARK: - Extensions
extension TextFieldViewController {
    // ⭐️
    func showWarningAlert(_ message: String) {
        // Clear all field and labels
        textfield.text?.removeAll()
        lengthOfStringLabel.text = "0"
        capitalizedStringLabel.text?.removeAll()
        
        // Show warning alert
        let alert = UIAlertController(
            title: "입력 오류",
            message: message,
            preferredStyle: .alert
        )
        let ok = UIAlertAction(title: "확인", style: .default)
        alert.addAction(ok)
        
        present(alert, animated: true)
    }
}

6

짜잔🎉 잘 작동하죠?

그렇다면 앞서 예제에서 ⌜distinctUntilChanged 연산자만 삭제하면 비슷하지 않으냐⌟고 의문이 드실 수도 있습니다. 앞서 말씀드렸다시피, Reactor는 State의 프로퍼티 중 하나라도 값이 재할당되면 다른 프로퍼티의 값 할당 유무에 상관없이 전체 State를 항목으로 방출(Observable<State>)합니다. 즉, @Pulse 프로퍼티 래퍼로 alertMessage 프로퍼티를 선언하지 않고 distinctUntilChanged 연산자를 붙여주지 않는다면, capitalizedString 프로퍼티에만 값이 재할당되더라도, 이와 상관없는 alertMessage 프로퍼티도 함께 전체 State 항목으로 방출되어 경고창이 뜨게 됩니다. 텍스트 필드에 입력할 때마다 경고창이 뜨게 되는 거죠. 그렇다고 @Pulse 프로퍼티 래퍼는 빼버리고 distinctUntilChanged 연산자만 붙이면 동일한 alertMessage를 스트림으로 받지 못하게 되겠죠.

뷰 컨트롤러에서 @Pulse 프로퍼티 래퍼로 선언된 State의 프로퍼티를 바인딩할 때, pulse(_:) 메서드를 사용해야 합니다. 이는 pulse(_:) 메서드의 코드를 살펴보면 쉽게 이해할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@propertyWrapper
public struct Pulse<Value> {

  public var value: Value {
    didSet {
      riseValueUpdatedCount()
    }
  }

  public internal(set) var valueUpdatedCount = UInt.min

  public init(wrappedValue: Value) {
    self.value = wrappedValue
  }

  public var wrappedValue: Value {
    get { value }
    set { value = newValue }
  }

  public var projectedValue: Pulse<Value> {
    self
  }

  private mutating func riseValueUpdatedCount() {
    valueUpdatedCount &+= 1
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
extension Reactor {
  /// Returns an observable sequence that emits the value of the pulse only when its valueUpdatedCount changes.
  ///
  /// - seealso: [Pulse document](https://github.com/ReactorKit/ReactorKit/blob/master/Documentation/Contents/Pulse.md)
  /// - seealso: [The official document introduction](https://github.com/ReactorKit/ReactorKit#pulse)
  ///
  /// - parameter transformToPulse: A transform function to apply to the current state of the reactor
  /// to produce a pulse.
  /// - returns: An observable that emits the value of the pulse whenever its valueUpdatedCount changes.
  public func pulse<Result>(_ transformToPulse: @escaping (State) throws -> Pulse<Result>) -> Observable<Result> {
    state.map(transformToPulse).distinctUntilChanged(\.valueUpdatedCount).map(\.value)
  }
}

@Pulse 프로퍼티 래퍼의 코드를 살펴보면 valueUpdatedCount 프로퍼티가 있습니다. 해당 프로퍼티에 새로운 값이 할당될 때마다 riseValueUpdatedCount() 메서드에 의해 valueUpdatedCount 프로퍼티에 1 증가된 값이 할당됩니다. 그리고 아래 예제 코드에서 pulse(_:) 메서드는 해당 프로퍼티의 valueUpdatedCount 값이 바뀐 경우에만 항목을 방출하도록 하고 있습니다. 복잡해보이는 키워드지만, 알고보면 굉장히 단순한 원리가 숨어있답니다.

다른 뷰에 이벤트를 전달하는 GlobalState

ReactorKit을 처음 접하며 제일 난감했던 점 중 하나는 ⌜뷰 컨트롤러 간 이벤트 전달을 어떻게 처리할 지⌟였습니다. 다행히도 ReactorKit은 transform(_:) 메서드를 통해 다른 뷰 컨트롤러에서 방출하는 이벤트를 받아 손쉽게 UI를 업데이트할 수 있습니다.

GlobalState는 어느 Reactor에도 종속되지 않은 독립적인 전역 객체로, PublishSubjectBehaviorSubject를 통해 서로 항목을 주고 받아 하위 뷰 컨트롤러가 상위 뷰 컨트롤러에게 필요한 로직을 처리하게 지시할 수 있습니다.

앞서 예제에서 SettingsViewController를 추가하고, 해당 뷰 컨트롤러에서 TextFieldViewController의 배경 색상을 무작위로 바꾸는 기능을 추가해 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ServiceProvider.swift

import UIKit

import RxSwift

class BaseService {
    unowned let provider: ServiceProviderProtocol
    
    init(provider: ServiceProviderProtocol) {
        self.provider = provider
    }
}

enum SettingEvent {
    case setBackgroundColor(UIColor?)
}

protocol SettingServiceProtocol {
    var event: PublishSubject<SettingEvent> { get }
    
    @discardableResult
    func setBackgroundColor(_ color: UIColor?) -> Observable<UIColor?>
}

final class SettingService: BaseService, SettingServiceProtocol {
    var event: PublishSubject<SettingEvent> = PublishSubject<SettingEvent>()
    
    func setBackgroundColor(_ color: UIColor?) -> Observable<UIColor?> {
        event.onNext(.setBackgroundColor(color))
        return Observable<UIColor?>.just(color)
    }
}


protocol ServiceProviderProtocol: AnyObject {
    var settings: SettingServiceProtocol { get }
}

final class ServiceProvider: ServiceProviderProtocol {
    lazy var settings: SettingServiceProtocol = SettingService(provider: self)
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// SettingsViewReactor.swift

import UIKit

import ReactorKit
import RxSwift

final class SettingViewReactor: Reactor {
    // MARK: - Action
    enum Action {
        case didTapButton
    }
    
    // MARK: - Mutation
    enum Mutation {
        case setBackgroundColor(UIColor?)
    }
    
    // MARK: - State
    struct State {
        var backgroundColor: UIColor?
    }
    
    // MARK: - Properties
    var initialState: State
    var provider: ServiceProviderProtocol
    
    let colors: [UIColor] = [.systemRed, .systemOrange, .systemYellow, .systemTeal, .systemGreen]
    
    // MARK: - Intializer
    init(
        backgroundColor color: UIColor? = nil,
        service provider: ServiceProviderProtocol
    ) {
        self.initialState = State(
            backgroundColor: color
        )
        self.provider = provider
    }
    
    // MARK: - Mutate
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .didTapButton:
            let color = colors.randomElement()
            provider.settings.setBackgroundColor(color) // ⭐️
            return Observable<Mutation>.just(.setBackgroundColor(color))
        }
    }
    
    // MARK: - Reduce
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        switch mutation {
        case let .setBackgroundColor(color):
            newState.backgroundColor = color
        }
        return newState
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// SettingsViewController.swift

import UIKit

import SnapKit
import Then
import ReactorKit
import RxSwift
import RxCocoa

final class SettingViewController: UIViewController, View {

    // MARK: - Typealias
    typealias Reactor = SettingViewReactor
    
    // MARK: - Views
    var button: UIButton = UIButton(configuration: .borderedProminent())
    
    // MARK: - Properties
    var disposeBag: DisposeBag = DisposeBag()
    
    // MARK: - Intializer
    convenience init(reactor: SettingViewReactor) {
        self.init()
        self.reactor = reactor
    }
    
    // MARK: - LifeCycles
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupAutoLayout()
        setupAttributes()
    }
    
    // MARK: - Reactor
    func bind(reactor: SettingViewReactor) {
        button.rx.tap
            .map { Reactor.Action.didTapButton }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
        
        reactor.state.map { $0.backgroundColor }
            .distinctUntilChanged()
            .bind(to: view.rx.backgroundColor)
            .disposed(by: disposeBag)
    }
    
    // MARK: - Attributes
    func setupUI() {
        view.addSubview(button)
    }
    
    func setupAutoLayout() {
        button.snp.makeConstraints {
            $0.center.equalToSuperview()
        }
    }
    
    func setupAttributes() {
        button.do {
            $0.setTitle("색상 바꾸기", for: .normal)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// TextFieldViewReactor.swift

final class TextFieldViewReactor: Reactor {
    // MARK: - Action
    enum Action {
        case inputField(String)
        case didTapSettingButton // ⭐️
    }
    
    // MARK: - Mutation
    enum Mutation {
        case setBackgroundColor(UIColor?)
        
        case setCapitalizedString(String)
        case setLengthOfString(Int)
        
        case showAlertMessage(String)
        case pushSettingViewController(UIViewController?) // ⭐️
    }
    
    // MARK: - State
    struct State {
        var backgroundColor: UIColor?
        
        var lengthOfString: Int?
        var capitalizedString: String?
        
        @Pulse var alertMessage: String?
        @Pulse var settingViewController: UIViewController? // ⭐️
    }
    
    // MARK: - Properties
    var initialState: State
    var provider: ServiceProviderProtocol // ⭐️
    
    // MARK: - Intializer
    init(
        backgroundColor color: UIColor? = nil,
        service provider: ServiceProviderProtocol // ⭐️
    ) {
        self.initialState = State(
            backgroundColor: color
        )
        self.provider = provider
    }
    
    // MARK: - Transform
    func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
        // ⭐️
        let eventMutation = provider.settings.event
            .flatMap { event in
                switch event {
                case let .setBackgroundColor(color):
                    return Observable<Mutation>.just(.setBackgroundColor(color))
                }
            }
        
        return Observable<Mutation>.merge(mutation, eventMutation)
    }
    
    // MARK: - Mutate
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case let .inputField(input):
            if input.isNumber {
                return Observable<Mutation>.just(.showAlertMessage("소문자를 입력할 수 없습니다."))
            } else {
                let lengthOfString = input.count
                let capitalizedString = input.uppercased()
                return Observable<Mutation>.concat(
                    Observable.just(.setCapitalizedString(capitalizedString)),
                    Observable.just(.setLengthOfString(lengthOfString))
                )
            }
        // ⭐️
        case .didTapSettingButton:
            let reactor = SettingViewReactor(
                backgroundColor: currentState.backgroundColor,
                service: provider
            )
            let viewController = SettingViewController(reactor: reactor)
            return Observable<Mutation>.just(.pushSettingViewController(viewController))
        }
    }
    
    // MARK: - Reduce
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        switch mutation {
        case let .setBackgroundColor(color):
            newState.backgroundColor = color
        case let .setCapitalizedString(string):
            newState.capitalizedString = string
        case let .setLengthOfString(length):
            newState.lengthOfString = length
        case let .showAlertMessage(message):
            newState.alertMessage = message
        // ⭐️
        case let .pushSettingViewController(vc):
            newState.settingViewController = vc
        }
        return newState
    }
}

// <...후략...>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// TextFieldViewController.swift


final class TextFieldViewController: UIViewController, View {

    // <...전략...>

    // MARK: - Views
    var settingButton: UIButton = UIButton(configuration: .borderedProminent())
    
    var stackView: UIStackView = UIStackView()

    // <...중략...>
    
    // MARK: - Reactor
    func bind(reactor: TextFieldViewReactor) {
        textfield.rx.text.orEmpty
            .map { Reactor.Action.inputField($0) }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
        
        // ⭐️
        settingButton.rx.tap
            .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
            .map { Reactor.Action.didTapSettingButton }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
        
        // ⭐️
        reactor.pulse(\.$settingViewController)
            .compactMap { $0 }
            .bind(with: self) { owner, vc in
                owner.navigationController?.pushViewController(vc, animated: true)
            }
            .disposed(by: disposeBag)
    }
    
    // <...후략...>
}

7

짜잔🎉 이제 정말로 끝났습니다. 코드가 꽤 복잡해 보이지만, 한번 잘 만들어 놓으면 정말 편하게 사용할 수 있습니다.

transform(_:) 메서드에 대한 자세한 내용은 여기를 참조해주세요.

마무리

ReactorKit은 Rewift와 Flux를 융합하여 만든 아키텍처로, 단방향 데이터 흐름을 제공하여 뷰와 비즈니스 로직을 분리하고 테스트 용이성을 높입니다. 유연하게 적용할 수 있으며, 코드 작성량을 줄이고 로직 흐름을 명확하게 파악할 수 있습니다. 이는 팀 내 협업을 용이하게 하고, 코드 유지 보수성을 향상시킵니다. 따라서 ReactorKit은 애플리케이션 개발에 매우 유용한 아키텍처입니다.

참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.