[포켓몬 도감 앱 만들기] 트러블슈팅: 무한 스크롤 구현하기

황석범·2024년 12월 31일
0

내일배움캠프_iOS_5기

목록 보기
54/76

문제 상황

무한 스크롤을 구현 중 스크롤 할 때마다 버벅거리고 중복된 이미지가 보임


문제 분석

  • fetchPokemonData()가 매번 호출되어서 데이터가 중복되거나 비효율적으로 로드되는 문제일 수 있음

해결 방법

1. 중복 데이터 로딩 방지:

  • 새로운 데이터를 로드할 때, 이미 로드된 데이터와 중복되지 않도록 해야 합니다.
    pokemonListSubject에 새로운 데이터를 추가할 때, 중복되지 않도록 확인합니다.

2. 로딩 상태 관리:

  • 데이터가 로드 중일 때 추가적인 로드를 방지해야 합니다. 이를 위해 isLoading 플래그를 사용하여 로딩 상태를 관리합니다.
    스크롤 중일 때 isLoading이 true이면 더 이상 데이터를 요청하지 않도록 합니다.

문제 코드

//MainViewModel
final class MainViewModel {
    
   private let disposeBag = DisposeBag()
    let pokemonListSubject = BehaviorSubject<[Pokemon]>(value: [])
    let pokemonDetailSubject = BehaviorSubject<PokemonDetail?>(value: nil)
    let pokemonImageSubject = BehaviorSubject<UIImage?>(value: nil)
    private var currentPage = 0
    private let limit = 20
    
    func fetchPokemonData() {
        // 페이지를 증가시키면서 데이터 가져오기
        let offset = currentPage * limit
        
        guard let url = APIEndpoint.pokemonListURL(limit: limit, offset: offset) else {
            pokemonListSubject.onError(NetworkError.invalidUrl)
            return
        }
        
        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self] (pokemonResponse: PokemonResponse) in
                // 기존 데이터에 새로운 데이터를 추가
                self?.currentPage += 1
                var currentPokemonList = try! self?.pokemonListSubject.value() ?? []
                currentPokemonList.append(contentsOf: pokemonResponse.results)
                self?.pokemonListSubject.onNext(currentPokemonList)
            }, onFailure: { [weak self] error in
                self?.pokemonListSubject.onError(error)
            }).disposed(by: disposeBag)
    }
}
final class MainViewModel { 

    private let disposeBag = DisposeBag()
    let pokemonListSubject = BehaviorSubject<[Pokemon]>(value: [])
    let pokemonDetailSubject = BehaviorSubject<PokemonDetail?>(value: nil)
    let pokemonImageSubject = BehaviorSubject<UIImage?>(value: nil)
    private var currentPage = 0
    private let limit = 20
    private var isLoading = false  // 로딩 중 상태 추가
    
    func fetchPokemonData() {
        guard !isLoading else { return } // 이미 로딩 중이면 요청하지 않음
        isLoading = true
        
        let offset = currentPage * limit
        
        guard let url = APIEndpoint.pokemonListURL(limit: limit, offset: offset) else {
            pokemonListSubject.onError(NetworkError.invalidUrl)
            isLoading = false
            return
        }
        
        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self] (pokemonResponse: PokemonResponse) in
                guard let self = self else { return }
                
                do {
                    // 새로운 데이터가 로드되면 기존 데이터에 추가
                    self.currentPage += 1
                    var currentPokemonList = try self.pokemonListSubject.value()

                    // 중복된 데이터를 추가하지 않도록 필터링
                    let newPokemons = pokemonResponse.results.filter { newPokemon in
                        !currentPokemonList.contains { $0.url == newPokemon.url }
                    }
                    
                    currentPokemonList.append(contentsOf: newPokemons)
                    self.pokemonListSubject.onNext(currentPokemonList)
                    
                    self.isLoading = false
                } catch {
                    // 오류 처리
                    print("Error fetching Pokémon list: \(error)")
                    self.isLoading = false
                }
            }, onFailure: { [weak self] error in
                self?.pokemonListSubject.onError(error)
                self?.isLoading = false
            }).disposed(by: disposeBag)
    }
  • isLoading 플래그를 사용하여 이미 로딩 중인 경우 추가 데이터를 요청하지 않도록 했습니다.
  • pokemonListSubject에 데이터를 추가할 때, 기존 데이터에 중복되지 않도록 필터링을 적용했습니다.

스크롤을 내렸을 때 중복 데이터가 보이는 현상은 없어진 것을 알 수 있다. 하지만 아직 스크롤을 다시 올렸을 때 데이터가 돌아오는 속도가 너무 느린 것 같다.


문제 분석

UICollectionView에서 cell이 재사용될 때, 이전에 비동기적으로 로드된 이미지나 데이터를 덮어쓰게 되는 경우인 것 같다는 생각을 함

  • 스크롤 시 데이터가 바뀌는 이유는 셀이 재사용되는 과정에서, configure 메서드가 호출될 때마다 새로운 데이터를 로드하는 코드가 실행되고 있음.
  • 비동기적으로 데이터를 받아오는 동안 셀이 빠르게 재사용되면서, 다른 데이터를 받아서 이미지를 덮어쓰거나 의도하지 않은 업데이트가 발생할 수 있음.

해결 방법

1. 셀의 재사용 문제 해결:

  • 셀에 데이터를 설정하기 전에 해당 셀이 현재 표시되고 있는 셀인지 확인합니다.
    비동기 데이터 로딩 중에 이전 셀에 대한 이미지를 업데이트하는 문제를 방지하기 위해 indexPath를 이용한 체크를 추가합니다.

2. disposeBag 문제:

  • 각 셀에 대해 disposeBag을 셀의 인스턴스마다 별도로 관리하여, 셀이 재사용될 때마다 새롭게 disposeBag을 생성합니다.
final class MainCollectionViewCell: UICollectionViewCell {
    
    static let identifier = "MainCollectionViewCell"
    private let viewModel = MainViewModel()
    private var disposeBag = DisposeBag()
    
    override func prepareForReuse() {
        super.prepareForReuse()
        self.imageView.image = nil
        self.disposeBag = DisposeBag() // 셀이 재사용되면 새로운 disposeBag을 할당
        self.currentIndexPath = nil // indexPath 초기화
    }
    
    // 셀에 데이터 설정
    func configure(with pokemon: Pokemon) {
        
        viewModel.fetchPokemonDetail(for: pokemon.url)
        
        viewModel.pokemonDetailSubject
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] pokemonDetail in
                guard let detail = pokemonDetail else { return }

                    self?.viewModel.fetchPokemonImage(for: detail.id)
                    
                    self?.viewModel.pokemonImageSubject
                        .observe(on: MainScheduler.instance)
                        .subscribe(onNext: { image in
                            self?.imageView.image = image
                        }, onError: { error in
                            print("Error fetching image: \(error)")
                        })
                        .disposed(by: self?.disposeBag ?? DisposeBag()) //
            }, onError: { error in
                print("Error fetching Pokémon detail: \(error)")
            })
            .disposed(by: disposeBag)
    }
}
//수정한 코드 
final class MainCollectionViewCell: UICollectionViewCell {
    
    static let identifier = "MainCollectionViewCell"
    private let viewModel = MainViewModel()
    private var disposeBag = DisposeBag()
    private var currentIndexPath: IndexPath? // 현재 셀의 indexPath를 추적
   
    override func prepareForReuse() {
        super.prepareForReuse()
        self.imageView.image = nil
        self.disposeBag = DisposeBag() // 셀이 재사용되면 새로운 disposeBag을 할당
        self.currentIndexPath = nil // indexPath 초기화
    }
    
    // 셀에 데이터 설정
    func configure(with pokemon: Pokemon, indexPath: IndexPath) {
        // 현재 셀의 indexPath를 저장
        self.currentIndexPath = indexPath
        
        viewModel.fetchPokemonDetail(for: pokemon.url)
        
        viewModel.pokemonDetailSubject
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] pokemonDetail in
                guard let detail = pokemonDetail else { return }
                
                // 현재 셀의 indexPath와 일치하는지 확인
                if self?.currentIndexPath == indexPath {

                    self?.viewModel.fetchPokemonImage(for: detail.id)
                    
                    self?.viewModel.pokemonImageSubject
                        .observe(on: MainScheduler.instance)
                        .subscribe(onNext: { image in
                            self?.imageView.image = image
                        }, onError: { error in
                            print("Error fetching image: \(error)")
                        })
                        .disposed(by: self?.disposeBag ?? DisposeBag()) //
                }
            }, onError: { error in
                print("Error fetching Pokémon detail: \(error)")
            })
            .disposed(by: disposeBag)
    }
}

1. 현재 indexPath를 추적:

  • MainCollectionViewCell에서 indexPath를 직접 저장하는 currentIndexPath 변수를 추가했습니다. 비동기 작업이 완료될 때마다 해당 셀이 현재 스크롤된 셀인지 확인합니다.

2. 셀 재사용 시 indexPath 초기화:

  • 셀이 재사용될 때 prepareForReuse()에서 currentIndexPath를 초기화하여 다른 셀이 덮어쓰지 않도록 방지합니다.

3. configure 메서드:

  • indexPath를 configure 메서드에 넘겨주어, 데이터 로딩이 완료된 후 해당 indexPath의 셀만 이미지를 업데이트하도록 합니다.


여전히 이미지가 돌아오는 속도가 느리고 셀에 잘못된 이미지가 들어가 있다...

self.currentIndexPath = indexPath
        
        // 포켓몬 상세 정보를 가져옴
        viewModel.fetchPokemonDetail(for: pokemon.url)
        
        // 포켓몬 상세 정보 구독
        viewModel.pokemonDetailSubject
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] pokemonDetail in
                guard let detail = pokemonDetail else { return }
                
                // 현재 셀이 여전히 올바른 indexPath인지 확인
                guard self?.currentIndexPath == indexPath else { return }
                
                guard (self?.currentIndexPath?[1])! + 1 == detail.id else { return }
                
                // 포켓몬 이미지를 가져옴
                self?.viewModel.fetchPokemonImage(for: detail.id)
                
                // 포켓몬 이미지 구독
                self?.viewModel.pokemonImageSubject
                    .observe(on: MainScheduler.instance)
                    .subscribe(onNext: { image in
                        // 현재 셀이 여전히 올바른 indexPath인지 확인
                        guard self?.currentIndexPath == indexPath else { return }
                        
                        self?.imageView.image = image

cell의 index와 해당 cell에서 요청할 이미지 id 값이 -1이므로 currentIndexPath + 1 == detail.id 일때만 이미지를 요청하도록 수정하였다.

이제 이미지가 잘못 들어가는 경우가 없고 돌아오는 속도도 훨씬 빨라졌다.

아직도 잘못 들어간다

여기서 해결했다


profile
iOS 공부중...

0개의 댓글

관련 채용 정보