티스토리 뷰

반응형

 

개요

검색어를 통해 코인 정보를 검색할 경우 테이블을 통해 Cell을 보여줘요.

 

 

여기서 Cell을 누를 경우 호가 화면으로 넘어가게 구현하려고 해요.

화면을 구체적으로 구현하기 전에 Cell을 클릭하면 넘어갈 화면의 ViewController를 생성하고, 넘어가는 로직을 구현하려고 해요.

 

설계하기

UIKit으로만 작업한다면 당연히 Delegate을 사용해서 이러한 기능들을 구현하겠지만 현재 프로젝트는 Rx를 사용하는 것을 목표로 하고 있기 때문에 event의 방출을 통해 구현해보려고 해요.

 

우선 첫 화면인 ExchangeViewController가 로직을 갖는 것이 아니라 ExchangeViewController는 CoinListView를 소유하고 CoinListView에서 Table을 구현하는 구조로 작성했기 때문에 로직은 CoinListView에서 구현할 계획이에요.

 

Cell을 클릭하면 그 이벤트를 기준으로 작업이 이루어져야 하기 때문에 클릭을 인지할 Stream을 CoinListViewModel을 정의해주고 bind를 통해 연결할 계획이에요.

 

넘겨받은 데이터를 기준으로 새로운 화면이 그려져야 하기 때문에 새로운 ViewController로 화면이 넘어가야 해요.

이때, CoinListView의 로직에서 직접 다른 화면을 띄우는 것은 좋지 않은 구조라고 생각했어요.

분명히 객체가 가져야 할 권한 이상의 기능일뿐더러 단일 책임에도 위배되기 때문이에요.

 

그래서 저는 Coordinator 객체를 만들어서 이 객체를 통해 화면을 전환하려고 해요.

 

순서를 생각하면

 

CoinListView에서 클릭 이벤트 발생 -> CoinListViewModel이 감지 -> ExchangeViewModel이 감지 -> ExchangeCoordinator를 통해 화면을 전환

 

로 정의할 수 있어요.

 

이때 만약 CellData 자체를 넘겨주는 구조로 작성하면 ViewController간에 Entity를 넘기는 Clean하지 못한 구조가 되기 때문에 raw data인 OrderCurrency만 넘기고, 이것을 기반으로 새로운 화면에서 새로운 사이클을 돌리는 구조로 설계했어요.

 

필요 기술 찾기

Cell을 클릭했을 때 rx.itemSelected를 통해 선택 이벤트를 감지할 수 있어요. 이 때 선택된 Cell의 index는 넘길 수 있지만 Cell이 어떤 코인인지 CellData는 넘길 수 없는 구조예요.

그래서 RxCocoa GitHub에서 검색했어요.

 

TableView+Rx에서 필요한 정보를 찾던 중 아래 코드를 발견했어요.

 

/**
    Reactive wrapper for `delegate` message `tableView:didSelectRowAtIndexPath:`.
    
    It can be only used when one of the `rx.itemsWith*` methods is used to bind observable sequence,
    or any other data source conforming to `SectionedViewDataSourceType` protocol.
    
     ```
        tableView.rx.modelSelected(MyModel.self)
            .map { ...
     ```
*/
public func modelSelected<T>(_ modelType: T.Type) -> ControlEvent<T> {
    let source: Observable<T> = self.itemSelected.flatMap { [weak view = self.base as UITableView] indexPath -> Observable<T> in
        guard let view = view else {
            return Observable.empty()
        }

        return Observable.just(try view.rx.model(at: indexPath))
    }

    return ControlEvent(events: source)
}

 

정확히 제가 원하던 기능이에요! item이 선택되면 그 시점에 indexPath를 통해 그 Cell이 가진 정보를 넘기는 구조예요. 

 

modelSelected

목표는 CoinListView에서 선택된 모델을 ExchangeViewController로 넘기는 거예요.

Coordinator 패턴을 활용하며 ExchangeViewController가 아닌 ExchangeViewModel에서 화면을 전환했어요.

Coordinator 패턴 글 보러 가기

 

modelSelected에서는 선택된 Cell이 가진 데이터를 넘기고, 그것이 현재 프로젝트에서는 CellData이기 때문에 아래와 같이 구현했어요.

 

self.rx.modelSelected(CoinListViewCellData.self)
  .map { $0.ticker }
  .map { OrderCurrency.search(with: $0) }
  .bind(to: viewModel.selectedOrderCurrency)
  .disposed(by: self.disposeBag)

 

View가 자신을 소유한 ViewController를 직접 다른 화면으로 Routing 하는 것은 좋지 않은 구조라고 판단하여 이 모델을 관찰할 수 있는 Stream(selectedOrderCurrency)을 구현했어요. 

 

CellData를 가공하여 코인 타입으로 만들어주고, 그 Event를 selectedOrderCurrency에 전달해요. 

 

전달받은 데이터로 화면 전환하기

이제 selectedOrderCurrency를 바탕으로 화면을 전환하려고 해요.

Coordinator 패턴과 본 프로젝트에서의 적용은 위에서 언급한 링크에 자세히 담겨있어서 이 글에서는 생략할게요!

 

화면 전환은 아래와 같이 구현했어요.

 

self.coinListViewModel.selectedOrderCurrency
  .subscribe(on: MainScheduler.instance)
  .subscribe {
    self.exchangeCoordinator?.presentCoinDetailViewController(orderCurrency: $0)
  }.disposed(by: self.disposeBag)

 

이 과정에서 문제가 발생했었어요.

dispose를 self.disposeBag가 아닌 DisposeBag()으로 구현을 했었는데 원하는 결과물이 나오지 않았어요.

고민해보니 DisposeBag()로 생성된 인스턴스의 생명 주기는 해당 인스턴스가 포함된 메서드가 종료되는 시점에 사라져요.

즉, 비동기로 처리되어야 할 작업이 바로 비워져 버려서 원하는 작업이 진행되지 않는다고 판단했어요. 

새삼스럽게 왜 프로퍼티로 disposeBag을 선언하는지 깨닫게 되었어요.

 

Escaping closure captures mutating 'self' parameter

위의 코드를 구현했을 때 ExchangeViewModel은 struct구조였어요. 

그런데 제목과 같은 에러가 발생했어요.

해석해보면 탈출 클로저가 mutating 한 self를 캡처한다는 뜻이에요. 

 

즉, 비동기 동작이 이뤄지는 시점의 self와 캡처되는 순간의 self가 달라질 수 있어서 에러를 표시해주는 거예요.

값 타입의 경우에는 사용 시점이 아니라 캡처 시점의 값이 저장될 수 밖에 없기 때문이죠!

 

하지만 클로저는 캡쳐 시 값 타입의 값들도 참조 형식으로 저장한다고 알고 있어요.

만약 참조 형식으로 저장된다면 위의 에러가 발생하면 안 된다고 생각했고, 왜 안 되는지에 대한 의문이 생겼어요.

생각해보면 값 타입을 참조 형식으로 저장한다고 해도 원본 값의 주소를 참조 형식으로 저장하는 게 아니라 원본 값을 복사한 값의 주소를 저장한다고 알고 있어요.

즉, 참조 타입이라고 원본의 변화를 인식할 수 있는 게 아니라 전혀 다른 값을 인식하는 것이죠.

 

단순히 에러 메시지를 없애고 싶다면 캡처 리스트의 사용으로도 충분했어요.

 

self.coinListViewModel.selectedOrderCurrency
  .subscribe(on: MainScheduler.instance)
  .subscribe { [self]
    self.exchangeCoordinator?.presentCoinDetailViewController(orderCurrency: $0)
  }.disposed(by: self.disposeBag)

 

하지만 말 그대로 에러 메시지만 사라지는 것이지 문제를 해결하지는 못해요.

위의 코드는 init에 포함되어 있고 init시점에 exchangeCoordinator는 코드 흐름상 무조건 nil이기 때문이에요.

 

그래서 ViewModel을 final class로 변경해줬어요. 

발생한 문제가 값 타입이므로 생긴 문제라 당연히 참조 타입으로 변경하면 문제가 해결되겠죠?

거기에 한 가지 이유가 더 있는데 값 타입 안에 참조 타입 프로퍼티가 여러 개 생기면 성능에 영향을 줄 수 있어서 class로 변경했어요! 

 

관련 PR:
https://github.com/helloworldjay/Bithumb/pull/27

 

반응형
댓글