๋ฐฐ์ธ๊ฒ ์ ๋ง ๋ง๋ค ..!
ํ๋ ํ๋ ์ํคํ ์ณ๋ค์ ์์๊ฐ๋ฉด์ MVx ์ธ์ ์ฑ ์ํคํ ์ณ ๊ฐ๋ ์ด ์ ์ ๊ตฌ์ฒดํ๋๋๊ฑฐ ๊ฐ๋ค. ์ด๋ฒ์๋ reactorKit ๋ํ๋จผํธ๋ฅผ ์ฝ์ด๋ณด๊ณ ๋ค๋ฅธ ์ํคํ ์ณ์ ์ด๋ป๊ฒ ๋ค๋ฅธ์ง ์์๋ณด๋ ค๊ตฌ ํ๋ค !!
์ฐธ๊ณ : https://github.com/ReactorKit/ReactorKit
ReactorKit์ด๋ Flux์ Reactive Programming์ ์กฐํฉ์ ๋๋ค.
์ฌ์ฉ์์ action๊ณผ view์ state๋ observable ์คํธ๋ฆผ์ ํตํด ๊ฐ layer์๊ฒ ์ ๋ฌ๋ฉ๋๋ค.
์ด๋ฌํ ํ๋ฆ์ ๋จ๋ฐฉํฅ์ ์ ๋๋ค. ๋ทฐ๋ ์ค์ง action๋ง ๋ฐฉ์ถ ํ ์ ์๊ณ , reactor๋ state๋ง ๋ฐฉ์ถ ํ ์ ์์ต๋๋ค.
๋์์ธ ๋ชฉํ :
Testability : ReactorKit์ ์ฒซ๋ฒ์งธ ๋ชฉํ๋ ๋ทฐ๋ก๋ถํฐ ๋น์ง๋์ค ๋ก์ง์ ๋ถ๋ฆฌํ๋ ๊ฒ์ ๋๋ค. ์ด๊ฒ์ ์ฝ๋๋ฅผ ํ ์คํธ ๊ฐ๋ฅํ๊ฒ ๋ง๋ค์ด ์ค๋๋ค. reactor๋ ๋ทฐ์ ์์กด์ ์ด์ง ์์ต๋๋ค. ๋จ์ง reactor์ view binding์ ๋ํด ํ ์คํธ ํ๋ฉด ๋ฉ๋๋ค.
Start Small : ReactorKit์ ์ฑ ์ ์ฒด๊ฐ ๋จ์ผ ์ํคํ ์ณ๋ฅผ ์ฌ์ฉํ์ง ์์๋ ๋ฉ๋๋ค. reactorKit์ ํ๋ ๋๋ ๊ทธ ์ด์์ ๋ทฐ์ ๋ํด ๋ถ๋ถ์ ์ผ๋ก ์ ์ฉํ ์ ์์ต๋๋ค. ๊ธฐ์กด ํ๋ก์ ํธ์ ReactorKit์ ์ ์ฉํ๊ธฐ ์ํด ๋ชจ๋ ์ฝ๋๋ฅผ ์ฌ ์์ฑ ํ์ง ์์๋ ๋ฉ๋๋ค. (์ข๊ตฐ ..)
Less Typing : ReactorKit์ ๊ฐ๋จํ ๊ฒ์ ์ํด ๋ณต์กํ ์ฝ๋๋ฅผ ํผํ๋ ๋ฐ ์ค์ ์ ๋ก๋๋ค. ๋ค๋ฅธ ์ํคํ ์ณ์ ๋น๊ตํ๋ฉด ๋ ์ ์ ์ฝ๋๋ฅผ ํ์๋ก ํฉ๋๋ค.
View
view๋ ๋ฐ์ดํฐ๋ฅผ ๋ํ๋ ๋๋ค. viewController์ Cell์ view๋ก ์ทจ๊ธ๋ฉ๋๋ค. view๋ user input์ action stream์ ๋ฐ์ธ๋ฉํ๊ณ view state๋ฅผ UI ์ปดํฌ๋ํธ์ ๋ฐ์ธ๋ฉํฉ๋๋ค. view layer์๋ ๋น์ง๋์ค ๋ก์ง์ด ์์ต๋๋ค. view๋ ์ค์ง action stream๊ณผ state stream์ ๋งคํํ๋ ๋ฐฉ๋ฒ์ ์ ์ํฉ๋๋ค.
view๋ฅผ ์ ์ํ๊ธฐ ์ํด์ ์กด์ฌํ๋ ๊ธฐ์กด class์ View protocol์ ์ค์์ํต๋๋ค. ๊ทธ๋ฌ๋ฉด ๊ทธ ํด๋์ค๋ ์๋์ผ๋ก reactor๋ผ๋ ํ๋กํผํฐ๋ฅผ ๊ฐ์ง๊ฒ ๋ ๊ฒ์ ๋๋ค. ์ด ํ๋กํผํฐ๋ ์ผ๋ฐ์ ์ผ๋ก view ๋ฐ์์ ์ ์ธ๋ฉ๋๋ค.
class ProfileViewController: UIViewController, View {
var disposeBag = DisposeBag()
}
profileViewController.reactor = UserViewReactor() // inject reactor
reactor ํ๋กํผํฐ๊ฐ ๋ณํ๋ค๋ฉด, bind(reactor:) ํจ์๊ฐ ํธ์ถ๋ฉ๋๋ค. ์ด ๋ฉ์๋๋ฅผ ๊ตฌํํ์ฌ action stream๊ณผ state stream์ ๋ฐ์ธ๋ฉ์ ์ ์ํฉ๋๋ค.
func bind(reactor: ProfileViewReactor) {
// action (View -> Reactor)
refreshButton.rx.tap.map { Reactor.Action.refresh }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
// state (Reactor -> View)
reactor.state.map { $0.isFollowing }
.bind(to: followButton.rx.isSelected)
.disposed(by: self.disposeBag)
}
์คํ ๋ฆฌ๋ณด๋๋ฅผ ์ฌ์ฉํ์ฌ ๋ทฐ ์ปจํธ๋กค๋ฌ๋ฅผ ์ด๊ธฐํํ๋ ๊ฒฝ์ฐ StoryboardView ํ๋กํ ์ฝ์ ์ฌ์ฉํฉ๋๋ค. ๋ชจ๋ ๊ฒ์ด ๋์ผํ์ง๋ง ์ ์ผํ ์ฐจ์ด์ ์ view๊ฐ ๋ก๋๋ ํ(viewDidLoad) StoryboardView๊ฐ ๋ฐ์ธ๋ฉ์ ์ํํ๋ค๋ ๊ฒ์ ๋๋ค.
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
reactor๋ ๋ทฐ์ ์ํ๋ฅผ ๊ด๋ฆฌํ๋ฉฐ ๋ทฐ์ ์์กด์ ์ด์ง ์์ ๊ณ์ธต์ ๋๋ค. reactor์ ๊ฐ์ฅ ์ค์ํ ์ญํ ์ control flow๋ฅผ view์์ ๋ถ๋ฆฌํ๋ ๊ฒ์ ๋๋ค. ๋ชจ๋ view๋ ์์ํ๋ reactor๋ฅผ ๊ฐ์ง๊ณ ์๊ณ ๋ชจ๋ ๋ก์ง์ reactor์๊ฒ ์์ํฉ๋๋ค. reactor๋ view์ ์์กด์ฑ์ด ์๊ธฐ ๋๋ฌธ์ ํ ์คํธ ํ๊ธฐ ์ฉ์ดํฉ๋๋ค.
reactor๋ฅผ ์ ์ํ๊ธฐ ์ํด Reactor ํ๋กํ ์ฝ์ ์ค์ํฉ๋๋ค. ์ด ํ๋กํ ์ฝ์ Action, Mutation, State 3๊ฐ์ง ํ์ ์ ์ ์ํด์ผ ํฉ๋๋ค. ๋ํ 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()
}
Action์ ์ฌ์ฉ์ ์ํธ์์ฉ์ ๋ํ๋ด๋ฉฐ State๋ view์ ์ํ๋ฅผ ๋ํ๋ ๋๋ค. Mutation์ Action๊ณผ State์ ๋งค๊ฐ์ฒด์ ๋๋ค. reactor๋ action stream์ state stream์ผ๋ก 2๋จ๊ณ์ ๊ฑธ์ณ ๋ฐ๊ฟ๋๋ค. mutate(), reduce()
mutate()๋ action์ ๋ฐ์ Observable<Mutation>์ ์์ฑํฉ๋๋ค.
func mutate(action: Action) -> Observable<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์ mutate๋ฅผ ๋ฐ์ ์๋ก์ด state๋ฅผ ์์ฑํฉ๋๋ค.
func reduce(state: State, mutation: Mutation) -> State
์ด ๋ฉ์๋๋ ์์ํจ์ ์ ๋๋ค. ๋จ์ง ์๋ก์ด state๋ฅผ ์์ฑํด ๋๊ธฐ์ ์ผ๋ก ๋ฆฌํดํฉ๋๋ค. ์ด ํจ์์์ ์ด๋ ํ ๋ถ์์ฉ๋ ์ํํ์ง ๋ง์ญ์์ค.
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()์ ๊ฐ๊ฐ์ stream์ ๋ณํํฉ๋๋ค. 3๊ฐ์ง transform() ํจ์๊ฐ ์์ต๋๋ค.
func transform(action: Observable<Action>) -> Observable<Action>
func transform(mutation: Observable<Mutation>) -> Observable<Mutation>
func transform(state: Observable<State>) -> Observable<State>
์ด ๋ฉ์๋๋ฅผ ๊ตฌํํ์ฌ ๋ค๋ฅธ Observable stream์ ๋ณํํ๊ณ ๊ฒฐํฉํฉ๋๋ค. ์๋ฅผ ๋ค์ด, transform(mutation:)์ global event stream์ mutation stream์ ๊ฒฐํฉํ๋ ๋ฐ ๊ฐ์ฅ ์ข์ต๋๋ค. ์์ธํ ๋ด์ฉ์ Global State๋ฅผ ์ฐธ์กฐํ์ญ์์ค.
Global States
redux์ ๋ฌ๋ฆฌ, ReactorKit๋ global app state๋ฅผ ์ ์ํ์ง ์์ต๋๋ค. ์ด๊ฒ์ global state๋ฅผ ๊ด๋ฆฌํ๊ธฐ ์ํด ๋ฌด์์ด๋ ์ฌ์ฉํ ์ ์์์ ์๋ฏธํฉ๋๋ค. BehaviorSubject, PublishSubject ๋๋ Reactor๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค. ReactorKit์ global state๋ฅผ ๊ฐ์ ํ์ง ์์ผ๋ฏ๋ก ์ ํ๋ฆฌ์ผ์ด์ ์ ํน์ ๊ธฐ๋ฅ์์ ReactorKit์ ์ฌ์ฉํ ์ ์์ต๋๋ค.
์ด๊ฒ์ Action → Mutation → State flow์์ global state๊ฐ ์์์ ์๋ฏธํฉ๋๋ค. global state๋ฅผ mutation์ผ๋ก ๋ณํํ๋ ค๋ฉด transform(mutation:)์ ์ฌ์ฉํด์ผ ํฉ๋๋ค. ํ์ฌ ์ธ์ฆ๋ ์ฌ์ฉ์๋ฅผ ์ ์ฅํ๋ global BehaviorSubject๊ฐ ์๋ค๊ณ ๊ฐ์ ํด ๋ณด๊ฒ ์ต๋๋ค. currentUser๊ฐ ๋ณ๊ฒฝ๋ ๋ Mutation.setUser(User?)๋ฅผ ๋ด๋ณด๋ด๋ ค๋ฉด ๋ค์๊ณผ ๊ฐ์ด ํ๋ฉด ๋ฉ๋๋ค.
var currentUser: BehaviorSubject<User> // global state
func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
return Observable.merge(mutation, currentUser.map(Mutation.setUser))
}
๊ทธ๋ฌ๋ฉด view๊ฐ reactor์ action์ ๋ณด๋ด๊ณ currentUser๊ฐ ๋ณ๊ฒฝ๋ ๋๋ง๋ค mutation์ด ๋ฐ์ํฉ๋๋ค.
View Communication
์ฌ๋ฌ view์ communication ํ๊ธฐ ์ํด์ callback closures ๋๋ delegate pattern์ ์ต์ํด์ผ ํฉ๋๋ค. ReactorKit๋ ์ด๋ฅผ ์ํด reactive extensions์ ์ฌ์ฉ ํ๊ธธ ๊ถ์ฅํฉ๋๋ค. ControlEvent์ ๊ฐ์ฅ ์ผ๋ฐ์ ์ธ ์๋ UIButton.rx.tap์ ๋๋ค. ํต์ฌ ๊ฐ๋ ์ custom view๋ฅผ UIButton ๋๋ UILabel๋ก ์ฒ๋ฆฌํ๋ ๊ฒ์ ๋๋ค.
๋ฉ์์ง๋ฅผ ํ์ํ๋ ChatViewController๊ฐ ์๋ค๊ณ ๊ฐ์ ํด ๋ณด๊ฒ ์ต๋๋ค. ChatViewController๋ MessageInputView๋ฅผ ์์ ํฉ๋๋ค. ์ฌ์ฉ์๊ฐ MessageInputView์์ send ๋ฒํผ์ ํญํ๋ฉด ํ ์คํธ๊ฐ ChatViewController๋ก ์ ์ก๋๊ณ ChatViewController๋ reactor์ action์ผ๋ก ๋ฐ์ธ๋ฉ ํ ๊ฒ์ ๋๋ค. ์๋๋ MessageInputView์ rx extension์ ๋๋ค.
extension Reactive where Base: MessageInputView {
var sendButtonTap: ControlEvent<String> {
let source = base.sendButton.rx.tap.withLatestFrom(...)
return ControlEvent(events: source)
}
}
ChatViewController์์ ์์ extension์ ์ฌ์ฉ ํ ์ ์์ต๋๋ค.
messageInputView.rx.sendButtonTap
.map(Reactor.Action.send)
.bind(to: reactor.action)
Testing
ReactorKit์๋ ํ ์คํธ๋ฅผ ์ํ ๋ด์ฅ ๊ธฐ๋ฅ์ด ์์ต๋๋ค. ๋ค์ ์ง์นจ์ ๋ฐ๋ผ ๋ณด๊ธฐ์ ๋ฆฌ์กํฐ๋ฅผ ๋ชจ๋ ์ฝ๊ฒ ํ ์คํธํ ์ ์์ต๋๋ค.
์ฒซ๋ฒ์งธ๋ก ๋ฌด์์ ํ ์คํธํ ์ง ๊ฒฐ์ ํด์ผ ํฉ๋๋ค. ํ ์คํธํ ๋ ๊ฐ์ง๊ฐ ์์ต๋๋ค: view, reactor
view :
Action - ์ฃผ์ด์ง ์ ์ ์ธํ์ ๋ฐ๋ฅธ reactor์๊ฒ ์ ์ ํ action์ ๋ณด๋๋๊ฐ ?
State - following state์์ view ํ๋กํผํฐ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ๋์๋๊ฐ ?
Reactor :
State : action์ ๋ฐ๋ฅธ ์ ์ ํ state๋ก ๋ณ๊ฒฝ ๋์๋๊ฐ ?
View testing
view๋ stub reactor๋ก ํ ์คํธ ๋ ์ ์์ต๋๋ค. reactor๋ stub ํ๋กํผํฐ๋ฅผ ๊ฐ์ง๋๋ฐ action์ ๊ธฐ๋กํ๊ณ state๋ฅผ ๊ฐ์ ๋ก ๋ณ๊ฒฝํ ์ ์์ต๋๋ค. reactor์ stub์ด ํ์ฑํ ๋๋ฉด, mutate()์ reduce()๊ฐ ๋ชจ๋ ์คํ๋์ง ์์ต๋๋ค. stub์๋ ๋ค์๊ณผ ๊ฐ์ ์์ฑ์ด ์์ต๋๋ค.
var state: StateRelay<Reactor.State> { get }
var action: ActionSubject<Reactor.Action> { get }
var actions: [Reactor.Action] { get } // recorded actions
์๋๋ ํ ์คํธ ์ผ์ด์ค์ ๋ํ ์์ ์ ๋๋ค.
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
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
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)
}
๋๋๋ก state๋ ๋จ์ผ action์ ๋ํด ๋ ๋ฒ ์ด์ ๋ณ๊ฒฝ๋ฉ๋๋ค. ์๋ฅผ ๋ค์ด .refresh๋ ์ฒ์์ state.isLoading์ true๋ก ๋ณ๊ฒฝํ๊ณ ๋ค์ refreshing ํ false๋ก ๋ณ๊ฒฝํฉ๋๋ค. ์ด๋ฐ ๊ฒฝ์ฐ currentSate๋ก state.isLoading์ ํ ์คํธ ํ๊ธฐ์ ์ฝ์ง ์์ต๋๋ค. ๋ฐ๋ผ์ RxTest ๋๋ RxExpect๊ฐ ํ์ํ ์ง๋ ๋ชจ๋ฆ ๋๋ค. ์๋๋ RxSwift๋ฅผ ์ฌ์ฉํ ์์์ ๋๋ค.
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์ reduceํ๊ณ observing ํ๋ ๋ฐ ์ฌ์ฉ๋๋ ์ค์ผ์ค๋ฌ๋ฅผ ์ง์ ํ๋ ค๋ฉด ์ค์ผ์ค๋ฌ ํ๋กํผํฐ๋ฅผ ์ ์ํ์ญ์์ค. ์ด ๋๊ธฐ์ด์ ์ง๋ ฌ ๋๊ธฐ์ด์ด์ด์ผ ํฉ๋๋ค. ๊ธฐ๋ณธ ์ค์ผ์ค๋ฌ๋ CurrentThreadScheduler์ ๋๋ค.
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
Pulse๋ mutation์ด ์์ ๋๋ง 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
alertMessage์ฒ๋ผ ๋์ผํ ๊ฐ์ด๋๋ผ๋ ์๋ก์ด ๊ฐ์ด ํ ๋น๋ ๊ฒฝ์ฐ์๋ง ์ด๋ฒคํธ๋ฅผ ์์ ํ๊ณ ์ ํ ๋ ์ฌ์ฉํฉ๋๋ค.
// Reactor
private final class MyReactor: Reactor {
struct State {
@Pulse var alertMessage: String?
}
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case let .alert(message):
return Observable.just(Mutation.setAlertMessage(message))
}
}
func reduce(state: State, mutation: Mutation) -> State {
var newState = state
switch mutation {
case let .setAlertMessage(alertMessage):
newState.alertMessage = alertMessage
}
return newState
}
}
// View
reactor.pulse(\.$alertMessage)
.compactMap { $0 } // filter nil
.subscribe(onNext: { [weak self] (message: String) in
self?.showAlert(message)
})
.disposed(by: disposeBag)
// Cases
reactor.action.onNext(.alert("Hello")) // showAlert() is called with `Hello`
reactor.action.onNext(.alert("Hello")) // showAlert() is called with `Hello`
reactor.action.onNext(.doSomeAction) // showAlert() is not called
reactor.action.onNext(.alert("Hello")) // showAlert() is called with `Hello`
reactor.action.onNext(.alert("tokijh")) // showAlert() is called with `tokijh`
reactor.action.onNext(.doSomeAction) // showAlert() is not called
'IOS๐ > ์ํคํ ์ณ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[์ํคํ ์ณ] RIBs๋ (0) | 2022.05.06 |
---|---|
[์ํคํ ์ณ] MVP ๋ (0) | 2022.01.06 |
[์ํคํ ์ณ] MVVM (clean-architecture) ์ด๋ (0) | 2021.12.22 |
[์ํคํ ์ณ] Viper ๋ (0) | 2021.11.29 |
[์ํคํ ์ณ] RiBs tutorial1 ๋ฐ๋ผํ๊ธฐ (0) | 2021.11.13 |
๋๊ธ