사이드 프로젝트에서 Reactor Kit을 사용하기로 결정됐다.
Reactor Kit은 반응형 단방향 프레임워크로 다양한 회사에서 사용하고 있기도 하고, 이해하고 적용하는데 크게 어려움이 없을 것 같아 적용하기로 했다.
Reactor Kit은 크게 View, Reactor로 구성된다.
ViewController, Cell 등을 포함하며 상태를 표현한다.
비즈니스 로직을 수행하지 않으며 사용자와의 상호작용을 추상화하여 Reactor로 전달하고, Reactor로부터 전달받은 상태를 View component에 바인드한다.
View 프로토콜을 채택하면 View를 정의할 수 있고, DisposeBag
프로퍼티와 bind(reactor: )
메소드를 필수적으로 정의해야 한다.
또한 View 프로토콜을 채택하면 reactor
프로퍼티가 자동으로 생성되고, 이 프로퍼티에 새로운 값이 지정이 되면 bind
메소드가 자동으로 호출된다.
View의 상태를 관찰한다. View로부터 Action을 전달받아 비즈니스 로직을 수행한 후 상태를 변경하여 다시 View에 전달한다.
Reactor 프로토콜을 채택하여 정의하며 사용자와의 상호작용을 표현하는 Action
, 상태를 변경하는 Mutation
, View의 상태를 표현하는 State
, 최초의 상태를 나타내는 initialState
를 필수적으로 정의해야 한다.
ReactorKit으로 구현한 프로젝트 가운데 간단한 Counter 예시를 통해 자세히 알아보자.
위와 같이 -버튼과 +버튼을 통해 0.5초의 딜레이를 두고 숫자를 증감시켜 나타내는 프로젝트이다.
예시 프로젝트에서의 Action은 increase
, decrease
State는 isLoading
, value
가 될 수 있다.
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
프로토콜을 채택해야 하고, Action
, Mutation
, State
, initialState
를 필수적으로 정의해주어야 한다고 했다.
Action
이나 State
와 달리 Mutation
은 Reactor 클래스 외부에 노출되지 않고, Action
과 State
를 연결하는 역할을 한다.
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
}
}
해당 예시는 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)
}
}