[Swift] ReactorKit 사용기 - 3

민경준·2022년 6월 27일
1

ReactorKit 사용기

목록 보기
3/3

4. Apply

4-1. Service Provider

우리는 위에서 언급했던 global state를 좀 더 체계적으로 관리할 필요가 있다. service provider는 Reactor 만으로는 부족한 프로세스 단위의 관리를 할 수 있도록 도와주는 역할을 하는 동시에 side effect, API 호출과 같은 비동기 작업들을 맡아서 관리한다.

Reactor들이 사용할 Service Provider를 구현하자. Service Provider는 프로세스 단위의 Service들을 가지고 있어야 하고 모든 Reactor들은 provider라는 이름의 ServiceProviderProtocol속성에 공통적인 Provider를 주입해줘야 한다.

protocol ServiceProviderProtocol: class {
	var appleService: AppleServiceProtocol { get }
}

final class ServiceProvider {
	static let shared = ServiceProvider()
	// Reactor들이 공통적인 스트림을 제공 받아야 하기때문에
	// 하나의 객체로 서비스를 제공받도록 싱글톤 패턴으로 구현했다.
	lazy var appleService: AppleServiceProtocol = AppleService(provider: self)
}

우선 모든 Service들의 기본이 되는 BaseService를 구현하자

class BaseService {
  unowned let provider: ServiceProviderProtocol

  init(provider: ServiceProviderProtocol) {
    self.provider = provider
  }
}

이것을 기반으로 하여 AppleService를 구현하자. Service에는 기본적으로 Reactor에서 Stream을 통해 전달 받을 Observable<Event>속성의 event와 Action을 통해 바인딩 받아 side effect를 처리 할 함수를 구현해줘야 한다.

enum AppleEvent {
	case increasedValue(Int)
}

protocol AppleServiceProtocol {
	// Reactor에서 Stream을 통해 전달 받을 event
	var event: PublishSubject<AppleEvent> { get } 
	func increaseValue(before: Int) -> Observable<Int>
}

final class AppleService: BaseService, AppleServiceProtocol {
	var event = PublishSubject<AppleEvent>()
	
	// Action을 통해 값을 바인딩받아 side effect를 처리할 함수
	func increaseValue(before: Int) -> Observable<Int> {
		return Observable.create { observer in
			self.requestIncreaseValue(before, completion: { newVal in
				self.event.onNext(.increasedValue(newVal))
				observer.onNext(newVal)
			})

			return Disposables.create()
	}

	private func requestIncreaseValue(_ val: Int, completion: @escaping (Int) -> Void) {
		// side effect OR async work like API Calls....
		completion(newValue)
	}
}

Service 까지 구현을 마쳤다면, Reactor에 해당 Service를 주입시켜줘야 한다. 보통 Service는 Reactor를 생성하고 View의 reactor에 주입시킬때 주입시켜준다.

class AppleViewReactor: Reactor {
	// Action, Mutation, State...

	var initialState: State
	var provider: ServiceProviderProtocol
	
	init(provider: ServiceProviderProtocol) {
		self.initialState = State()
		self.provider = provider
	}

	// transform, mutation, reduce....
}

AppleViewController.reactor = AppleViewReactor(provider: ServiceProvider.shared)

이어서 Service의 Stream을 통해 event를 받아 새로 Observable<Mutation>을 생성하거나, 함수의 flatMap을 통하여 Observable<Mutation>을 생성을 해주는것으로 State까지 값을 전달할 수 있다. 둘 중 편한 방법을 사용하면 되고, 그 값을 이용하여 newState를 생성하고 리턴해주면 된다.

func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
	// 방법 1.
	// appleService의 event로 부터 값을 전달받아
	// Observable<Mutation>을 새로 생성한다.
	let appleService = self.provider.appleService.event
				.flatMap { E -> Observable<Mutation> in
					switch E {
					case .increasedValue(let val):
						return .just(.executeIncrease(val))
					}
			}

	return Observable.merge(mutation, appleService)
}

func mutate(action: Action) -> Observable<Mutation> {
  switch action {
	case .increase: 
		// 방법 2.
		// Action을 전달 받아 Service의 메서드로 바인딩한다.
		// Service의 리턴값을 flatMap하여 Observable<Mutation>을 리턴한다.
		return self.provider.appleService.increaseValue(before: 5)
				.flatMap { newVal -> Observable<Mutation> in
					return .just(.executeIncrease(newVal)) // convert to Mutation stream
																								 // or Use .empty()
				}
	}
}

func reduce(state: State, mutation: Mutation) -> State {
	var newState = state // create a copy of the old state
	switch mutation {
	case .executeIncrease(let b):
		newState.currentValue = b // manipulate the mutation, creating a new state
	}

	return newState // return the new state
}

보통 방법 1과 같은 경우는 여러 View 사이에 event를 통해 상태를 전달하고자 할 때 사용하고, 방법 2와 같이 action 에서 값을 받아 flatMap으로 Observable<Mutation>을 생성하는 경우는 하나의 View 에서 Action에 값을 전달하고 State로 값을 바인딩 받을 때 사용한다.


4-2. Revisioned Data

ReactorKit 에는 치명적인 단점이 하나 있다. state 자체가 하나의 stream 이라 발생하는 문제점인데
state 중에 하나의 상태값이 변경되어 stream을 진행하게 되면 bind(reactor:)에 구독해두었던 reactor.state 들이 모두 동작하게 된다.

예를 들어 다음과 같이 State 안에 wasIncreasedwasDecreased값이 있다고 가정해보자.

func bind(reactor: AppleReactor) {
	reactor.state
		.map { $0.wasIncreased }
		.subscribe(onNext: { _ in
			print("was increased")
		})
		.disposed(by: self.disposeBag)

	reactor.state
		.map { $0.wasDecreased }
		.subscribe(onNext: { _ in
			print("was decreased")
		})
		.disposed(by: self.disposeBag)
}

먼저 wasIncreased가 값이 변경되어 true 값이 들어온 후 wasDecreased가 변경되어 false가 들어온다면 코드가 어떻게 진행될까? 결과는 다음과 같다.

// result
was increased
was increased
was decreased

바로 state 자체가 하나의 stream이라 하나만 값이 변경되더라도 구독해두었던 모든 상태들이 다시 한번 들어오게 된다. 또, RxSwift의 Operator 중 하나인 distinctUntilChanged()를 사용하여 문자열과 같은 값들은 중복을 방지한다고 하더라도 Bool 타입의 값처럼 같은 값이 연속으로 들어와야 하는 경우는 핸들링 할 수 없게 된다. 이번에 들어온 true가 이전 이벤트를 통해서 들어온 값인지 이번에 발생한 이벤트를 통해서 들어온 값인지 확인 할 수 없기 때문이다.

이러한 부분을 핸들링하기 위해 전수열(갓갓,,)님이 제안 해주신 방법 중 하나가 Revisioned Data 이다.
(개발자 한분이 전수열님에게 이러한 문제를 제시했고 전수열님이 제안하셨고 완성은 개발자님께서 하셨다.)

Revisioned Data에 대해 간단히 설명하자면 Identifiable 프로토콜과 Equatable 프로토콜을 동시에 사용하는것과 같은 기능을 한다. 구조를 살펴보자.

revisionIdentifiableid 처럼 사용하고 update(_ data:)를 호출 하게되면 revision에 1을 더하여 새로운 RevisionedData를 생성하여 리턴한다. 그리고 Generic 변수인 data에 값을 저장해 두게 된다.

struct RevisionedData<T>: Equatable {
	static func == (lhs: RevisionedData, rhs: RevisionedData) -> Bool {
		return lhs.revision == rhs.revision
	}

	fileprivate let revision: UInt
	let data: T?
}

extension RevisionedData {
	func update(_ data: T?) -> RevisionedData {
		return RevisionedData<T>(revision: self.revision + 1, data: data)
	}
}

이것을 Reactor에 대입시켜보자. 우선, State의 값들을 RevisionedData로 선언을 해준다.
그리고 기존의 state 값에 update(_ data:)를 호출하여 newState를 생성하고 리턴해준다.

// Action, Mutation...
struct State {
	// declare to RevisionedData
	var name = RevisionedData<String>(revision: 0, data: nil)
	var age = RevisionedData<Int>(revision: 0, data: nil)
}

// transform(mutation:), mutation(action:) ...

func reduce(state: State, mutation: Mutation) -> State {
	var newState = state
	switch mutation {
	// manipulate the 'update(_ data:)', creating a new state
	case .changeName(let str):
		newState.name = state.name.update(str)  
	case .changeAge(let val):
		newState.age = state.age.update(val)
	}

	// return New State
	return newState
}

이후에 bind(reactor:)에서 state를 바인딩 받을 때 distinctUntilChanged로 중복을 방지해주면 새로운 값이 들어올 때에만 state가 실행되게 된다.

하지만 처음 State를 구현할 때 값을 주입시켜놨기 때문에 최초에 값이 한번 들어오게 된다. 이것은 revision 이 0 이 아닐때만 사용하도록 filter를 걸어주는것으로 방지하거나 처음 Rivisioned Data를 생성할 때 data 값을 nil로 주입시킨 뒤 subscribe에서 guard 문을 걸어주는것으로 방지할 수 있다.

func bind(reactor: AppleReactor) {
	reactor.state
		.map { $0.name }
		.distinctUntilChanged()
		.filter { $0.revision > 0 }
		.subscribe(onNext: { obj in
			guard let str = obj.data else { return }
			print("Name: ", str)
		})
		.disposed(by: self.disposeBag)

	reactor.state
		.map { $0.age }
		.distinctUntilChanged()
		.filter { $0.revision > 0 }
		.subscribe(onNext: { obj in
			guard let val = obj.data else { return }
			print("Age: ", val)
		})
		.disposed(by: self.disposeBag)
}

// result
Name: Min
Age: 28

Reference

profile
iOS Developer 💻

0개의 댓글