RxSwift와 Input Output을 활용한 MVVM 구현
서론
최근 TCA나 ReactorKit을 살펴 보다보다가 input output을 활용한 MVVM구현에 대한 포스팅을 보게 되었습니다.
기존에 MVVM을 구현할 때는 표준이나 정해진 약속이 없어 구현하는 사람에 따라 다양한 방식으로 구현 되는데요.
`input output`패턴을 활용하여 구현하면 조금더 데이터의 흐름을 파악하기 쉽고 뷰와 뷰모델이 주고 받는 것을 명확하게 표현할 수 있다는 장점이 있습니다.
물론 Rx 같은 비동기 프로그래밍에 대한 사전지식이 필수라는 단점이 있지만 그만큼 더 깔끔한 프로그래밍 할 수 있는 것 같습니다.
그래서 오늘은 실제로 공부하고 간단하게 테이블 뷰가 있는 뷰컨트롤러에 적용해보려고 합니다.
그래서 어떻게 구현?
protocol ViewModel
제일 중요한 키워드는 input output입니다.
먼저 View와 ViewModel 사이의 데이터 흐름을 input output으로 구분합니다.
protocol ViewModel {
associatedtype Input
associatedtype Output
var disposeBag: DisposeBag { get set }
func transform(input: Input) -> Output
}
View에서 전달되는 이벤트인 Input
Intput의 결과를 출력하는 Output
두가지를 먼저 `associatedtype`으로 정의합니다.
그 다음 저는 Rx를 사용할 예정이기 때문에 disposeBag도 정의해줍니다.
마지막으로 Input을 가공하여 Output을 방출하는 `transform()`메서드도 정의해줍니다.
class TableViewModel: ViewModel
그 다음에 ViewController에서 사용하도록 ViewModel을 구현해줍니다.
final class ProductListViewModel: ViewModel, ProductListProvider {
// MARK: - ViewModel
struct Input {
let viewDidLoadTrigger: Observable<Void>
let fetchMoreDatas: PublishSubject<Void>
}
struct Output {
let productList: BehaviorRelay<[Product]>
let failAlertAction: Signal<String>
}
var disposeBag: DisposeBag = .init()
private let failAlertAction = PublishRelay<String>()
func transform(input: Input) -> Output {
input.viewDidLoadTrigger
.subscribe(
onNext: { [weak self] in
guard let self = self else { return }
do {
try self.checkServer()
try self.fetchProductPage(pageNumber: self.pageCounter)
} catch let error {
self.failAlertAction.accept(error.localizedDescription)
}
}
)
.disposed(by: disposeBag)
input.fetchMoreDatas
.subscribe(
onNext: { [weak self] in
guard let self = self else { return }
do {
try self.fetchProductPage(pageNumber: self.pageCounter)
} catch let error {
self.failAlertAction.accept(error.localizedDescription)
}
}
)
.disposed(by: disposeBag)
return Output(
productList: productList,
failAlertAction: failAlertAction.asSignal()
)
}
// MARK: - ProductListProvider
var productList = BehaviorRelay<[Product]>(value: [])
var pageCounter: Int = 1
func fetchProductPage(pageNumber: Int) throws {
Task.detached { [self] in
// 다음 Page Item을 요청 후 product list에 전달
}
}
}
Input & Output
struct Input {
let viewWillAppearTrigger: Observable<Void>
let fetchMoreDatas: PublishSubject<Void>
}
struct Output {
let productList: BehaviorRelay<[Product]>
let failAlertAction: Signal<String>
}
ViewController의 viewWillAppear 시점을 확인하기 위한 trigger 변수
tableView의 데이터를 요청하는 fetchMoreDatas 변수
두가지를 Input에 정의해줍니다.
Output에는
TableView에 사용하는 아이템 리스트 변수
에러가 발생했을 때 사용자에게 error 메세지를 보여주는 변수
두가지를 정의해줍니다.
transform()
func transform(input: Input) -> Output {
input.viewDidLoadTrigger
.subscribe(
onNext: { [weak self] in
guard let self = self else { return }
do {
try self.checkServer()
try self.fetchProductPage(pageNumber: self.pageCounter)
} catch let error {
self.failAlertAction.accept(error.localizedDescription)
}
}
)
.disposed(by: disposeBag)
input.fetchMoreDatas
.subscribe(
onNext: { [weak self] in
guard let self = self else { return }
do {
try self.fetchProductPage(pageNumber: self.pageCounter)
} catch let error {
self.failAlertAction.accept(error.localizedDescription)
}
}
)
.disposed(by: disposeBag)
return Output(
productList: productList,
failAlertAction: failAlertAction.asSignal()
)
}
transform 메서드는 매개변수로 받은 input을 가공해서 Output을 return 합니다.
데이터 스트림을 한 곳에서 볼 수 있어서 ViewModel의 가독성이 좋아졌습니다.
ViewController부터 ViewDidLoad가 실행되었다고 알림이 오면 ViewModel에서는 TableView에 사용할 아이템을 서버로부터 전달받습니다.
ViewController
final class ProductListViewController: BaseTableViewController {
private let viewModel: ProductListViewModel
init(viewModel: ProductListViewModel) {
self.viewModel = viewModel
super.init()
}
override func setupBind() {
super.setupBind()
let viewDidLoadTrigger = Observable.just(Void())
let input = ProductListViewModel.Input(viewDidLoadTrigger: viewDidLoadTrigger,
fetchMoreDatas: PublishSubject<Void>())
let output = viewModel.transform(input: input)
output.productList
.observe(on: MainScheduler.instance)
.bind(to: tableView.rx.items(cellIdentifier: ProductCell.reuseIdentifier,
cellType: ProductCell.self)) { index, item, cell in
cell.configure(with: item)
}.disposed(by: disposeBag)
output.failAlertAction
.emit(onNext: { title in
let alert = AlertFactory.make(.failure(title: title, message: nil))
self.present(alert, animated: true)
})
.disposed(by: disposeBag)
tableView.rx.setDelegate(self)
.disposed(by: disposeBag)
tableView.rx.prefetchRows
.compactMap(\.last?.row)
.withUnretained(self)
.bind { viewController, row in
guard row == viewController.viewModel.productList.value.count - 1 else { return }
input.fetchMoreDatas.onNext(())
}
.disposed(by: disposeBag)
tableView.rx.modelSelected(Product.self)
.subscribe { element in
let productDetailViewController = ProductDetailViewController(product: element)
productDetailViewController.modalPresentationStyle = .popover
self.present(productDetailViewController, animated: true, completion: nil)
}
.disposed(by: disposeBag)
}
}
ViewController에서는 필요한 input output 을 선언해주고 output에 있는 친구들을 하나씩 binding 해줍니다.
추가적으로 TableView에 추가적인 아이템을 요청하려면 RxCocoa의 `prefetchRows`를 활용한다면 쉽게 데이터를 요청할 수 있더라구요. 완전 꿀팁!!
마무리
ViewModel의 Input, Output을 통해 추상화를 하니 구조가 간결해지고 바인딩도 한눈에 쉽게 알아볼 수 있게 된 것 같습니다.
또한, 기능의 수정 또는 추가시 손 쉽게 추가할 수 있고, ViewModel의 Testing까지 진행 할 수 있을 것 같네요.
그리고 외부 라이브러리를 사용하지 않고 앱의 구조를 정의 할 수 있는 것 또한 장점으로 느껴지네요.
물론, 간결한 화면 구조에서는 오버엔지니어링이 될 것 같지만 복잡한 화면에서는 구조를 간결하게 만드는데 도움을 주는 방법이라고 생각합니다.
읽어주셔서 감사합니다 😀
전체 코드 : https://github.com/zhilly11/ios-open-market-refactor