[TIL] iOS 심화 주차 과제 : 포켓몬 도감 앱 만들기 day.05

Emily·2025년 1월 6일
4

PictorialBookApp

목록 보기
5/5
post-thumbnail

자잘한 리팩토링

URL에서 포켓몬 id 추출하는 코드 수정

// before
var pokemonId: String {
    guard let firstIndex = url.dropLast().lastIndex(of: "/") else { return "" }
    return String(url[firstIndex..<url.endIndex].dropFirst().dropLast())
}

이건 내 기존 코드인데, 솔직히 처음에 이걸 썼을 때도 내가 생각해낼 수 있는 유일한 방법이어서 썼지 계속 drop을 하는 게 썩 마음에 들진 않았다. 그래서 다른 동기들은 어떻게 구현했는지 구경하러 다니다가 마음에 드는 코드를 발견해서 따라쓴다고 말하고 따라썼다😁 히히

// after
var pokemonId: String {
    guard let id = url.split(separator: "/").last else { return "" }
	return String(id)
}

split을 왜 생각해내지 못했을까? 하여튼 저걸 떠올리지 못해서 뒤에서 두번째 /와 마지막 / 사이의 값을 빼내겠다고 저 난리를 피웠던 것이다. 그래도 재밌었다.

2개 이상의 포켓몬 type을 표시하는 코드 수정

// before
typeLabel.text = "타입 : \(pokemon.types[0].type.translatedType)"

if pokemon.types.count > 1 {
    typeLabel.text?.append(", \(pokemon.types[1].type.translatedType)")
}

사실 포켓몬 타입이 최대 2개라는 것을 확인했기 때문에 그냥 하나 더 있으면 그 뒤에 ", 타입"을 붙이자는 의도에서 작성한 코드긴 하다. 하지만 이건 만약 값이 업데이트 되어 3개 이상이 되었을 때는 수정을 해야하는 코드인 것이다. 그래서 수정이 필요없는 더 좋은 코드로 수정했다. 이 코드 역시 위의 코드를 쓴 같은 동기가 쓴 것이다. (매우 고마운 마음을 전하는 바다.)

// after
typeLabel.text = "타입 : \(pokemon.types.map { $0.type.translatedType }.joined(separator: ", "))"

깔끔 편안 동기사랑 나라사랑

포켓몬 이름 번역기 오류 수정

과제 가이드에서 포켓몬 이름 번역 코드를 제공해주었는데, 당시 포켓몬 API의 응답과 현재 응담 데이터의 형태에 차이가 있나보다. 몇몇 포켓몬 이름들이 달라져 있었다.

"nidoran♀": "니드런♀"

"mr. mime": "마임맨"

이런 식으로 한 다여섯개 발견해서 수정했다. 근데 다른 동기가 자기는 30개도 더 넘게 고쳤다는 것이다😮 그래서 거래를 통해 번역 코드를 받았다. (금전 거래 아님) 다른 사람들과 교류하며 코드 이야기를 하는 것이 얼마나 좋은 건지 새삼 느낀 이번 과제였다.

Rx를 이용한 리팩토링

필수 구현 기능을 다 마친 뒤 도전해볼 만한 구현 사항에는 다음이 있었다.

  1. Relay를 활용해보세요. (RxSwift)
  2. 이미지 라이브러리 Kingfisher를 활용해보세요.

Kingfisher는 이미 처음부터 썼기 때문에, 남은 건 SubjectRelay의 차이를 알아보고 적용하는 것이었다.

Subject → Relay

// 기존 subject를 사용한 코드
class MainViewModel {
	let pokemonList = PublishSubject<[PokemonResult]>()
    
	func fetchPokemonList() {
        offset += 20

        provider.request(.fetchURL(offset: offset)) { [weak self] result in
            switch result {
            case .success(let response):
                do {
                    let data = try JSONDecoder().decode(PokemonURL.self, from: response.data)
                    self?.pokemonList.onNext(data.results)
                } catch {
                    self?.pokemonList.onError(NetworkError.decodingFailed)
                }
			case .failure(let error):
                self?.pokemonList.onError(error)
            }
        }
    }
}

class DetailViewModel {
	let pokemonDetail = PublishSubject<Pokemon>()
    
	func fetchPokemonDetail(_ urlString: String) {
        guard let url = URL(string: urlString) else {
            pokemonDetail.onError(NetworkError.invalidURL)
            return
        }

        networkManager.fetch(url: url)
            .subscribe(
                onSuccess: { [weak self] (response: Pokemon) in
                    self?.pokemonDetail.onNext(response)
				},
                onFailure: { [weak self] error in
                    self?.pokemonDetail.onError(error)
                }
            )
            .disposed(by: disposeBag)
	}
}

SubjectRxSwift에서 ObservableObserver 역할을 모두 수행할 수 있는 타입이다. 즉, 다른 Observable을 구독함과 동시에 값을 방출할 수 있다. 위에 코드를 보면, 네트워크 통신을 통해 불러온 API response 값에 대해 통신 성공 시에는 onNext, 실패 시에는 onError를 통해 결과 값을 받는 것을 확인할 수 있다.

// relay로 변경한 코드
class MainViewModel {
	let pokemonList = PublishRelay<[PokemonResult]>()
    
    func fetchPokemonList() {
    	offset += 20
		
        provider.request(.fetchURL(offset: offset)) { [weak self] result in
        	switch result {
            	case .success(let response):
                	do {
                    	let data = try JSONDecoder().decode(PokemonURL.self, from: response.data)
                        self?.pokemonList.accept(data.results)
                    } catch {
						print(error.localizedDescription)
                    }
                case .failure(let error):
                	print(error.localizedDescription)
            }
        }
    }
}

class DetailViewModel {
	let pokemonDetail = PublishRelay<Pokemon>()
    
    func fetchPokemonDetail(_ urlString: String) {
    	guard let url = URL(string: urlString) else { return }
        
        networkManager.fetch(url: url)
        	.subscribe(
            	onSuccess: { [weak self] (response: Pokemon) in
                	self?.pokemonDetail.accept(response)
                },
                onFailure: { error in
                	print(error.localizedDescription)
                }
            )
            .disposed(by: disposeBag)
    }
}

Relay 역시 Subject와 동일하게 ObservableObserver 역할을 모두 수행하는 타입이다. 하지만 RelayRxCocoa의 객체이며, 주로 UI 이벤트 처리에 사용한다. 코드를 보면, Subject일 때는 onNext로 구독한 값을 받아왔던 부분을 Relayaccept를 통해 받는 것을 알 수 있다. Relay는 값을 구독하고 방출하지만 에러완료 이벤트를 방출하지 않기 때문에, 멈추지 않고 값을 받는다는 점에서 UI를 그리는 데 유용한 것이다. (데이터 변경에 따라 UI는 계속 업데이트 되어야하기 때문이다.) 이에 따라, Relay를 구독하는 Controllerbind 코드에도 onError 이벤트 처리 부분이 삭제되었다.

RxMoya 적용

네트워크 통신 라이브러리인 Moya에서도 Rx와 함께 사용할 수 있는 프레임워크를 제공한다. import RxMoya를 하면 네트워트 통신 결과를 구독할 수 있다. MainViewModel의 코드를 리팩토링 하였다.

class MainViewModel {
	private let provider = MoyaProvider<PokemonAPI>()
    let pokemonList = PublishRelay<[PokemonResult]>()
    
    func fetchPokemonList() {
    	offset += 20
        
        provider.rx.request(.fetchURL(offset: offset)
        	.map(PokemonURL.self)	// decoding 코드 대체
            .subscribe(
            	onSuccess: { [weak self] response in
                    self?.pokemonList.accept(response.results)
                },
                onFailure: { error in
                    print(error.localizedDescription)
                }
            )
            .disposed(by: disposeBag)
    }
}

do-catch문으로 감싸 JSONDecoder로 디코딩 했던 코드를 .map 한줄로 대체하니 코드의 가독성이 매우 좋아졌다. 아쉬움이 남는 건 error 처리를 세심하게 하지 못하고 print에서 그친 것이다.

Delegate → RxCocoa

RxCocoaUIKit의 컴포넌트들에 대한 이벤트 처리를 할 수 있는 강력한 도구라고 한다. 처리 가능한 범위가 매우 넓은 것 같은데, 모두 다 공부하지는 못했고 이번에는 UICollectionViewDelegateRx로 대체해보았다.

before
extension MainViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let vc = DetailViewController(vm: .init(pokemons[indexPath.item].url))
        navigationController?.pushViewController(vc, animated: true)
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if scrollView.contentSize.height - scrollView.contentOffset.y == scrollView.visibleSize.height {
            vm.fetchPokemonList()
        }
    }
}
after
private func bind() {
	containerView.pokemonCollectionView.rx.itemSelected
		.subscribe(
            onNext: { [weak self] indexPath in
                guard let self = self else { return }
                let vc = DetailViewController(vm: .init(pokemons[indexPath.item].url))
                navigationController?.pushViewController(vc, animated: true)
            }
        )
        .disposed(by: disposeBag)
        
    containerView.pokemonCollectionView.rx.didEndDecelerating
        .subscribe(
            onNext: { [weak self] in
                guard let scrollView = self?.containerView.pokemonCollectionView else { return }
                if scrollView.contentSize.height - scrollView.contentOffset.y == scrollView.visibleSize.height {
                    self?.vm.fetchPokemonList()
                }
            }
        )
        .disposed(by: disposeBag)
}

사실 프로젝트 규모가 크지 않으면 delegate를 사용하는 것이 퍼포먼스, 메모리 효율 측면에서 더 낫다고 한다. 하지만 이번 과제가 Rx를 연습해보는 게 취지인 만큼(그리고 RxCocoa에서 이런 게 가능하다는 걸 처음으로 알아서 신기하기도 했고) 한번 리팩토링 해보았다.

UICollectionViewdataSource를 구성할 때도 Rx의 활용이 가능한 것 또한 알게 되었지만, 이번에 DiffableDataSource 또한 새로 공부하고 사용해 본 만큼 이 부분은 리팩토링을 진행하지 않았다.

profile
iOS Junior Developer

3개의 댓글

comment-user-thumbnail
2025년 1월 6일

로컬라이징은 사실 PokeAPI의 Pokemon species를 사용하면 간?단?하게 해결이 가능합니다

1개의 답글