iOS 개발 기록

[UIKit]UIKit(CollectionView)+Combine 본문

UIKit

[UIKit]UIKit(CollectionView)+Combine

택꽁이 2023. 2. 25. 15:16
728x90
📄

CollectionView에 Combine 적용하기

RxSwift + RxCocoa 조합처럼 보통 Combine + SwiftUI 조합으로 많이 쓰인다고 한다.

그런데 회사에서는 UIKit을 사용중인데 Combine + UIKit 조합은 어떻게 사용하나 공부하다가 자주 사용하는 CollectionView에 적용해 데이터 변경에 자동으로 반응하는 UI를 테스트해 보았다.

테스트는 다음과 같은 단계로 진행했다.

  1. UICollectionViewDiffableDataSource를 따르는 UICollectionView를 만든다
  1. API를 요청하여 데이터를 방출하는 Combine Publisher를 만든다.
  1. Publisher를 구독하고, 데이터가 수신되면 CollectionView의 DataSource를 업데이트한다.
  1. CollectionView에 업데이트 된 데이터를 표시한다.

DiffableDataSource를 사용한 이유

  • Publisher가 방출하는 데이터의 변화에 유연하게 대응하기 용이하기 때문에 DiffableDataSource를 사용

사실 Combine이나 Diffable이나 다 공부하려고 사용했다. 공부를 위해 UI도 코드로 그렸다.

UICollectionView

CollectionView 기본 레이아웃 설정

  • 해당 프로젝트에서는 UICollectionViewDiffableDataSource의 section과 item의 타입을 설정하여 상속해 사용했다.
class MoviesCollectionViewDiffableDataSource: UICollectionViewDiffableDataSource<String?, Movies> { }

Combine Publisher

Model

  • API 로 요청한 데이터 값을 Combine Publisher로 만드는 단계
  • API는 imdb-api 라는 곳의 영화 검색 API를 사용했다. 회원가입 후 받는 API Key를 받고 아래와 같은 형태의 URL 로 호출하면 된다. URL: https://imdb-api.com/en/API/SearchMovie/<API Key>/<검색할 영화 제목 >
  • 해당 API를 호출해 받게 되는 결과값의 포맷에 맞게 Model을 작성
struct Result: Codable {
  let searchType: String
  let expression: String
  let results: [Movies]
  let errorMessage: String
}

struct Movies: Codable, Hashable {
  let id: String
  let resultType: String
  let image: String
  let title, description: String
}
  • Model을 작성할 때에 Cell에 필요한 데이터의 정보는 Hashable 프로토콜을 준수해야한다. UICollectionViewDiffableDataSource의 ItemIdentifierType이 셀을 구분할 때에 Hash값을 가지고 구분하나보다.
    UICollectionViewDiffableDataSource의 제네릭으로 들어가는 타입

API request

  • Alamofire를 사용했다. @escaping이나 async/await를 사용해도 되지만 기왕 Combine을 사용하기로 한거 Combine으로 처리했다.
var cancellable = Set<AnyCancellable>() // 메모리 해제를 위한 cansellable

func fetchMovies(of keyword: String) -> AnyPublisher<[Movies], Error> {
  // 1. url 주소 생성. 실패시 Fail로 publisher 반환 
	guard let url = URL(string: BASE_URL + API_KEY + "/" + keyword) else {
    return Fail(error: URLError(.badURL))
           .eraseToAnyPublisher()
  }
  
	// 2. completion Handler를 @escaping closure 대신 Future로 사용
  return Future() { promise in
    AF.request(url)
      .publishDecodable(type: Result.self)      // 값을 디코딩함
      .value()                                  // 디코딩된 값을 AnyPublisher<Data, AFError> 형태로 
      .sink { completion in
        switch completion{
        case .finished:
          print("fetchMovies finished")
        case .failure(let error):
          print("fetchMovies error: \(error)")
          promise(.failure(URLError(.badServerResponse)))    // 실패할 경우 에러값 publisher 반환
        }
      } receiveValue: { result in
        promise(.success(result.results))        // 성공한 경우 데이터값을 담은 publisher 반환
      }
      .store(in: &self.cancellable)              // 작업 후 메모리 해제
  }
  .eraseToAnyPublisher()                         // 최종적으로 AnyPubliser로 반환
}

Publisher를 구독하고 diffableDataSource에 업데이트

ViewModel

class ViewModel {
  var cancellables = Set<AnyCancellable>() // 메모리 해제를 위한 Cancellable
  @Published var keyword: String = ""       // 1.
  
  var diffableDataSource: MoviesCollectionViewDiffableDataSource!
  var snapshot = NSDiffableDataSourceSnapshot<String?, Movies>()
  
  init() {
    $keyword.receive(on: RunLoop.main)
    .debounce(for: .seconds(0.5), scheduler: RunLoop.main)  // 2. 
    .sink { (_) in
      self.searchMovies()
    }.store(in: &cancellables)
  }
      

  func searchMovies() {
		// APIService의 FetchMovies를 구독
    APIService.shared.fetchMovies(of: keyword)
      .sink { completion in
        switch completion {
        case .finished:
          print("ViewModel searchMovies finished")
        case .failure(let error):
          print("ViewModel searchMovies failure: \(error.localizedDescription)")
        }
      } receiveValue: { movies in
				// api를 통해 값을 받으면 해당 데이터를 diffableDataSource에 넣는다. 
        self.snapshot.deleteAllItems()
        self.snapshot.appendSections([""])
        
        if movies.isEmpty {
					// 3.
          self.diffableDataSource.apply(self.snapshot, animatingDifferences: true)
          return 
        }
        self.snapshot.appendItems(movies)     
        self.diffableDataSource.apply(self.snapshot, animatingDifferences: true)    
      }
      .store(in: &cancellables)
  }
}
  1. @Published : Combine에서 제공하는 Property wrapper로 값의 변화를 감지하고 didset에서 이벤트를 방출한다.
  1. debounce : duetime으로부터 해당 시간만큼 데이터를 받지 않으면 최근 데이터를 방출한다.

CollectionView에 데이터를 적용


@Published var keyStroke: String = "" 
var viewModel = ViewModel()

func setupObservers() {
		// 1. 
    $keyStroke
      .receive(on: RunLoop.main)
      .sink { keyword in
        print(keyword)
        self.viewModel.keyword = keyword
      }
      .store(in: &cancellables)
    
		// 2.
    viewModel.diffableDataSource = MoviesCollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, itemIdentifier) -> UICollectionViewCell? in
      guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MainCell", for: indexPath) as? MainCell else { return UICollectionViewCell() }
      
      cell.movie = itemIdentifier
      return cell
    }
  }
  1. @Published 로 선언된 keyStroke의 값이 바뀔 때마다 ViewModel로 해당 값을 할당한다. keyStroke는 UISearchBarDelegate를 통해 입력된 값을 할당 받는다. 이를 구독하여 ViewModel의 keyword에 준다.
  • ViewModel로 방출된 데이터는 ViewModel의 keyword 또한 @Published로 선언되어 있으므로 값이 변할때 debounce 시간을 설정하여 sink한다. sink가 성공적으로 이루어지면 API를 호출하며, API의 결과값을 DataSource에 저장한다.
  1. APIService로 받은 Movies의 데이터를 ViewModel로 넘겨줌

var movie: Movies! {
  didSet {
    setupData()
  }
}

private func setupData() {
  name.text = movie.title
  desc.text = movie.description
  
  let imageURL = URL(string: movie.image)
   image.kf.indicatorType = .activity
  image.kf.setImage(with: imageURL)
}
  • movie의 데이터 변화를 View에 바로 적용하기 위해 didSet에 메소드 적용
  • movie데이터를 sell 값에 할당. 영화 포스터 이미지 처리를 위해서는 KignkFisher 라이브러리를 사용했다.

결과

  • 데이터 결과의 갯수에 따라 알아서 Cell의 수를 계산해 CollectionView에 그려낸다. (애니메이션은 덤)

Reference

Mixing DiffableDataSource with UIKit and Combine Part.1 : iOS Movies Catalogue
Introducting DiffableDataSource, Combine and MVVM pattern in a simple, but realistic project Part.1
https://medium.com/dataseries/mixing-diffabledatasource-with-uikit-and-combine-part-1-ios-movies-catalogue-65cf90e37305
API for IMDb, TMDb, Wikipedia and more - IMDb-API
The IMDb API is a web service for receiving Movie, Series TV info as JSON. Supported Sites are IMDb, TMDb, Wikipedia, RottenTomatoes, Metacritic ...
https://imdb-api.com/

Uploaded by N2T