Swift/Rick & Morty

[iOS] Rick&Morty - #20 Fetch Episodes

devKen 2023. 3. 20. 21:22

#20

 

에피소드의 데이터를 받아와서 세 번째 섹션을 채우는 작업을 한다.

3번째 섹션

지난번 시간에 우리는 두번째 섹션인 Info 셀에 대한 내용을 채우는 작업을 했다. 

세 번째 섹션에 대해 기억이 안 난다면 다음과 같이 파란색으로 칠해서 보자. 파란색 카드들이 줄지어 있을 것이다. 에피소드 카운트만큼 생성됐으므로 릭 산체스의 경우 51개가 존재한다.

 

final class RMCharacterEpisodeCollectionViewCell: UICollectionViewCell {
    ...
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = .systemBlue
        contentView.layer.cornerRadius = 8
    }
		...
}

 

 

에피소드 VM에는 현재 URL만 존재하고 있다. 이 URL에는 각각 에피소드에 대한 정보들이 담겨있는데 이건 다시 추상화를 위해 모델을 생성해야 하는 것을 뜻한다.

VM에서 fetchepisode 메서드를 생성하고 configure에서 출력하도록 설정하자

 

final class RMCharacterEpisodeCollectionViewCell: UICollectionViewCell {
    ...
    public func configure(with viewModel: RMCharacterEpisodeCollectionViewCellViewModel) {
        viewModel.fetchEpisode()
    }
}
final class RMCharacterEpisodeCollectionViewCellViewModel {
    ...
    public func fetchEpisode() {
        guard let url = episodeDataUrl,
              let request = RMRequest(url: url) else {
            return
        }
        RMService.shared.execute(request,
                                 expecting: RMEpisode.self) { result in
            
        }
    }
}

 

여기서 테이블뷰나 컬렉션뷰의 셀에 대한 이해가 필요하다. 우리가 만든 Configure 함수는 셀이 dequeue 될 때마다 매번 호출이 된다. 이 말은 우리가 congifure에서 fetching을 할 경우, 셀을 넘기면서 호출이 계속 이뤄지는 상황이 발생한다는 것이다. 당연히 불필요한 작업이다.

이를 해결하기 위해 두가지 선택을 할 수 있다.

  1. Flag를 설정하여 이미 fetch 된 데이터는 진행하지 않는다.
  2. 미리 선제적으로 request를 디스패치(발송)하여 모든 에피소드의 데이터를 받아오는 것이다. 이렇게 할 경우 일종의 백그라운드 작업이 될 것이다.

 

먼저 첫 번째 방법이다. isFetching이라는 플래그를 이용하여 각 CellVM에 패칭이 한 번만 수행되도록 한다.

 

final class RMCharacterEpisodeCollectionViewCellViewModel {
    ...
    private var isFetching = false
    ...
    public func fetchEpisode() {
        guard !isFetching else {
            return
        }
        
        guard let url = episodeDataUrl,
              let request = RMRequest(url: url) else {
            return
        }
        isFetching = true
        
        RMService.shared.execute(request,
                                 expecting: RMEpisode.self) { result in
            switch result {
            case .success(let success):
                print(String(describing: success.id))
            case .failure(let failure):
                print(String(describing: failure))
            }
        }
    }
}

 

출력하면 카드가 화면에서 dequeue 될 때마다 계속 id가 출력되는 게 아니라 한번 생성되면 더 이상 출력이 안 되는 걸 확인할 수 있다.

이제 VM이 View에게 모델을 가지고 데이터를 출력하라고 해줘야 한다. 어떻게? 물론 이제까지 써온 대로 delegate를 사용할 수도 있다. 하지만 그 외의 다른 방법도 존재한다. 새로운 방식을 한번 써보자

 

final class RMCharacterEpisodeCollectionViewCellViewModel {
    ...
    private var episode: RMEpisode?
    ...
    public func fetchEpisode() {
        ...
        RMService.shared.execute(request,
                                 expecting: RMEpisode.self) { [weak self] result in
            switch result {
            case .success(let model):
                self?.episode = model
            ...
            }
        }
    }
}

 

Publisher and subscriber pattern

  • 펍섭 패턴이라고도 하던데..

registerForData라는 메서드를 이용하여 publish 할 것이다. 이 메서드는 completion을 가지는데 이제는 block이라고 명명할 것이다. 알프라즈는 이게 더 나은 용어라 생각한다고 한다.

다음으로 프로토콜을 생성하는데 해당 프로토콜에서는 어떤 데이터가 필요한지를 정의한다. 다음으로 데이터를 등록할 때 우리가 받고 싶은 것은 해당 모델을 직접 받는 것이 아니고 프로토콜을 받으려 한다.

이 방법을 통해서 모델 안의 모든 정보를 숨긴 채로 받는 것이 가능하다.

블록은 global 스코프로 받아서 사용해야 한다. 여기서 아주 재밌었던 부분은 block 자체의 타입을 (프로토콜 → Void)로 하는 것이 가능하다는 것이다. 생각 못 해본 사용방법이었다.

다음으로 episode의 didset에서 dataBlock(model)로 모델을 전달해 준다. 성공적으로 모델이 전달된다면 success 케이스에서 데이터를 백그라운드로 업데이트하는 작업을 하자.

 

protocol RMEpisodeDataRnder {
    var name: String { get }
    var airDate: String { get }
    var episode: String { get }
}

final class RMCharacterEpisodeCollectionViewCellViewModel {
    ...
    private var dataBlock: ((RMEpisodeDataRnder) -> Void)?
    
    private var episode: RMEpisode? {
        didSet {
            guard let model = episode else {
                return
            }
            dataBlock?(model)
        }
    }
    ...
    public func registerForData(_ block: @escaping (RMEpisodeDataRnder) -> Void) {
        self.dataBlock = block
    }
    
    public func fetchEpisode() {
        ...
        RMService.shared.execute(request,
                                 expecting: RMEpisode.self) { [weak self] result in
            switch result {
            case .success(let model):
                DispatchQueue.main.async {
                    self?.episode = model
                }
            ...
            }
        }
    }
}

 

VM에서의 작업이 끝나면 이것을 Cell에서 표시하도록 해보자. 일단은 출력이다.

final class RMCharacterEpisodeCollectionViewCell: UICollectionViewCell {
    ...
    public func configure(with viewModel: RMCharacterEpisodeCollectionViewCellViewModel) {
        viewModel.registerForData { data in
            print(String(describing: data))
        }
        viewModel.fetchEpisode()
    }
}

 

지금 이 구조를 통해 작동시키면 우리가 에지케이스가 발생한다.

이미 데이터를 fetch를 통해서 가져온 상황에서 이것을 셀로 가져오는 경우가 바로 그 에지케이스이다.

이 문제를 해결하기 위해 guard로 돌려보내기 전에 model을 dataBlock으로 전달하게 하자.

public func fetchEpisode() {
        guard !isFetching else {
            if let model = episode {
                self.dataBlock?(model)
            }
            return
        }
        ...
    }

 

protocol, didSet, dataBlock을 이용해서 델리게이트를 사용하는 것과 동일한 기능을 수행하는 디자인을 구현했다.

실제로 Cell View에서 viewModel를 통해 접근 가능한 데이터를 살펴보면 우리가 프로토콜에서 정의한 name, airDate, episode 밖에 없다는 사실을 알 수 있다. 이를 통해 효과적으로 데이터의 은닉이 가능하다.

여기서 우리 전체 작업을 다시 상기시켜본다면 탭바에 에피소드 탭이 있는 걸 알 수 있다. 그런데 한번 생각해 보면 에피소드 탭에서도 패치를 통해 에피소드 정보를 다시 불러와야 할까? 왜 통신을 두 번 할 필요가 있지?

없다. 나중에 세션을 캐싱하는 방법을 통해 해결하던지 DB나 코어 데이터를 통해서 데이터를 저장해서 해결할 것이다.