프로젝트를 진행하면서 DiffableDataSource를 사용하기 이전에 기본적인 UICollectionViewDataSource를
좀 더 잘 사용해보자는 의미에서 시작했지만 생각보다 많은 걸 얻어가는 것 같아서 기록으로 남기려합니다.
해당 내용에서 생각보다 많은 무겁다면 무겁고 가볍다면 가벼운 주제로 기록하기에 조금은 가볍게 넘어가며
에러를 해결하며 많은 자료를 찾아보았지만 무엇하나 원하는 답이 없었고.. 도움이 되었으면 좋겠습니다 🙇🏻
그럼 시작해보겠습니다. 코드가 이쁘지 않지만 해결에 중점을 두고 작성하였습니다.
Food
라는 구조체를 사용합니다.메인 | 수프 | 밑반찬 |
---|---|---|
final class Observable<T> {
typealias Listener = (T?) throws -> Void
var listener: Listener?
var value: T? {
didSet {
do {
try listener?(value)
} catch {
print(ErrorOfHomeViewModel.EmptyOfOpenAPIData)
}
}
}
init(_ value: T? = nil) {
self.value = value
}
func bind(listener: Listener?) {
self.listener = listener
}
}
위 코드를 처음 학습했을 때는 이해했다고 생각했지만 키워드에 대한 아이디어만 얻었었지 전혀 기억하지 못했었다.
프로퍼티와 메서드에 대한 이해한 내용은 아래와 같다.
이렇게 이해한 내용을 바탕으로 프로젝트에 적용하기로 하였다.
Boxing이라는 네이밍으로 아래의 Kodeco 아티클을 통해서 아이디어를 얻어 옵저버 패턴을 구현해보려고 하였다.
(아래 참고사이트의 Boxing 코드를 인용하여 프로젝트에 맞춰 변경하였습니다.)
🌐 참고사이트
iOS MVVM Tutorial: Refactoring from MVC
UICollectionViewDataSource를 구현할 때는 반드시 아래의 메서드를 구현해야한다.
[필수 메서드]
- Section당 Item의 갯수: collectionView(_:numberOfItemsInSection:)
- Cell content(즉, Item)를(을) 정의: collectionView(_:cellForItemAt:)
위 2가지 메서드에서 CellForItem
메서드에 집중해보겠습니다.
지금까지는 너무 간단하게 이해하였다.
Cell에 대한 구성요소를 업데이트할 때 쓰이는 메서드잖아?!
오만방자한 생각이었다..
위 메서드에서 API를 통해 얻어오는 데이터를 fetch하기도하고 반복문을 통해
IndexPath에 맞춰서 하나씩 매핑해서 Cell을 업데이트해줘!!
라는 멍청한 생각으로 작성한 코드는 아래와 같다.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: HomeViewCell.identifier, for: indexPath) as? HomeViewCell else {
return UICollectionViewCell()
}
homeViewModel.fetchOfData()
guard let sectionType = Section(rawValue: indexPath.section) else {
return UICollectionViewCell()
}
let viewModelFoods = homeViewModel.sectionStorage[sectionType]
viewModelFoods?.bind(listener: { foods in
guard let unwrappingFoods = foods else {
return
}
unwrappingFoods.forEach { food in
Task {
cell.configure(of: food)
}
}
})
return cell
}
말 그대로였다. indexPath를 출력하여 확인해보았을때 아래의 결과를 보였다.
[출력 결과]
- [0, 0]
- [0, 1]
- [0, 2]
- [0, 3]
- [0, 4]
해당 프로젝트에서 홈화면에 처음 표시되는 Cell은 5개의 행이었기 때문에 총 5번 호출된 것이었다.
정말 기본적인 부분을 나는 지금까지 알고있다고 생각하고 넘어갔던것이었다...
셀을 dequeueReusableCell로 재사용할 수 있게 구현해놓고 매번 매크로처럼 작성하던 코드다보니 정작 의미를 잊어버렸다는 것이었다.
해당 내용을 깨닫고 api를 통해 받아오는 homeViewModel.fetchOfData()
메서드를 해당 프로토콜 메서드 안에서
호출하면 안된다고 바로 알아차렸다.
데이터가 모두 올라오는 fetchOfData
메서드의 호출 위치는 viewDidLoad로 옮겼고 맞다고 생각되었다.
이제부터 가장 큰 고민이 들었다.
데이터 바인딩은 UI 요소들을 업데이트할 때 사용되기에 bind
메서드의 위치는 맞다고 생각하였다.
하지만 이 부분에서 indexPath는 비동기로 처리되기에 가장 마지막 위치인 [0, 4]
의 값만 받아왔고
아래와 같은 결과가 발생하였다...
머리를 또 탁치는 상황이 발생한 것이다.
indexPath의 값이 bind 메서드가 호출된 시점으로만 사용되기에 모든 indexPath의 Row값에 맞춰 업데이트 되는 것이 아니라
[0, 4]
위치에서 값이 계속 업데이트 되는 기이한 현상이 발생한 것이었다.
다시 처음으로 돌아가 프로젝트에 맞춰 구현한 Observable 클래스의 Listener
의 코드를 아래와 같이 수정해보려하였다.
typealias Listener = (T?, IndexPath?) throws -> Void
var indexPath: IndexPath?
...
var value: T? {
didSet {
do {
try listener?(value, indexPath)
} catch {
print(ErrorOfHomeViewModel.EmptyOfOpenAPIData)
}
}
}
하지만 위 코드에서 indexPath의 값을 할당하는 위치에 대한 문제점이 있었다.
1. fetchOfData의 메서드 호출 위치 변경
2. indexPath의 값을 할당할 수 있는 위치의 문제
bind 메서드를 굳이 사용하지않고 indexPath와 section의 값을 파라미터로 하는 메서드를 구현하여
food 데이터를 얻어오자!
// HomeViewModel.swift
func getFoodInfo(with section: Section, indexPath: IndexPath) async throws -> Food {
guard let foods = sectionStorage[section]?.value else {
throw ErrorOfHomeViewModel.EmptyOfOpenAPIData
}
return foods[indexPath.row]
}
// HomeViewDataSource.swift
...
Task {
let getFood = try await homeViewModel.getFoodInfo(with: sectionType, indexPath: indexPath)
cell.configure(of: getFood)
}
위 메서드를 호출하여 사용하였더니 원하던대로 indexPath에 맞춰 food 데이터를 받아올 수 있었다.
하지만 또 문제가 발생하였으니...
위 실행 결과와 같이 처음에 바로 데이터가 올라오는 것이 아니라 스크롤을 시작할 때 food 데이터를 바인딩하기 시작한 것이었다.
이제는 정말 모르겠다싶었다... indexPath의 값을 할당하는 문제까지 겹쳐있어서 어디가 문제인지 몰랐었다.
데이터를 받아오는 순간 셀을 업데이트 하고싶었고 일단 원하는 결과를 볼 수 있게 해보자하는 마음에 딜레이를 줘보기로했다.
try await Task.sleep(nanoseconds: 1_000_000_000)
위 코드를 추가하여 실행하였더니 원했던 모습이지만 무언가 이상했다... 바로 스크롤링할때마다 업데이트하는 문제였던 것이다!
혼자의 힘으로 모든걸 해결해보려고 했지만 도움을 얻어서 정확하게 해결하는 것이 옳다고 판단되었다.
동료 메이슨(Mason)과 해당 내용을 공유하여 고민하는 부분을 설명하였고 추가적인 문제점을 함께 토론해보았다.
bind의 메서드는 UI 요소들을 업데이트하기 위해 사용하는데 이 부분에
reloadData
를 사용하는게 올바른 방향일까요??
다행히 비슷한 고민을 하여 해당 내용에 대해 간단하게 결론을 내린 부분으로는
해당 bind(listener:)
메서드의 의미와는 조금 거리가 있지만 분명 그렇게하여 해결할 수 있겠지만 해당 프로젝트의 크기를
생각해보았을때는 너무 오버스펙으로 구현하고 오히려 복잡한 로직을 가져올 수 있기에 reloadData
를 bind 메서드에서 호출하는게
오히려 괜찮을 것 같다는 결론이내려졌고 빠르게 오류를 해결해보았다.
데이터가 모두 load되는 시점에 Observable한 sectionStorage가 확인되는 시점에 reload를 해보았고 해당 코드는 아래와 같다.
아래와 같이 구현하였더니 원하는 결과가 나오게 되었다.
// viewController.swift
override func viewDidLoad() {
super.viewDidLoad()
configureOfUIComponents()
configureOfSuperViewLayout()
homeCollectionViewDataSource.homeViewModel.fetchOfData()
reload()
}
func reload() {
// 결과만 빠르게 확인하기 위해 코드가 조잡해보임을 양해부탁드립니다...
let observableData = homeCollectionViewDataSource.homeViewModel.sectionStorage[.main]
observableData?.bind(listener: { _ in
Task {
self.collectionView.reloadData()
}
})
}
분명 에러를 해결하는 과정에서 많이 부족한 부분이 많았지만 기록하기 위해 작성하다보니 다소 깔끔하지 못한 부분이 많았습니다...
블로그, 공식문서, 여러 예시 자료를 많이 찾아보며 원하는 내용을 찾지 못하여 구글링 실력의 문제도 있었겠지만..
조금이나마 도움이 되었으면 좋겠습니다!
마지막으로 늦은 새벽시간에도 흔쾌히 질문을 받아주시고 함께 고민하게 해결하는데 도움을 주신 메이슨에게 감사하다는 말로
끝을 맺겠습니다!
긴 글 읽어주셔서 감사합니다! 다음에는 좀 더 알차고 잘 정리된 내용으로 돌아오겠습니다!! 🙇🏻
GitHub Link: https://github.com/JasonLee0223/iOS-SideDish