본문 바로가기
개발/IOS Swift

[ReactorKit#1] Swift ReactorKit github readme 분석, 읽어보기 / MVVM

by doyou1 2021. 10. 18.
반응형

개발을 할 때마다 느낀거지만, 개발을 시작할 때 적절한 "폼", "틀"을 가지고 시작하는게 중요하다.

개발 속도를 높이고 덜 피로한 효율적인 개발을 가능하게 한다.

그래서 다양한 툴, 기법, 프레임 워크, 디자인 패턴이 있는 것이다.

 

오늘은 Swift에서 MVVM패턴을 적용한 ReactorKit 템플릿을 공부해보려한다.

디자인패턴에는 효율적인 개발을 위한 MVC, MMVM 등 다양한 패턴들이 있다.

 

다른 언어들도 마찬가지지만, Swift 역시 많은 개발자들이 상황에 따라 적절한 디자인패턴을 활용해 개발을 한다. 그 와중에 같은 패턴이더라도 다양한 모양의 결과물이 나올 수 있는데, 이러한 난개발 문제는 보통 정형화된 한두개의 템플릿이 기준이 되면서 점점 틀이 잡힌다. Swift의 경우에는 이 "ReactorKit"이 그 정형화된 템플릿 중 하나로 꼽힌다. 이 "ReactorKit"을 살펴보며, Swift 문법, MVVM 디자인패턴, Rx, Flux, RxSwift에 대한 이해를 얻어보자.

 

해당 포스트는 ReactorKit Github의 README.md를 기준으로 작성하겠다.

https://github.com/ReactorKit/ReactorKit

 

GitHub - ReactorKit/ReactorKit: A library for reactive and unidirectional Swift applications

A library for reactive and unidirectional Swift applications - GitHub - ReactorKit/ReactorKit: A library for reactive and unidirectional Swift applications

github.com

 

README.md에서 공부에 필요한 목차는 다음과 같다.

 

목차
1. 기본 개념

1.1 설계 목표

1.2 View

1.2.1 Storyboard 지원

1.3 Reactor

1.3.1 mutate()

1.3.2 reduce()

1.3.3 transform()

2. Advanced

2.1 Global States

2.2 View Communication

2.3 Testing

2.3.1 Testing 대상

2.3.2 View Testing

2.3.3 Reactor Testing

2.4 Scheduling

2.5 Pulse

3. Example

 


1. 기본 개념

ReactorKitFluxReactive Programming(반응형 프로그래밍)의 조합입니다. User Action들과 View의 상태는 observable(관찰가능한) stream들을 통해 각각의 layer에 전달됩니다. 그 Stream들은 unidirection al(단방향)입니다. View"action"만 emit(전달)할 수 있고, Reactor는 state(상태)만 전달할 수 있습니다.

 

기본 데이터 흐름

- Flux란?

Flux는 애플리케이션의 데이터 흐름을 관리하는 패턴 중 하나이다. 중요한 특징은 데이터의 흐름이 "단방향"이라는 것이다.

 

* 관련 참조- https://haruair.github.io/flux/docs/overview.html

 

Flux | Flux

Application architecture for building user interfaces

facebook.github.io

 

- Reactive Programming이란 ?

Reactive Programming은 "programming with asynchronous data streams(비동기적 데이터 흐름을 처리하는 프로그래밍)"을 의미한다.

 

* 관련 참조

- https://gist.github.com/staltz/868e7e9bc2a7b8c1f754

 

The introduction to Reactive Programming you've been missing

The introduction to Reactive Programming you've been missing - introrx.md

gist.github.com

- https://m.blog.naver.com/jdub7138/220983291803

 

Reactive Programming과 Rx

비동기 데이터 흐름Reactive Programming을 한줄로 설명하자면 다음과 같습니다. (출처: https://gist.gi...

blog.naver.com

 


 

1.1 설계 목표

- Testability(테스트 가능성) : ReactorKit의 첫번째 목적은 비즈니스 로직을 View로부터 분리하는 것이다. 이는 코드를 테스트 가능하게 만들 수 있다. reactor는 View에 대해서 어떤 dependency(의존성)도 갖지 않는다. 그저 Reactor와 View binding을 테스트하면 된다. 자세한 내용은 Testing 섹션에

- Start Small(소규모 시작) : ReactorKit은 전체 애플리케이션이 단일한 아키텍처를 따를 필요가 없습니다. 당신은 기존 프로젝트에서 ReactorKit을 사용하기 위해 모든 걸 다시 작성할 필요가 없습니다.

- Less Typing(적은 코드) : ReactorKit은 심플함을 위해 복잡한 코드를 피하는데 주력합니다. ReactorKit은 다른 아키텍처에 비해 더 적은 코드를 필요로 합니다. 심플하게 시작해 키워나가세요. 

1.2 View

View는 Data를 보여줍니다. View Controller와 Cell은 View로 처리됩니다. View는 User Input을 Action Stream에 바인딩하고, View의 state(상태)를 각각의 UI component에 바인딩합니다. View Layer에는 비즈니스 로직이 없습니다. View는 Action Stream과 State Stream을 매핑하는 방법을 정의합니다.

 

View를 정의하려면, 기존 클래스가 "View"라는 프로토콜을 따르도록 해야합니다. 그러면 당신의 클래스는 자동적으로 reactor라는 속성을 가지게 될 것입니다. 이 속성은 일반적으로 View의 외부에 설정됩니다.

 

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

profileViewController.reactor = UserViewReactor()	// inject reactor, 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)
}

 

1.2.1 Storyboard 지원

Storyboard를 이용해 View Controller를 초기화한다면, StoryboardView 프로토콜을 사용하세요. 모든 것이 같지만, 한가지 차이는 StoryboardView가 View가 로드된 후 바인딩을 수행한다는 것입니다.

 

let viewController = MyViewController()
viewController.reactor = MyViewReactor() // will not executes `bind(reactor: )` immediately
										// bind(reactor: )를 즉시 실행하지 않을 것임
                                        
class MyViewController: UIViewController, StoryboardView {
	func bind(reactor: MyViewReactor) {
    	// this is called after the view is loaded (viewDidLoad)
        // 이건 View가 로드된 이후에 호출된다 (viewDidLoad)
    }
}

 

1.3 Reactor

Reactor는 View의 state(상태)를 관리하는 UI 독립 Layer입니다. Reactor의 가장 중요한 역할은 Control flow(제어 흐름)을 View에서 분리하는 것입니다. 모든 View는 그에 상응하는 Reactor를 가지고 있고, 모든 로직을 그 Reactor에게 위임합니다. Reactor는 View에 대한 의존이 없기때문에 쉽게 Test할 수 있습니다.

 

Reactor 프로토콜에 따라 Reactor를 정의하세요. 이 프로토콜은 3가지 type을 정의해야 합니다. 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()
}

 

Action은 User Interaction(상호작용)을 나타내고, State는 View State를 나타냅니다. Mutation은 Action과 State를 연결하는 다리입니다. Reactor는 Action Stream을 State Stream으로 변환합니다. mutate()와 reduce()인 두 단계로..

 

 

1.3.1 mutate()

mutate()는 Action을 받고, Observable<Mutation>을 생성합니다.

 

func mutate(action: Action) -> Observable<Mutation>

 

비동기 작업, API 호출 등과 같은 모든 부수적인 작용은 이 method로 수행됩니다.

 

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

 

1.3.2 reduce()

reduce()는 이전의 State와 Mutation로부터 새로운 State(상태)를 생성합니다.

 

func reduce(state: State, mutaiton: Mutation) -> State

 

이 메소드는 순수 함수(pure function)입니다. 새로운 State를 동기적으로 return하기만 하면 됩니다. 이 fucntion에서는 다른 side effect를 수행하지 마세요.

 

func reducce(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
    }
}

 

1.3.3 transform()

transform()은 각각의 stream을 변환시킵니다. 3개의 transform() function들은 아래 코드에

 

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

 

다른 관찰 가능한 Stream과 결합 혹은 변환시키기 위해 이 메소드들을 구현하세요. 예를 들어, transform(mutation: )은 전역 이벤트 Stream을 Mutate Stream에 결합하기 위한 최적의 장소입니다. 자세한 내용은 Global State 섹션 참조!

 

2. Advanced

2.1 Global States

Redux와 달리 ReactorKit은 전역 app state를 정의하지 않습니다. 이는 global state를 관리함에 있어 무엇이든 사용할 수 있음을 의미합니다. BehaviorSubject, PublishSubject 혹은 Reactor를 사용할 수 있습니다. ReactorKit은 Global State를 강제하지 않기에 당신의 애플리케이션의 특정 기능에 ReactorKit을 사용할 수 있습니다. 

 

Action -> Mutation -> State 흐름에 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가 Action을 Reactor에게 보내고, currentUser가 변경될 때마다 그 mutation은 전달될 것입니다.

 

- Redux란?

Javascript App을 위한 예측가능한(predictable) state container라고 정의된다. 보통 리액트가 가지는 global state 관리의 어려움을 해결하는 데 활용되는 Library이다. state(상태) 관리에 용이하다.

 

* 관련 참조

- https://ko.redux.js.org/introduction/getting-started

 

Redux 시작하기 | Redux

소개 > 시작하기: Redux를 배우고 사용하기 위한 자료

ko.redux.js.org

 

2.2 View Communication

복수의 View 끼리의 커뮤니케이션을 위한 콜백 클로져와 위임 패턴에 익숙해져야한다. ReactorKit은 Reactive Extensions 사용을 권장합니다. ControlEvent의 가장 일반적인 예제는 UIButton.rx.tap. 중요한 컨셉은 Custom View를 UIButton이나 UILabel로 취급하는 것이다.

 

 

메시지를 보여주는 ChatViewController를 가정해보자. ChatViewController는 MessageInputView를 소유한다. User가 MessageInputView의 send button을 눌렀을 때, 그 텍스트는 ChatViewController로 보내질 것이고, ChatViewController는 Reactor의 Action에 바인딩할 것입니다. MessageInputView의 Reactive extension의 예제:

 

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

 

이 extension을 ChatViewController에서 사용할 수 있다. 예를 들어

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

 

2.3 Testing

ReactorKit은 testing을 위한 기능이 내장되어 있다. 당신은 다음 지시로 View와 Reactor 모두를 쉽게 test할 수 있을 것이다.

 

2.3.1 Testing 대상

먼저, 무엇을 test할지 결정해야 한다. 여기 test할 두 가지 "View"와 "Reactor"가 있다.

 

  • View
    • Action : 주어진 User Interaction(유저 상호작용)으로 적절한 Action이 Reactor로 전송되었는가?
    • State : 다음 State에서 View 속성이 제대로 설정되었는가?
  • Reactor
    • State : Action에 따라 State가 적절히 바뀌었는가?

 

2.3.2 View Testing

View는 stub Reactor로 test할 수 있다. Reactor는 Action를 기록하고, 상태를 강제로 변경할 수 있는 속성 stub을 가지고 있다. 만약 Reactor의 stub이 활성화되면, mutate()와 reduce() 모두 실행되지 않는다. stub은 다음과 같은 속성이 있다.

 

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

 

여기 test cases 예제가 있다.

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

 

2.3.3 Reactor Testing

Reactor은 독립적으로 test될 수 있다.

 

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로 설정하고, Refreshing 후에는 false로 설정한다. 이 경우 state.isLoading과 currentState를 test하기 어렵기 때문에, RxTest나 RxExpect를 사용할 필요가 있다. 다음은 RxSwift를 사용한 test 예제이다.

 

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

 

2.4 Scheduling

Scheduler 속성을 정의하여 state stream을 reducing, observing하는 데 사용할 scheduler를 명시한다. 이 queue는 serial queue(직렬 대기열)이어야 한다. default scheduler는 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
    }
}

 

2.5 Pulse

Pulse는 mutate할 때에만 diff 되는데(다르게 되는데)?, 코드로 설명하자면 결과는 다음과 같다.

 

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

let oldMessagePulse: Pulse<String?> = message
message = "Hello tokijh"

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

 

당신이 Event를 받고 싶을 때 동일한 value라도 new value가 할당된 경우에만 사용해라.??

다음 alertMessage 변수 혹은 PulseTests.swift

 

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

 

3. Example

- Counter

- Github Search

- RxTodo

- Cleverbot

- Drrrible

- passcode

- Filckr Search

- ReactorKitExample

- reactorkit-keyboard-example

- SWHub

 

 

사실 대강 읽어보고 예제나 몇개 따라해보려 했는데, 처음부터 하나하나 잘 살펴보고 싶어서 이렇게 작성해봤다. 사실은 그냥 보고 해석한 것에 불과하지.. 앞으로 예제들 하나하나 따라해보면서 ReactorKit, Swift, MVVM패턴에 대해 익숙해져 보려한다. 화이팅

반응형

댓글