지금까지 RxSwift를 이용한 MVVM구조를 공부하고 적용해본적이 몇 번 있는데, Input Output 처럼 템플릿을 어느정도 만들어 놓고 사용할 수는 있지만 제대로 된 템플릿이 있지는 않다보니 항상 개발하면서 바인딩 하나하나 고민하고 검색을 하며 개발을 했었다.
사실 MVVM 구조를 많이 개발 안해본것이 가장 큰 문제겠지만, RxSwift 역시 익숙치 않아 항상 개발하며 많은 시간이 걸렸다...
그러던 중 RxSwift를 어느정도 써보면서 템플릿화 된 ReactorKit도 해보는게 어떻냐는 추천을 받았었다.
이름을 들어본 회사에서 기술스택에 포함되어 있는것을 봤어서 한 번 적용해보자! 라는 마음으로, iOS를 처음 시작했을때 패캠 강의를 다 보고 만들어 출시한 앱을 ReactorKit으로 구조를 변경해보며 공부를 하기로 했다.
ReactorKit의 기본 구조
GitHub - ReactorKit/ReactorKit: A library for reactive and unidirectional Swift applications
A library for reactive and unidirectional Swift applications - ReactorKit/ReactorKit
github.com
먼저 ReactorKit을 간단하게 알아보자.
ReactorKit은 Flux와 Reactive Programming의 개념을 결합해 만들어졌다.
여기서 Flux는 애플리케이션의 데이터 흐름을 관리하는 패턴으로 단방향으로 데이터가 흐른다. Flux의 구조는 4가지로 Dispatcher, Store, Action, View로 나눠져 있는데, 이런 개념과 구조를 ReactorKit에 녹여낸것이다.
그렇기에 장점으로는 데이터의 일관성과 예측 가능성을 높여주고, 모듈간 결합도를 낮추고 테스트를 쉽게 할 수 있다.
View에서 유저의 상호작용을 확인하고, Reactor에서 Action을 받고 Mutate로 비즈니스 로직을 처리한 후 데이터를 Reduce에 보내 State를 업데이트 한다. 그럼 View에서 State를 구독하고 있다가 변경된 State를 받아 UI를 업데이트하게된다!
먼저 View의 기본 구조의 코드다.
import UIKit
import ReactorKit
final class ViewController: View, UIViewController {
typealias Reactor = ViewReactor
var disposeBag = DisposeBag()
init(reactor: Reactor) {
super.init(nibName: nil, bundle: nil)
self.reactor = reactor
}
func bind(reactor: ViewReactor) {
}
}
View는 SwiftUI에서도 볼 수 있는 클래스지만, 여기서는 ReactorKit의 View를 말하는것이다. 추가로 Reactor라는 클래스도 있다.
bind에서는 reactor의 state에 구독하거나 유저의 상호작용에 따른 Action을 보내주는 바인딩 작업을 하면 된다.
그 다음 Reactor 코드.
import ReactorKit
class ViewReactor: Reactor {
let initialState = State()
enum Action { // 여러 event 정의
}
enum Mutation { // State 값을 변경하는 가장작은 단위
}
struct State { // 값이나 상태
}
func mutate(action: Action) -> Observable<Mutation> { //네트워킹이, 비동기 로직과 같은 로직을 여기서 처리 후 결과값으로 옵져버블 Mutation을 리턴, 이 값이 reduce()로 전달됨.
}
func reduce(state: State, mutation: Mutation) -> State { //여기서 state 변경
}
}
주석으로 설명이 되어 있는 것 처럼 Action에는 유저와 상호작용시 바인딩한 이벤트가 들어오게 되고, 그걸 mutate 함수에서 action을 받아 비즈니스 로직을 처리하고 그 결과를 mutation으로 보내 state를 변경한다.
자세한 코드는 직접 변경해보며 알아보자!
기존 프로젝트 ReactorKit 으로 변경
여기서는 Home 화면을 변경한 것만 기록.
먼저 기존에 유저와 상호작용하는 버튼이나 보여줘야 할 데이터를 생각해서 Reactor의 기본구조 3가지를 채워줬다.
enum Action {
case brandType(BrandType)
case rankDateType(String?)
case songDetail(IndexPath)
}
enum Mutation {
case changeBrand(BrandType)
case changeDate(RankDateType)
case popularList([Song])
case LoadState(Bool)
case moveToDetail(Song?)
case alertError(NetworkError?)
case null
}
struct State {
var selectedSong: Song?
var popularList: [Song] = []
var brandType: BrandType = .tj
var dateType: RankDateType = .daily
var isLoading: Bool = false
var errorDescription: String? = nil
}
Home 화면에서는 인기차트를 일,주,월간 으로 보여주고 노래방 브랜드도 스위칭 하여 볼 수 있다.
또한 검색, 애창곡 화면으로 이동하는 기능도 있다.
func mutate(action: Action) -> Observable<Mutation> { //service 및 데이터 변환
switch action {
...
case let .rankDateType(date):
let date = (RankDateType(rawValue: date ?? "일간") ?? RankDateType.daily)
let response: Observable<Mutation> = service.rx.rankSongsRequest(brand: currentState.brandType, date: date)
.asObservable().materialize()
.map { result -> Mutation in
switch result {
case .completed: return .null
case let .next(songs): return .popularList(songs)
case let .error(error): return .alertError(error as? NetworkError)
}
}
return .concat([ //여러개를 묶어서 전송
.just(.changeDate(date)), .just(.LoadState(true)),
response
])
case let .songDetail(indexPath):
let song = currentState.popularList[indexPath.row]
return .concat([.just(.moveToDetail(song)), .just(.moveToDetail(nil))]) //😀
}
}
action을 받아 위와같이 mutate에서 처리를 해줄 수 있다.
또한 reduce로 보내는 Mutation은 concat을 이용해 여러개를 묶어서 보낼 수 있다.
😀-이 주석은 개발하면서 문제가 생겼던 부분이였는데, 처음엔 song 값만 보내고 끝이였다. 하지만 다른 State가 변경되어도 reduce에서는 항상 state 전체를 반환하다보니 남아있는 값 때문에 의도하지 않게 detail이 계속 표시되던 상황이 있었다.
View에서 바인딩시 .distinctUntilChanged() 로 중복을 걸러서 사용할 수는 있겠지만, 사용자가 같은 셀을 여러번 눌러 확인 할 수도 있으니, 차라리 먼저 Detail을 보여주고나면 다시 nil로 만드는 방식을 사용해서 해결하였다.
하지만,,, @Pulse를 사용하면 추가적인 작업이 필요없다는걸 알았다.. 그건 아래에 따로 기술할 예정.
func reduce(state: State, mutation: Mutation) -> State { // state data 주입
var state = state
switch mutation {
case let .moveToDetail(song):
state.selectedSong = song
case let .LoadState(isLoading):
state.isLoading = isLoading
case let .popularList(songs):
state.popularList = songs
state.isLoading = false
...
}
return state
}
reduce에서는 간단하게 state의 값을 변경해주면 된다!
마지막으로 View에서의 바인딩을 보면
func bind(reactor: HomeReactor) {
//Action
brandSegmentedControl.rx.selectedSegmentIndex
.map{Reactor.Action.brandType(BrandType.allCases[$0])}
.bind(to: reactor.action)
.disposed(by: disposeBag)
rankTableView.rx.itemSelected
.map{Reactor.Action.songDetail($0)}
.bind(to: reactor.action)
.disposed(by: disposeBag)
rankTableHeader.currentTitle
.map {Reactor.Action.rankDateType($0)}
.bind(to: reactor.action)
.disposed(by: disposeBag)
...
//State
reactor.state.observe(on: MainScheduler.instance)
.map{$0.isLoading}
.distinctUntilChanged()
.withUnretained(self)
.bind { owner, isLoading in
owner.loadIndicator.isHidden = !isLoading
isLoading ? owner.loadIndicator.startAnimating() : owner.loadIndicator.stopAnimating()
}
.disposed(by: disposeBag)
reactor.state.observe(on: MainScheduler.instance)
.map{ $0.popularList }
.bind(to: rankTableView.rx.items(cellIdentifier: SongTableViewCell.identifier, cellType: SongTableViewCell.self)) { index, item, cell in
cell.setup(rank: index, song: item)
}
.disposed(by: disposeBag)
...
}
bind 메서드에서는
- Reactor에서 정의한 Action을 이벤트가 발생하면 reactor에 binding
- State값을 구독하고 그에따른 View update 시행
두가지를 하면 끝이다.
ReactorKit을 사용하며 알게된 팁 (어려웠던 문제)
1. transform 함수 사용하기
내 프로젝트에서는 A화면에서 노래의 상세 페이지를 확인했을때 별을 눌러 저장상태를 변경하면 A화면의 데이터들을 다시 업데이트해줘야했다.
이 전에는 간단하게 Delegate를 사용해 그 기능을 구현하고 있었는데, ReactorKit을 쓰고 있는데 이에 대응하는 기능이 있다면 써봐야지 하며 찾아보고 알게된게 global state, transform 함수이다.
//Service
enum PersistenceEvent {
case deleted(Bool)
}
final class PersistenceManager: ReactiveCompatible {
static var shared: PersistenceManager = PersistenceManager()
lazy var persistentContainer: NSPersistentContainer = {...}()
var context: NSManagedObjectContext {...}
let event = PublishSubject<PersistenceEvent>()//😀
@discardableResult
func addFavoriteSong(song: Song) throws -> Bool {
...
do {
try self.context.save()
event.onNext(.deleted(true)) //😀
return true
}
catch {
event.onNext(.deleted(false)) //😀
throw PersistenceError.addError
}
}
else {
throw PersistenceError.entityError
}
}
먼저 Global state를 정의한다. Global state는 변경이 생겼을때 알림을 받고싶은 데이터를 타겟으로 생성해주면 된다.
//Reactor
func transform(action: Observable<Action>) -> Observable<Action> {
let eventAction = persistence.event.flatMap { event -> Observable<Action> in
switch event {
case let .deleted(isDeleted):
if isDeleted { return .just(.reload)}
else { return .just(.reload)}
}
}
return Observable.merge(action, eventAction)
}
// FavoriteSongReactor에서 SongDetailReactor를 return해 주어야 값 처리가 가능함.
func reactorForSetting() -> SongDetailReactor? {
guard let song = currentState.selectedSong else { return nil }
return SongDetailReactor(song: song)
}
두번째로 Reactor에서 아까 생성한 Global state를 구독하기 위해 transform 메서드를 사용한다.
Global state를 구독하고 값에 따라 어떤 액션을 할지 정의한 후 본래 Reactor에 있는 State와 함께 보내주면 된다.
transform 메서드는 action, mutate, state 3가지 모두 대응하고 있어서 원하는 방식대로 연결하면 된다.
추가로 다른 화면에서 바뀌는 값을 구독하기 위함이니까 화면 이동시 필요한 리액터는 Base가 되는 Reactor에서 객체를 생성을 하여 보내면 된다.
내 프로젝트의 경우 Service를 그냥 Singleton으로 만들어버려서 객체 참조값이 동일하기에 따로 Reactor를 생성할때 작업을 안했지만, 만약에 service도 따로 주입해야하고 Singleton이 아닌 경우에는 Base가 되는 Reactor에 주입된 Service를 그대로 넘겨주면 된다.
//View
reactor.state.compactMap{$0.selectedSong}
.withUnretained(self)
.bind { owner, song in
if let detailReactor = reactor.reactorForSetting() {
let songDetailVC = SongDetailViewController(reactor: detailReactor)
owner.present(songDetailVC, animated: true)
}
}
.disposed(by: disposeBag)
마지막으로 화면이동을 위한 State를 구독해놓고 reactor에서 정의한 reactor를 주입해고 present 해주면 완성이다!
2. Action, Mutation 묶어서 보내기
위에서도 잠깐 설명했지만, Mutation은 묶어서 보낼 수 있고 예제들을 보았을 때 많이 보이는 패턴이었다.
하지만 당연히 Action도 묶어서 보낼 수 있을텐데 왜 다른 예제에서 못 본것인지 모르겠다...
그래서 이런 식으로 한 번 구현을 해봤다.
brandSegmentedControl.rx.selectedSegmentIndex
.flatMap{ index in
let type = BrandType.allCases[index]
let first = Observable.just(Reactor.Action.brandType(type))
let second = Observable.just(Reactor.Action.search)
return Observable.concat([first, second])
}
.bind(to: reactor.action)
.disposed(by: disposeBag)
브랜드를 변경하는 Mutate에서는 brand만 변경시켜야 하지만 Action을 묶어서 보내기 전에는 브랜드가 변경되고 service를 통해 데이터를 다시 요청해와 mutation을 보내는 것이 문제가 있는 것 같아 Action을 묶기로 했는데 문제가 있는 방법은 아닌거 같다.
3. viewdidLoad 에서 action 보내기
처음 써보면서 생각을 못했는데 view가 load되었을때 작업이 필요한 작업도 존재했다.
bind는 viewdidLoad전에 호출되기 때문에 로드되며 필요한 작업이 있는경우에 viewdidload에서 reactor의 action을 보내주면 된다.
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
reactor?.action.onNext(.viewDidLoad)
}
그리고 블로그에서 예제를 여러개 봤을때, reactor를 직접 VC에 선언해주거나 바인드를 따로 만들거나 하는 코드들을 봤는데,
기본적으로 ReactorKit의 View를 상속받으면 reactor는 이미 부모객체가 가지고 있는 상태고 사용할때는 Reactor의 type만 지정해주면 된다.
나머지도 필수 함수와 변수만 정의해주면 다 연결을 시키게 되어있으니, 굳이 안건드는게 더 좋은 방법이다.
궁금하다면 View, Reactor의 정의를 직접 뜯어보면 될 거 같다.
4. @Pulse 사용하기
ReactorKit의 어노테이션 중 @Pulse 라는게 존재하는데 이걸 모르고 프로젝트를 마무리 했었다..
지금까지 사용하면서 State 중 하나가 변경되어도 모든 값이 함께 보내져 구독하고 있는 UI 들이 다 같이 업데이트 되던것이 신경쓰였는데
@Pulse를 사용하면 해당 상태값이 변경될때만 반응한다...
위에서 셀을 선택하고 다시 nil을 주어서 한 번 표시된것을 바로 nil로 변경하는 방식을 사용해 중복되서 다시 표시되지 않게 할 수 있었다. 하지만 여기서 그냥 State에 @Pulse를 붙여주면 이러한 작업이 필요 없는것이다...
사용법은 간단하다.
struct State {
var favoriteSongs: [Song] = []
@Pulse var selectedSong: Song?
...
}
이렇게 변경하고 추가적으로 mutate에서 nil을 묶어 보낸 코드를 제거해보니 문제없이 의도한대로 동작한다!!
이 외에도 테스트 방법이 있는데 프로젝트에 적용해보면서 공부해봐야겠다.
ReactorKit 시작하기
오늘은 StyleShare에서 ReactorKit을 사용한지 딱 1년이 되는 날입니다. ReactorKit은 반응형 단방향 앱을 위한 프레임워크로, StyleShare와 Kakao를 비롯한 여러 기업에서 사용하고 있는 기술입니다.
medium.com
ReactorKit으로 변경한 프로젝트
GitHub - JustHm/KaraokeBooks: 노래방 책자 어플
노래방 책자 어플. Contribute to JustHm/KaraokeBooks development by creating an account on GitHub.
github.com