더 많은 캐릭터들을 불러와서 화면에 로딩하는 작업을 한다.
API Call
fetchAdditionalCharacters 메서드에서 call을 하게 된다.
RMService를 이용해서 데이터를 불러온다. 그를 위해서 scrollViewDidScroll에서 guard로 nextUrlString이 nil인지 확인하고 이를 이용해 url을 만들어 fetch 메서드에 url을 넣어주자.
url을 이용하여 RMRequest를 구성하려고 보니까 초기화시키는 게 살짝 불편하다.
여기서 편한게 convenience init이다.
- 진짜 이렇게 말함 ㅎㅎ
covenience init은 예전에 github에서 살짝 정리한 글이 있다. 모른다면 참고
이제 RMRequest에서 아래의 코드를 작성해 convenience init을 실행할 것이다.
string이 들어오는데 만약 들어온 url에 baseURL이 포함되어 있지 않다면 뭔가 잘못된 것이다. 모든 릭 앤 모티 API에서는 베이스 url이 포함되어 들어오니까.
다음으로 replacingOccurrences를 사용해서 url 주소에서 baseurl과 / 문자까지 삭제해준다. 이렇게 만들어진 url은 “characters/blabla/balbal?123?asdsad” 이런 식으로 구성되어 있을 것이다.
이제 조건에 맞게 내부 문자열을 잘라주고 문자열을 RMEndpoint를 이용하여 넣어서 초기화시키자.
마지막으로 return으로 nil을 하여 그 어떤 경우에 해당하지 않으면 nil로 끝내주게 한다.
convenience init?(url: URL) {
let string = url.absoluteString
if !string.contains(Constants.baseUrl) {
return nil
}
let trimmed = string.replacingOccurrences(of: Constants.baseUrl+"/", with: "")
if trimmed.contains("/") {
let components = trimmed.components(separatedBy: "/")
if !components.isEmpty {
let endPointString = components[0]
if let rmEndPoint = RMEndpoint(rawValue: endPointString) {
self.init(endpoint: rmEndPoint)
return
}
}
} else if trimmed.contains("?") {
let components = trimmed.components(separatedBy: "?")
if !components.isEmpty {
let endPointString = components[0]
if let rmEndPoint = RMEndpoint(rawValue: endPointString) {
self.init(endpoint: rmEndPoint)
return
}
}
}
return nil
}
다음으로 fetchAdditionalCharacters을 수정해 보자.
guard로 들어온 url을 이용하여 request를 구성하자. 제대로 만들었다면 convenience init이 잘 만들어서 return 해준다. 만약 만들어지지 않았다면 뭔가 잘못된 것이므로 isLoading을 다시 false로 만들어 준다.
이제 아래에서는 execute 메서드를 이용하여 작업을 진행한다. 일단 print 해보자
- print는 이렇게 많이 박을 만큼 필요하다. 실제로 어느 단계가 계속 진행되는지 확인하고 넘어가는 게 시간을 절약해 준다고 생각한다.
public func fetchAdditionalCharacters(url: URL) {
isLoadingMoreCharacters = true
print("Fetching more characters")
guard let request = RMRequest(url: url) else {
isLoadingMoreCharacters = false
print("Failed to create reqeust")
return
}
RMService.shared.execute(request,
expecting: RMGetAllCharacterResponse.self) { result in
switch result {
case .success(let success):
print(String(describing: success))
case .failure(let failed):
print(String(describing: failed))
}
}
}
제대로 했다면 아래와 같이 엄청 많은 문자열이 출력된다. 하나하나가 다 캐릭터에 대한 정보이다.
다음으로 몇 가지 조건을 더해서 조건을 까다롭게 해 보자.
만약 셀모델이 비어있다면 위치를 판단하는 작업을 할 이유 자체가 없다. guard안에 추가해 주자.
다음으로 관용적인 표현이라는데 Timer를 이용해서 작업을 약간 지연시키는 것이다. 아마 예상하지 못하게 작동하는 걸 경계하는 거 같은데 이것은 조금 살펴봐야 할거 같다.
extension RMCharacterListViewViewModel: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldShowLoadMoreIndicator,
!isLoadingMoreCharacters,
!cellViewModels.isEmpty,
let nextUrlString = apiInfo?.next,
let url = URL(string: nextUrlString) else {
return
}
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] t in
let offset = scrollView.contentOffset.y
let totalContentHeight = scrollView.contentSize.height
let totalScrollViewFixedHeight = scrollView.frame.size.height
if offset >= (totalContentHeight - totalScrollViewFixedHeight - 120) {
self?.fetchAdditionalCharacters(url: url)
}
t.invalidate()
}
}
}
우리 API 호출에는 지금 문제가 하나 있다. 실제로 바닥에 닿으면 제한을 거는 요소가 없어 호출이 엄청나게 많이 반복이 된다는 것인데 이를 막아야 한다. 간단하게 실행하는 입장에서 isLoadingMoreCharacter의 상태를 보고 실행 여부를 판단하도록 하자. fetchAdditionalCharacters 메서드의 실행은 여러 번 되겠지만 return으로 API Call은 진행되지 않는다.
public func fetchAdditionalCharacters(url: URL) {
guard !isLoadingMoreCharacters else {
return
}
...
}
이제 실제로 받아온 정보를 기존 ViewModel에 반영해 보자. 기본적으로 덮어쓰기 과정을 하는 것인데 캐릭터 정보는 append로 처리를 해준다.
여기서 잘 보면 isLodingMoreCharacters를 성공케이스와 실패케이스 모두 false로 업데이트를 해주는 것을 볼 수 있다. 근데 이럴 거면 execute를 실행하기 전에 그냥 위에서 한 번에 처리하면 되지 않나?
- 실수로 다른 더 많은 데이터를 가져오는 경우에 대비하여 이렇게 했다. 아래에 작성하는 경우 캐릭터 그리드가 업데이트되는 것을 기다렸다가 행동할 수 있기 때문에 의도한 설계이다.
public func fetchAdditionalCharacters(url: URL) {
...
RMService.shared.execute(request,
expecting: RMGetAllCharacterResponse.self) { [weak self] result in
switch result {
case .success(let responseModel):
let moreResults = responseModel.results
let info = responseModel.info
self?.apiInfo = info
self?.characters.append(contentsOf: moreResults)
DispatchQueue.main.async {
self?.delegate?.didLoadInitialCharacters()
self?.isLoadingMoreCharacters = false
}
case .failure(let failed):
print(String(describing: failed))
self?.isLoadingMoreCharacters = false
}
}
}
새로운 캐릭터가 있다는 것을 업데이트해야 하기 때문에 새로운 델리게이트 함수가 필요하다. 이 함수는 정수를 받아서 캐릭터의 수를 이용하여 그리드를 새로 그리는 작업을 담당한다.
우리는 캐릭터에 할당할 때마다 메모리를 새로 할당할 것이고 새로운 뷰 모델을 만들 것이다.
protocol RMCharacterListViewViewModelDelegate: AnyObject {
func didLoadInitialCharacters()
func didLoadMoreCharacters(with count: Int)
func didSelectCharacter(_ character: RMCharacter)
}
fetchCharacters 메서드의 안에 didLoadMoreCharacters를 넣어서 델리게이트를 설정해 주자. 안에 숫자를 설정하기 위해서는 현재 가지고 있는 characters의 count 값을 사용하면 된다.
public func fetchCharacters() {
RMService.shared.execute(.listCharactersRequests, expecting: RMGetAllCharacterResponse.self) { [weak self] result in
switch result {
case .success(let responseModel):
...
DispatchQueue.main.async {
self?.delegate?.didLoadMoreCharacters(with: self?.characters.count ?? 0)
...
}
case .failure(let error):
...
}
}
}
여기까지 진행했다면 다시 에러가 발생한다. 눈치챘을지 모르겠지만 프로토콜을 준수하지 않아서 생긴 일로 가장 하단에서 프로토콜에 정의한 함수를 이용해서 셀을 업데이트하는 과정을 거치자.
여기서 컬렉션뷰에 내용을 업데이트하기 위해 performBatchUpdates를 사용했다.
- performBatchUpdates 메서드를 잠깐 설명하자면 beginUpdates()와 endUpdates()를 대체하는 메서드로 Section 그리고 Row를 업데이트하는 역할을 한다. iOS 11부터 등장했기 때문에 그 이전 버전이 아니라면 performBatchUpdates를 사용하는 것이 좋을 듯하다.
- 업데이트를 위해서 insert, delete, reload, move의 기능을 수행하게 된다. 이번에는 insert 작업을 수행한다.
그런데 작성하려고 보니 insertItems는 indexPath를 알아야 한다. 이는 didLoadMoreCharacters 메서드가 잘못 설계되었다는 것을 의미한다. 수정하자
func didLoadMoreCharacters(with count: Int) {
collectionView.performBatchUpdates {
self.collectionView.insertItems(at: <#T##[IndexPath]#>)
}
} // (X)
func didLoadMoreCharacters(with newIndexPath: [IndexPath]) {
collectionView.performBatchUpdates {
self.collectionView.insertItems(at: newIndexPath)
}
} // (O)
설계를 바꿨다면 프로토콜에 가서 다시 바꿔주자. 해당 프로토콜은 VM에서 정의되어 있다.
protocol RMCharacterListViewViewModelDelegate: AnyObject {
...
func didLoadMoreCharacters(with newIndexPath: [IndexPath])
...
}
이제 실제로 업데이트되는 데이터를 셀에 반영하는 걸 해보자. 이제 남은 건 구현한 performBatchUpdates에 indexPath를 계산하여 지정해 주면 된다.
먼저 우리의 함수 안에 너무 많은 물음표가 존재한다. 이를 해결하기 위해 strongSelf라는 프로퍼티를 가드로 둘러싸고 self?로 되어 있는 부분을 대체하자. 이것도 보편적인 테크닉이라고 한다.
들어갈 셀을 구경하는 건 기존에 가지고 있던 characters.count와 새로 들어올 moreResult.count의 값을 뺴줌으로서 동작한다. 여기서 시작하는 인덱스는 originalCount의 위치일 것이다. (근데 여기서는 굳이 새로 프로퍼티를 만들어서 처리했다.)
indexPath를 만들 때는 CompactMap을 사용했다.
- 컴팩트맵은 옵셔널 바인딩을 자동으로 해주는 고차함수로 nil이 아닌 값만을 return 한다.
public func fetchAdditionalCharacters(url: URL) {
...
RMService.shared.execute(request,
expecting: RMGetAllCharacterResponse.self) { [weak self] result in
guard let strongSelf = self else {
return
}
switch result {
case .success(let responseModel):
let moreResults = responseModel.results
let info = responseModel.info
strongSelf.apiInfo = info
let originalCount = strongSelf.characters.count
let newCount = moreResults.count
let total = originalCount + newCount
let startingIndex = total - newCount
let indexPathsAdd: [IndexPath] = Array(startingIndex..<(startingIndex+newCount)).compactMap {
return IndexPath(row: $0, section: 0)
}
strongSelf.characters.append(contentsOf: moreResults)
DispatchQueue.main.async {
strongSelf.delegate?.didLoadMoreCharacters(
with: indexPathsAdd)
strongSelf.isLoadingMoreCharacters = false
}
case .failure(let failed):
print(String(describing: failed))
strongSelf.isLoadingMoreCharacters = false
}
}
}
Test
문제가 하나 있다. 같은 내용이 계속 반복된다. 즉 2페이지, 3페이지로 나아가는 게 아니라 1페이지를 계속 호출해서 그리드에 업데이트하고 있는 것이다.
디버깅
print를 찍어보자
[UICollectionView] Performing reloadData as a fallback — Invalid update:
invalid number of items in section 0.
The number of items contained in an existing section after the update (120)
must be equal to the number of items contained in that section before
the update (60), plus or minus the number of items inserted or deleted from
that section (20 inserted, 0 deleted) and plus or minus the number of items
moved into or out of that section (0 moved in, 0 moved out).
일단 fallback이 일어난 상태고 그 원인은 업데이트된 items의 개수가 업데이트되기 전과 달라서 그런 거 같다. 당연한 거 아닌가? 그런데 잘 보면 우리는 한 번에 20개를 받아와야 하는데 컬렉션뷰는 실제로는 60개로 인식하고 있다. 어째서?
아프라즈는 함수가 두 번 호출되고 있다고 생각한다. 60 → 120이니까. 그런데 왜 60?
- 나는 셀 안에 출력되는 요소가 3가지이므로 아이템이 그렇게 인식되는 게 아닌가 하는 생각이 든다.
VM에서 캐릭터를 다루는 곳을 확인해 보자. 로직을 살펴보면 들어오는 캐릭터라는 객체는 업데이트가 되겠지만 다시 이 객체를 반복하여 생성하는 행위를 하고 있다.
20개의 캐릭터 → (업데이트) → 40개의 캐릭터 → 여기서 추가된 20개만 더 생성해야 하는데 이미 만들어진 기존 20개의 캐릭터까지 다시 생성 → 20 + 40(있던 20, 추가된 20) = 60이 된 것이다.
캐릭터를 추가하는 for문에 where로 캐릭터 객체가 이미 이름을 가지고 있으면 추가로 생성 못 하게 해보자
final class RMCharacterListViewViewModel: NSObject {
...
private var characters: [RMCharacter] = [] {
didSet {
for character in characters
where !cellViewModels.contains(where: { $0.characterName == character.name }){
...
}
}
}
}
crash 난다. 이번엔 뭘까
'attempt to insert item 20 into section 0, but there are
only 20 items in section 0 after the update'
print로 문제의 원인을 찾아보면 항상 똑같은 호출이 반복되고 있다는 것을 알 수 있다. 물론 위에 있는 것도 문제였지만 애초에 호출에서 같은 문자열을 보내고 있는 것이다. 이유는 뭘까?
우리가 trimmed에서 문자열을 분리할 때 “/”를 가지고 있는지에 대해서만 처리를 하고 endpoint로 넘겨줬다. 애초에 쿼리가 없으니까 기본 상태가 아닌 조회는 불가능했던 것
RMRequest의 convenience init를 수정해 보자
다음과 같이 파싱을 해주자. 만약 컴포넌트의 내용물이 2개 이상이면 queryItem이 존재한다는 뜻이다. 콤팩트 맵을 사용 하여 작업을 하는데 기본적으로 쿼리는 Value=name의 형태를 띠기 때문에 문자열 조작을 해준다. 만약 코딩테스트를 스위프트로 준비한 적이 있다면 익숙한 작업일 것이다.
마지막으로 self.init 할 때 queryItem을 넣어서 보내면 끝이다. 정상적으로 API 호출이 일어날 것이다.
convenience init?(url: URL) {
...
} else if trimmed.contains("?") {
let components = trimmed.components(separatedBy: "?")
if !components.isEmpty , components.count >= 2{
let endPointString = components[0]
let queryItemsString = components[1]
let queryItems: [URLQueryItem] = queryItemsString.components(separatedBy: "&").compactMap({
guard $0.contains("=") else {
return nil
}
let parts = $0.components(separatedBy: "=")
return URLQueryItem(name: parts[0], value: parts[1])
})
if let rmEndPoint = RMEndpoint(rawValue: endPointString) {
self.init(endpoint: rmEndPoint, queryParameters: queryItems)
return
}
}
}
return nil
}
테스트해보자
Test
attempt to insert item 38 into section 0, but there are
only 38 items in section 0 after the update'
음 뭐가 문제인지 잘 모르겠다. 원래 20개씩 업데이트되는 거라 생각했었는데 38이라는 건 18개인 거 같은데.. 원래 있는 요소보다 더 많은 걸 추가하려고 하는 것이 문제인 듯하다.
이 문제가 왜 일어날까?
print를 찍어보면 새로 생성되는 데이터는 20개가 추가되는 걸 알 수 있다. 그런데 18개만 생겼다? 이거는 우리가 character를 추가할 때, 같은 이름을 가진 녀석을 걸러주기 때문이다. 즉, API에서 보내오는 데이터에서 name은 유일한 PK가 되지 못한다. 그럼 어떻게 인식해서 처리를 해주지?
name을 키값으로 걸래 내는 것 말고 더 고도화된 설계가 필요하다. 우린 Hashable, Equatable을 사용할 것이다. hash 메서드에서 세 가지의 요소를 결합하여 해쉬를 설정해 준다. 이 모든 것이 같은 캐릭터 객체는 아마 없을 것이다. “func ==” 함수는 보기만 했지 써본 건 처음인데 Equatable은 Hashable을 구현하기 위해 필요하다.
final class RMCharacterCollectionViewCellViewModel: Hashable, Equatable {
public let characterName: String
private let characterStatus: RMCharacterStatus
private let characterImageUrl: URL?
static func == (lhs: RMCharacterCollectionViewCellViewModel, rhs: RMCharacterCollectionViewCellViewModel) -> Bool {
return lhs.hashValue == rhs.hashValue
}
func hash(into hasher: inout Hasher) {
hasher.combine(characterName)
hasher.combine(characterStatus)
hasher.combine(characterImageUrl)
}
...
}
아주 잘 나온다. 그런데 또 문제가 있다. 한번 불러오는 건 가능한데 그 다음 또 불러오는건 안 된다…
이미 너무 많은 내용을 담고 있기에 다음 강의에서 다시 진행하겠다.
끝
'Swift > Rick & Morty' 카테고리의 다른 글
[iOS] Rick&Morty - #13 Character Detail View(2) (0) | 2023.03.11 |
---|---|
[iOS] Rick&Morty - #12 Image Loader (0) | 2023.03.09 |
[iOS] Rick&Morty - #10 Pagination Indicator (0) | 2023.03.07 |
[iOS] Rick&Morty - #9 Character Detail View (0) | 2023.03.06 |
[iOS] Rick&Morty - #8 Showing Characters (0) | 2023.03.05 |