[RxSwift] - 데이터 스트림 통합으로 UICollectionView UI 문제 해결

Benedicto-H·2023년 9월 20일
0

iOS

목록 보기
9/27

Trouble-shooting 💥

문제

Upstream에서 여러 데이터를 방출하는 ViewModel의 Properties를 ViewController에서 .bind(onNext:) 로 바인딩하여 UI를 갱신하려고 할 때, UICollectionView가 예상과 다르게 깨지는 현상이 발생했다.

좌측의 View가 최종적으로 그려져야하는 결과이고, 우측의 View가 잘못 그려진 결과다.

좌측은 Cell에 데이터 바인딩을 한 결과이고, 우측은 바인딩이 안된 결과이다.


원인

private func bind() -> Void {
    
    //  NewReleases
    self.newReleasesViewModel.newReleases
        .observe(on: MainScheduler.instance)
        .bind { [weak self] newReleasesResponse in
            self?.sections.append(.newReleases(newReleases: newReleasesResponse))
            DispatchQueue.main.async {
                self?.collectionView.reloadData()
            }
        }.disposed(by: self.bag)
        
    //  FeaturedPlaylists
	self.playlistsViewModel.featuredPlaylists
		.observe(on: MainScheduler.instance)
		.bind { [weak self] featuredPlaylistsResponse in
            self?.sections.append(.featuredPlaylists(playlists: featuredPlaylistsResponse))
			DispatchQueue.main.async {
                self?.collectionView.reloadData()
            }
		}.disposed(by: self.bag)
        
	//  Recommendations
	self.tracksViewModel.recommendations
		.observe(on: MainScheduler.instance)
	    .bind { [weak self] recommendationsResponse in
            self?.sections.append(.recommendations(tracks: recommendationsResponse))
            DispatchQueue.main.async {
                self?.collectionView.reloadData()
            }
		}.disposed(by: self.bag)
}

Publisher가 방출하는 Data를 구독하는 private func bind() 이다.

bind() 함수는 각 뷰모델로부터 데이터를 관찰하고 그 값을 구독하여 bind(onNext:)를 통해 UICollectionView의 각 섹션의 데이터를 담당하는 sections 배열에 구독한 값을 추가한 뒤, collectionView를 reload() 한다.

bind()는 viewDidLoad()에서 호출이 된다.

final class NewReleasesViewModel {
    
    // MARK: - Stored-Props
    var newReleases: BehaviorSubject<NewReleases?> = .init(value: nil)
    var bag: DisposeBag = DisposeBag()
    
    // MARK: - Init
    init() { addObserver() }
    
    // MARK: - Method
    private func addObserver() -> Void {
        
        APICaller.shared.getNewReleases()
            .subscribe { [weak self] releases in
                self?.newReleases.onNext(releases)
            } onError: { error in
                self.newReleases.onError(error)
            }.disposed(by: self.bag)
    }
}

위와 같이 각 뷰모델이 가지는 프로퍼티의 값은 nil로 설정이 되고, init()을 통해, 네트워크 통신으로 데이터를 subscribe() 하는 addObserver()가 호출이 된다.

그래서 viewDidLoad()에서 호출된 bind() 메서드는 bind(onNext:)로부터 각 sections 배열에 데이터를 추가할 때, BehaviorSubject타입으로 정의된 뷰모델의 프로퍼티 값인 nil을 먼저 추가하게 된다.

이는, bind(onNext:)가 내부적으로 subscribe()가 실행되며, BehaviorSubject의 특성상 구독이 시작될 때 가장 최근 값을 즉시 방출하기 때문이다.

이후, 뷰모델의 init()에서 addObserver()로 인해 API 요청이 끝난 후, onNext(releases)를 통해 새로운 값을 방출하게 된다. 따라서 프로퍼티의 값의 변화를 감지하여 bind(onNext:)가 한 번 더 실행된다.

bind()에서 sections에 값을 추가하기 전.후의 값을 출력하였을 때, 각 dump()가 2번씩 출력됨과 동시에 sections에 처음 값으로 nil이 먼저 추가됨을 확인할 수 있다.

이후, 뷰모델의 addObserver()로 onNext(releases)를 통해 새로운 값을 방출하며 프로퍼티의 값이 변경되어 bind(onNext:)에 의해 데이터 값이 추가됨을 확인할 수 있다.

다른 뷰모델 또한 같은 방식을 적용하면, sections 배열에는 아래와 같이 nil 값들이 먼저 추가되고 API 응답 데이터 값들이 추가된다.

sections: Optional([nil, nil, nil, newReleasesData, playlistsData, recommendationsData])

이로 인해, collectionView의 각 섹션에 사용되는 데이터 값들이 bind()의 호출로 인해 초기에 sections에 추가된 nil값들 때문에 매치가 되지 않아 UI가 깨지게 되고,
아래의 UICollectionViewCompositionalLayout를 활용한 레이아웃 메서드의 default문에 의해 UI가 구성되게 된다.

private let collectionView: UICollectionView = UICollectionView(
    frame: .zero,
    collectionViewLayout: UICollectionViewCompositionalLayout(sectionProvider: { sectionIdx, _ in
    return HomeViewController.configureCollectionViewLayout(section: sectionIdx)
}))

//	... (생략) ...
    
private static func configureCollectionViewLayout(section: Int) -> NSCollectionLayoutSection {
        
    let inset: CGFloat = 1.0
        
    let supplementaryItem: [NSCollectionLayoutBoundarySupplementaryItem] = [
        NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(50)),
            elementKind: UICollectionView.elementKindSectionHeader,
            alignment: .top)
    ]
    
    switch section {
    case 0:
        /// Item
        let item: NSCollectionLayoutItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalHeight(1.0))
        )
        
        item.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
        
        /// Groups   (-> Vertical group in horizontal group)
        let verticalGroup: NSCollectionLayoutGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .absolute(350)
            ),
            subitem: item,
            count: 3)
        let horizontalGroup: NSCollectionLayoutGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.9),
                heightDimension: .absolute(350)
            ),
            subitem: verticalGroup,
            count: 1)
            
        /// Section
        let section: NSCollectionLayoutSection = NSCollectionLayoutSection(group: horizontalGroup)
        
        section.boundarySupplementaryItems = supplementaryItem
        section.orthogonalScrollingBehavior = .groupPaging
        
        return section;
            
    case 1:
       let item: NSCollectionLayoutItem = NSCollectionLayoutItem(
           layoutSize: NSCollectionLayoutSize(
                widthDimension: .absolute(200),
                heightDimension: .absolute(200))
        )
        
        item.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
        
        let verticalGroup: NSCollectionLayoutGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .absolute(200),
                heightDimension: .absolute(400)
            ),
            subitem: item,
            count: 2)
        let horizontalGroup: NSCollectionLayoutGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .absolute(200),
                heightDimension: .absolute(400)
            ),
            subitem: verticalGroup,
            count: 1)
            
        let section: NSCollectionLayoutSection = NSCollectionLayoutSection(group: horizontalGroup)
        
        section.boundarySupplementaryItems = supplementaryItem
        section.orthogonalScrollingBehavior = .continuous
        
        return section;
        
    case 2:
        let item: NSCollectionLayoutItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalHeight(1.0))
        )
        
        item.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
        
        let group: NSCollectionLayoutGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .absolute(80)
            ),
            subitem: item,
            count: 1)
            
        let section: NSCollectionLayoutSection = NSCollectionLayoutSection(group: group)
        
        section.boundarySupplementaryItems = supplementaryItem
        
        return section;
        
    default:
        let item: NSCollectionLayoutItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalHeight(1.0))
        )
        
        item.contentInsets = NSDirectionalEdgeInsets(top: inset, leading: inset, bottom: inset, trailing: inset)
        
        let group: NSCollectionLayoutGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .absolute(400)
            ),
            subitem: item,
            count: 1)
            
        let section: NSCollectionLayoutSection = NSCollectionLayoutSection(group: group)
        
        section.orthogonalScrollingBehavior = .groupPaging
        
        return section;
    }
}

해결

문제를 해결하기 위해 각 뷰모델의 Observable을 하나로 통합하는 .combineLatest() 메서드를 이용하여 데이터의 흐름을 통합하는 방식으로 해결하였다.

각각의 뷰모델의 프로퍼티를 동시에 관찰하고, 하나의 Observable로 결합하여 각각의 Observable에서 값이 변경될 때마다 최신 항목의 데이터를 포함한 튜플을 방출한다. 결합된 Observable을 bind(onNext:)로 구독하고, guard를 사용하여 데이터가 유효한지 확인한 후, sections에 값을 추가한다.

마지막으로 DispatchQueue.main.async를 이용해 collectionView.reloadData()를 호출하여 UI를 갱신한다.

private func bind() -> Void {
    
    // 각 ViewModel의 Property를 관찰하는 Observable 선언 및 초기화
    let newReleasesObservable: BehaviorSubject<NewReleasesResponse?> = self.spotifyViewModel.albums.newReleases
    let featuredPlaylistsObservable: BehaviorSubject<FeaturedPlayListsResponse?> = self.spotifyViewModel.playlists.featuredPlaylist
    let recommendationsObservable: BehaviorSubject<RecommendationsResponse?> = self.spotifyViewModel.tracks.recommendations
    
    // Observables를 결합
    Observable.combineLatest(newReleasesObservable, featuredPlaylistsObservable, recommendationsObservable)
        .observe(on: MainScheduler.instance)
        .bind { [weak self] (newReleasesResponse, featuredPlaylistsResponse, recommendationsResponse) in
            guard newReleasesResponse != nil, featuredPlaylistsResponse != nil, recommendationsResponse != nil else { return }
            
            self?.sections.append(.newReleases(newReleases: newReleasesResponse))
            self?.sections.append(.featuredPlaylists(playlists: featuredPlaylistsResponse))
            self?.sections.append(.recommendations(tracks: recommendationsResponse))
            
            DispatchQueue.main.async {
                self?.collectionView.reloadData()
            }
        }.disposed(by: self.bag)
}

Cell에 Data 주입은 안했지만, View를 그리고자하는 최종 결과와 똑같은 뷰 결과를 얻을 수 있다.

profile
 Developer

0개의 댓글

Powered by GraphCDN, the GraphQL CDN