
이 글은 https://hasensprung.tistory.com/186 에서 이어지는 글 입니다.
[Reactorkit] Reactorkit 도입기와 여전히 남은 고민들
MVVM을 사용하면서 가장 힘들었던 부분은 모두가 조금씩은 다른 각자의 형태로 MVVM을 사용하고 있다는 점이었습니다. 어느정도 틀이 잡혀 있는 Input/Output Transform 등 MVVM을 다양한 방식으로 시도했
hasensprung.tistory.com
1. Reactorkit의 기본 개념
ReactorKit은 어떤 방식으로 단방향과 코드의 일관성을 도와주는 걸까요?
ReactorKit은 다음과 같은 방식으로 작동합니다.

이를 이해하기 위해서는 3가지 형태를 알아야 합니다.
Action, Mutation, State 입니다.
크게 보면, Action이 Mutation으로 변했다가, 마지막에 State로 변하는 것이죠.
Action
Action은 View에서 일어나는 Event입니다.
유저가 버튼 터치를 하거나, List를 스크롤하거나 등등을 의미하죠.
Action의 예시는 다음과 같습니다.
enum Action {
case didTapSegmentControl(Int)
case didSlidePage(Int)
case didTapEditModeButton
case didTapStyleModeButton
case didTapDeleteButton
}
Mutation
Mutation은 Action이 들어왔을 때 해야 할 작업 단위들을 의미합니다.
한 Action이 여러개의 Mutation을 트리거할 수도 있습니다.
예를 들어 viewDidLoad에 진입했다는 액션이, setLoadingStatus(true), fetchData, setLoadingStatus(false)와 같은 Mutation을 순서대로 트리거할 수 있습니다.
Mutation의 예시는 다음과 같습니다.
enum Mutation {
case setSelectedTabIndex(Int)
case toggleIsEditMode
case toggleStyleMode
case setDeleteConfirmAlert
}
State
State는 최종적으로 View로 방출되는 값입니다.
즉, View는 State를 관찰하고 있다가, 변화가 생기면 특정한 로직을 수행하도록 되어있습니다.
State의 예시는 다음과 같습니다.
struct State {
@Pulse var tabInfos: [TabInfo]
@Pulse var selectedTabIndex: Int
@Pulse var isEditMode: Bool = false
@Pulse var styleMode: StyleMode = .list
@Pulse var shouldShowDeleteConfirmAlert: Void?
}
2. Reactorkit의 각 요소의 변환 과정
간단하게 설명하면 다음과 같습니다.
1. View에서 Action(Event)을 받습니다.
2. 받은 Event는 func mutate(action: Action) -> Observable<Mutation> 메소드에서 Mutation 형태로 변경됩니다.
3. Mutation은 func reduce(state: State, mutation: Mutation) -> State 메소드에서 State 형태로 변환됩니다.
예를 들면 이렇게요!
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case let .didTapSegmentControl(index), let .didSlidePage(index):
return .just(.setSelectedTabIndex(index))
case .didTapEditModeButton:
return .just(.toggleIsEditMode)
case .didTapStyleModeButton:
return .just(.toggleStyleMode)
case .didTapDeleteButton:
return .just(.setDeleteConfirmAlert)
}
}
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case let .setSelectedTabIndex(index):
state.selectedTabIndex = index
case .toggleIsEditMode:
state.isEditMode.toggle()
case .toggleStyleMode:
state.styleMode.toggle()
case .setDeleteConfirmAlert:
state.shouldShowDeleteConfirmAlert = ()
}
return state
}
transform의 역할에 의문을 가지실 수도 있을 것 같아요.
간단히 말하면 Action이 아닌 외부 변화로 Action, Mutation, State 등을 트리거 하기 위한 도구입니다.
func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
let shouldToggleIsEditMode = self.shouldToggleIsEditMode
.map { Mutation.toggleIsEditMode }
return .merge(
mutation,
shouldToggleIsEditMode
)
}
예를 들어, 저는 부모 ViewController에서 자식 ViewController로 EditMode를 토글해라! 라는 신호를 보내고 있는데요. 그 신호를 받아서 transform을 통해 toggleIsEditMode라는 Mutation으로 바꿔주고 있습니다.
정말 간단하죠? 이것만 알고 있으면 ReactorKit을 사용하는데에는 큰 문제가 없을거에요!
정말 이렇게 함수 구현만 하면 자동으로 Action에서 Mutation으로, 그리고 State까지 연결해주는거야?
맞아요! 연결해줍니다ㅎㅎ
어떤 방식으로 연결해 주는지, 한단계 한단계씩 살펴볼게요!
1. Action과 State
1 - 1 ) bind method
View protocol 에 선언되어 있는 reactor가 set될 때, 자동으로 bind(reactor: Reactor)가 실행됩니다.
즉, viewController: View 외부에서 reactor를 주입해줄 때, bind 메소드는 자동으로 실행되게 됩니다.
public var reactor: Reactor? {
get { return MapTables.reactor.value(forKey: self) as? Reactor }
set {
MapTables.reactor.setValue(newValue, forKey: self)
self.disposeBag = DisposeBag()
if let reactor = newValue {
self.bind(reactor: reactor)
}
}
}
그렇기 때문에 bind에는 직접 실행하지 말라는 warning이 붙어있죠!
/// - warning: It's not recommended to call this method directly.
func bind(reactor: Reactor)
1 - 2 ) Action과 State
bind 내부에서 Action이 trigger 되면, reactor를 거쳐서 Observable타입의 State로 이벤트가 나오게 됩니다.
MVVM의 input / output과 비슷한 구조라고 생각하시면 될 것 같습니다.
// 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)
이 코드 같은 경우에는, increaseButton을 누르면, increase라는 이벤트가 들어가고,
만들어진 어떤 stream을 지나 count라는 state가 변경되고, 그 값이 방출되어 countLabel의 text를 변경하는거죠!
Action과 State는 reactor protocol안에 선언 되어있습니다.
코드를 한번 보면,
private var _action: ActionSubject<Action> {
if self.isStubEnabled {
return self.stub.action
} else {
return MapTables.action.forceCastedValue(forKey: self, default: .init())
}
}
public var action: ActionSubject<Action> {
// Creates a state stream automatically
_ = self._state // <- 이 부분!!
return self._action
}
State와 Action이 만들어질 때 혹은 변경될 때, state가 불리게 됩니다.
_ = self._state 부분을 보면, state stream을 create하기 위해서 _state의 연산을 실행해주고 있죠.
State는 연산될 때마다, self.createStateStream()을 실행하게 됩니다.
(forceCastedValue가 정확히 뭔지 아직 이해가 잘 안되지만…)
private var _state: Observable<State> {
if self.isStubEnabled {
return self.stub.state.asObservable()
} else {
return MapTables.state.forceCastedValue(forKey: self, default: self.createStateStream())
}
}
public var state: Observable<State> {
// It seems that Swift has a bug in associated object when subclassing a generic class. This is
// a temporary solution to bypass the bug. See #30 for details.
return self._state
}
즉, State나 Aaction이 선언되거나 변경되면, state의 stream(createStateStream) 이 작동하는 것이죠.
state stream(createStateStream) 이 뭔지 한 번 알아봅시다.
2. createStateStream
createStateStream코드입니다. 결국에는 Observable 타입의 State를 반환하고 있죠.
즉, State 타입이나 Action 타입이 선언되거나 변경되면, 저 stream 내에서 무슨 무슨 일이 일어나서 Observable 타입의 State을 만들어 반환하게 되는 것이죠.
이 과정을 총 7단계로 구분을 했는데요.
public func createStateStream() -> Observable<State> {
// (1)
let action = self._action.observe(on: self.scheduler)
// (2)
let transformedAction = self.transform(action: action)
// (3)
let mutation = transformedAction
.flatMap { [weak self] action -> Observable<Mutation> in
guard let `self` = self else { return .empty() }
return self.mutate(action: action).catch { _ in .empty() }
}
// (4)
let transformedMutation = self.transform(mutation: mutation)
// (5)
let state = transformedMutation
.scan(self.initialState) { [weak self] state, mutation -> State in
guard let `self` = self else { return state }
return self.reduce(state: state, mutation: mutation)
}
.catch { _ in .empty() }
.startWith(self.initialState)
// (6)
let transformedState = self.transform(state: state)
.do(onNext: { [weak self] state in
self?.currentState = state
})
.replay(1)
// (7)
transformedState.connect().disposed(by: self.disposeBag)
return transformedState
}
한 단계씩 알아 봅시다.
2 - 1 ) Observable<Action> 정의
let action = self._action.observe(on: self.scheduler)
액션이 만들어지는 부분은 다음과 같은데,
private var _action: ActionSubject<Action> {
if self.isStubEnabled {
return self.stub.action
} else {
return MapTables.action.forceCastedValue(forKey: self, default: .init())
}
}
isStubEnabled 하면, stub.action 을 리턴하고 있고, (TODO: Stub가 뭐지? 일단 나중에…)
그게 아니면, MapTables의 forceCast된 action값을 리턴합니다.
(forceCast 내부를 보면, generics type 함수로 해당 타입으로 강제 캐스팅을 하고 있습니다. 그래서 이름이 forceCast인가보다….)
return self.value(forKey: key, default: `default`() as! Value) as! T
어쨌든, action은 ActionSubject<Action> 타입이고,
observe(on:) 을 통해 action 이 특정 스케줄러에서 observe 되면서 Observable<Action> 타입으로 변환된다 정도로 일단…
2 - 2 ). Observable<Action> transform
우리가 필요한 경우 직접 구현한 transform(action: )을 통해 transform을 해줍니다.
let transformedAction = self.transform(action: action)
예를 들어, 이렇게 외부의 액션을 내부에서 정의한 액션으로 동작하도록 하는 역할을 수행
func transform(action: Observable<Action>) -> Observable<Action> {
let eventAction = service.event.flatMap { event -> Observable<Action> in
switch event {
case .updateUserName:
return .just(.increase)
}
}
return Observable.merge(action, eventAction)
}
이 코드의 경우에는 service의 .updateUserName라는 이벤트가 emit 될 때 마다, 기존 action에서 정의한 .increase로 변경되어 emit
하도록 합니다.
2- 3 ) Mutate (Observable<Action> → Observable<Mutation>)
이제 우리가 직접 구현한 mutate를 통해 Observable<Self.Action>을 Observable<Self.Mutation> 으로 mutate
여기서 Error 발생 시, 다음으로 넘어가지 않게 됩니다.
let mutation = transformedAction
.flatMap { [weak self] action -> Observable<Mutation> in
guard let `self` = self else { return .empty() }
return self.mutate(action: action).catch { _ in .empty()
}
}
즉, action이 들어오면, 어떤 처리를 해줘서 Observable로 방출하는 단계라고 할 수 있는데
여기서 concat, merge, withLatestFrom, combindLatest, zip 등 operator를 활용하여 여러가지 비동기 처리에 최적화 되어있다고 프로토콜 문서에 적혀있습니다.
여기서 만들어지는 Observable을 이용하여 결국 reduce에서 state를 변경하는 것이죠!
2 - 4 ) Observale<Mutation> transform
다음은 우리가 필요한 경우 직접 구현한 transform(mutation: )을 통해 transform을 해줍니다.
let transformedMutation = self.transform(mutation: mutation)
Action Transform과 비슷하게, 외부에서 일어나는 event를 현재 mutation 단위로 전환하기 위해 사용됩니다.
func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
let eventMutation = service.event.flatMap { event -> Observable<Mutation> in
switch event {
case .updateUserName(let name):
return .just(.updateUserName(name))
}
}
return Observable.merge(mutation, eventMutation)
}
이 코드의 경우에는, service의 event로 .updateUserName이 emit될 때마다, 현재 mutation의 .updateUserName으로 변경되어 emit 되도록 하는 역할을 하고 있습니다.
2 - 5 ) reduce를 통해 state 변경
Observable<Mutation>을 Observable<State>로 변환해주는 작업은 reduce에서 일어납니다.
여기서는 scan을 이용해서, 이전에 마지막으로 방출되었던 state 값과, 이제 막 들어오는 mutation 값을 이용해서 reduce를 해주고 있습니다.
즉, 새로운 Observable<State> 를 만들어주고 있죠.
Observable<State>와 scan의 초기값은 initialState입니다.
let state = transformedMutation
.scan(self.initialState) { [weak self] state, mutation -> State in
guard let `self` = self else { return state }
return self.reduce(state: state, mutation: mutation)
}
.catch { _ in .empty() }
.startWith(self.initialState)
2 - 6 ) Observale<State> transform
2-2, 2-4 와 마찬가지로, 외부의 event을 정의된 State으로 emit 하게 해주는 역할을 합니다.
let transformedState = self.transform(state: state)
.do(onNext: { [weak self] state in
self?.currentState = state
})
.replay(1)
이제 connect 되면, 최신의 아이템을 보내주기 위해 replay(1)을 합니다.
2 - 7 ) 방출 시작, disposeBag으로 메모리 관리
만들어진 Observable<State>가 모든 구독자들에게 데이터 방출을 시작하도록 replay + connect
그리고 이를 disposeBag에 넣어 메모리 관리가 되도록 하였습니다.
Reactorkit이 어떤 방식으로 작동하는지 살펴봤습니다!!
내부를 알고 쓰면 더 많은 것들이 보이고, 무엇보다 어떤 고민을 통해 이 패러다임을 만들었는지 고민해볼 수 있는 좋은 기회이기 때문에, 꼭 뜯어보고 사용해보시기를 추천드립니다!
'Swift 아키텍처' 카테고리의 다른 글
[RIBs] RIBs 분석 (0) | 2024.01.08 |
---|---|
[Reactorkit] (1) Reactorkit 도입기와 여전히 남은 고민들 (0) | 2023.08.06 |
[MVC] iOS의 MVC 아키텍처에 대해서 (0) | 2022.09.06 |
[Redux] Redux를 이용한 상태관리 후기 (with SwiftUI) (2) | 2022.05.15 |
[Redux] SwiftUI에서 Redux 아키텍처 적용해보기 - 첫인상 (0) | 2022.04.18 |