[iOS] Rick&Morty - #20 Fetch Episodes

에피소드의 데이터를 받아와서 세 번째 섹션을 채우는 작업을 한다.
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을 할 경우, 셀을 넘기면서 호출이 계속 이뤄지는 상황이 발생한다는 것이다. 당연히 불필요한 작업이다.
이를 해결하기 위해 두가지 선택을 할 수 있다.
- Flag를 설정하여 이미 fetch 된 데이터는 진행하지 않는다.
- 미리 선제적으로 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나 코어 데이터를 통해서 데이터를 저장해서 해결할 것이다.
끝