MVVM Inout패턴 + 네트워크 에러 핸들링

MVVM Inout패턴

ViewController -> ViewModel로 데이터나 이벤트를 Input으로 전달, ViewModel -> ViewController로 데이터나 이벤트를 Output으로 전달하는 구조. Input, Output으로 입출력이 명확하게 나뉘어지기에 유지보수나 가독성을 향상시킨 디자인 패턴

 

 

 

예시 - 아이튠즈 검색

 

구조

Input: SearchBarText, SearchBarTapSearchButton 이벤트

Output: isLoading, searchError, Search Result

시행착오

tapSearchButton 이벤트 스트림으로 에러처리 한 경우

func transform(input: Input) -> Output {
        let searchResult: PublishRelay<[AppInfo]> = .init()
        let searchError: PublishRelay<Error> = .init()
        input.tapSearchButton.withLatestFrom(input.searchText) { _ , query in
            return query
        }.subscribe(on: MainScheduler.instance)
            .flatMap { self.searchKeyword(keyword: $0) }
            .subscribe(with: self) { owner, result in
                owner.isLoading.accept(false)
            owner.searchResult.append(contentsOf: result)
                searchResult.accept(owner.searchResult)
        } onError: { owner, error in
            owner.isLoading.accept(false)
            searchError.accept(error)
        }.disposed(by: disposeBag)

        return Output(isLoading: isLoading.asDriver(), searchResult: searchResult.asDriver(onErrorJustReturn: []), searchError: searchError.asDriver(onErrorJustReturn: APIError.unknownResponse))
    }
    
    private func searchKeyword(keyword: String) -> Observable<[AppInfo]> {
        isLoading.accept(true)
        return searchService.fetchBoxOfficeData(searchKeyword: keyword)
    }

 

  • 이벤트를 받아 flatMap으로 검색 수행, 이후 subscribe로 오류 및 에러처리
  • 하지만 오류가 발생했을 경우 tapSearchButton이 dispose되면서 이후에 이벤트를 받을 수 없음
  • catch, catchReturns은 해당 스트림을 그대로 유지시켜 주지만, 기존에 subscribe를 하던 Observable을 dispose하고, 새로운 Observable은 리턴 해줌 -> tapSearchButton이 dispose되면서 동일한 문제 발생
  • drive역시 에러를 안받는다고 하기에 에러로 인한 dispose가 없다는 뜻으로 이해했으나.. 베이스는 Observable이기에 driver도 error받으면 dispose됨

따라서 tapSearchButton 스트림에서는 검색만 수행, viewModel 내부에 변수를 추가하여 output에 해당 변수를 전달하는 방식으로 구현.

import Foundation
import RxCocoa
import RxSwift

protocol BaseViewModel {
    associatedtype Input
    associatedtype Output
    
    var disposeBag: DisposeBag { get set }
    
    func transform(input: Input) -> Output  
}

class SearchViewModel: BaseViewModel {
    
    private var searchResult: [AppInfo] = []
    var searchResultRelay = PublishRelay<[AppInfo]>()
    var searchError = PublishRelay<Error>()
    var disposeBag = DisposeBag()
    private let isLoading: BehaviorRelay<Bool> = .init(value: false)
    private let currentError: PublishRelay<Error> = .init()
    let searchService = SearchService()
    
    struct Input {
        let searchText: ControlProperty<String>
        let tapSearchButton: ControlEvent<Void>
    }
    
    struct Output {
        let isLoading: Driver<Bool>
        let searchResult: Driver<[AppInfo]>
        let searchError: Driver<Error>
    }
    
    func transform(input: Input) -> Output {
        
        input.tapSearchButton.withLatestFrom(input.searchText) { _ , query in
            return query
        }.bind(with: self) { owner, query in
            owner.searchKeyword(keyword: query)
        }.disposed(by: disposeBag)
        
        return Output(isLoading: isLoading.asDriver(), searchResult: searchResultRelay.asDriver(onErrorJustReturn: []), searchError: searchError.asDriver(onErrorJustReturn: APIError.unknownResponse))
    }
    
    private func searchKeyword(keyword: String){
        isLoading.accept(true)
        searchService.fetchBoxOfficeData(searchKeyword: keyword).subscribe(with: self) { owner, result in
            owner.isLoading.accept(false)
            owner.searchResultRelay.accept(result)
        } onError: { owner, error in
            owner.isLoading.accept(false)
            owner.searchError.accept(error)
        }.disposed(by: disposeBag)

    }
    
}

 

구현은 하기는 했으나 뭔가 Rx스럽지않는다는 느낌이 든다. 중간에 stream이 끊기니까 굳이 Rx로 작성할 이유가 없어보인다.. 조만간 멘토님께 물어봐서 좀 고쳐야 할 듯 하다.