Skip to content

Commit 00f0280

Browse files
committed
perf: 당근마켓 스타일 페이지네이션 성능 개선
- 0.7초 인위적 지연 제거하여 즉시 데이터 로딩 - reloadData 대신 performBatchUpdates + insertItems로 자연스러운 셀 삽입 - willDisplay 기반 프리페치 트리거 (threshold: 6)로 미리 다음 페이지 로드 - Kingfisher ImagePrefetcher로 이미지 사전 캐싱 - CourseListCVC 중복 이미지 로딩 방지 및 prepareForReuse 최적화 - 첫 페이지만 로딩 인디케이터 표시 (페이지네이션은 백그라운드 로딩)
1 parent e49a040 commit 00f0280

File tree

3 files changed

+164
-59
lines changed

3 files changed

+164
-59
lines changed

Runnect-iOS/Runnect-iOS/Presentation/CourseDiscovery/Views/NativeAdCVC.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@ final class NativeAdCVC: UICollectionViewCell {
5555

5656
override func prepareForReuse() {
5757
super.prepareForReuse()
58+
nativeAdView.nativeAd = nil
5859
headlineLabel.text = nil
5960
mediaView.mediaContent = nil
6061
iconImageView.image = nil
62+
iconImageView.isHidden = true
6163
}
6264
}
6365

Runnect-iOS/Runnect-iOS/Presentation/CourseDiscovery/Views/VC/CourseDiscoveryVC.swift

Lines changed: 136 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import SnapKit
1111
import Combine
1212
import Moya
1313
import GoogleMobileAds
14+
import Kingfisher
1415

1516
protocol ScrapStateDelegate: AnyObject {
1617
func didUpdateScrapState(publicCourseId: Int, isScrapped: Bool)
@@ -38,7 +39,10 @@ final class CourseDiscoveryVC: UIViewController {
3839
private var isEnd: Bool = false
3940
private var pageNo: Int = 1
4041
private var sort = "date"
41-
private var isDataLoaded = false
42+
private var isFetchingData = false
43+
44+
/// 남은 아이템이 이 수 이하일 때 다음 페이지 프리페치 시작
45+
private let prefetchThreshold = 6
4246

4347
// MARK: - Native Ad Properties
4448

@@ -124,6 +128,7 @@ extension CourseDiscoveryVC {
124128
private func setDelegate() {
125129
mapCollectionView.delegate = self
126130
mapCollectionView.dataSource = self
131+
mapCollectionView.prefetchDataSource = self
127132
emptyView.delegate = self
128133
}
129134

@@ -211,8 +216,9 @@ extension CourseDiscoveryVC {
211216
}
212217

213218
func refresh() {
214-
print("refresh")
219+
print("refresh")
215220
pageNo = 1
221+
isFetchingData = false
216222
self.courseList = []
217223
self.getCourseData(pageNo: pageNo)
218224
}
@@ -442,6 +448,31 @@ extension CourseDiscoveryVC: UICollectionViewDelegateFlowLayout {
442448
navigationController?.pushViewController(courseDetailVC, animated: true)
443449
}
444450
}
451+
452+
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
453+
guard indexPath.section == Section.courseList else { return }
454+
455+
let totalItems = totalItemCount()
456+
let remainingItems = totalItems - indexPath.item - 1
457+
458+
// 남은 아이템이 threshold 이하이면 다음 페이지 로드
459+
if remainingItems <= prefetchThreshold {
460+
loadNextPageIfNeeded()
461+
}
462+
}
463+
464+
/// 다음 페이지 로드 조건을 확인하고 로드
465+
private func loadNextPageIfNeeded() {
466+
// 이미 로딩 중이면 무시
467+
guard !isFetchingData else { return }
468+
// 마지막 페이지면 무시
469+
guard pageNo < totalPageNum else { return }
470+
// 현재 페이지의 데이터가 다 안 왔으면 무시
471+
guard courseList.count >= pageNo * serverResponseNumber else { return }
472+
473+
pageNo += 1
474+
getCourseData(pageNo: pageNo)
475+
}
445476

446477
// 외부에서 Marathon Cell에서 받아오는 indexPath를 처리 합니다.
447478
private func setMarathonCourseSelection(at indexPath: IndexPath) {
@@ -461,35 +492,9 @@ extension CourseDiscoveryVC: UICollectionViewDelegateFlowLayout {
461492

462493
extension CourseDiscoveryVC: UIScrollViewDelegate {
463494
func scrollViewDidScroll(_ scrollView: UIScrollView) {
464-
performPagination()
465495
changeButtonStyleOnScroll()
466496
}
467497

468-
private func performPagination() {
469-
let contentOffsetY = mapCollectionView.contentOffset.y // 우리가 보는 화면
470-
let collectionViewHeight = mapCollectionView.contentSize.height // 전체 사이즈
471-
let paginationY = mapCollectionView.bounds.size.height // 유저 화면의 가장 아래 y축 이라고 생각
472-
473-
/// 페이지네이션 중단 코드
474-
if contentOffsetY > collectionViewHeight - paginationY {
475-
if courseList.count < pageNo * serverResponseNumber {
476-
// 페이지 끝에 도달하면 현재 페이지에 더 이상 데이터가 없음을 의미
477-
// 새로온 데이터의 갯수가 원래 서버에서 응답에서 온 갯수보다 작으면 페이지네이션 금지
478-
return
479-
}
480-
481-
if pageNo < totalPageNum {
482-
if !isDataLoaded {
483-
isDataLoaded = true
484-
print("🫠\(pageNo)")
485-
self.pageNo += 1
486-
getCourseData(pageNo: pageNo)
487-
isDataLoaded = false
488-
}
489-
}
490-
}
491-
}
492-
493498
private func changeButtonStyleOnScroll() {
494499
let contentOffsetY = mapCollectionView.contentOffset.y
495500
let scrollThreshold = mapCollectionView.bounds.size.height * 0.1 // 10% 스크롤 했으면 UI 변경
@@ -519,6 +524,36 @@ extension CourseDiscoveryVC: UIScrollViewDelegate {
519524
}
520525
}
521526

527+
// MARK: - UICollectionViewDataSourcePrefetching
528+
529+
extension CourseDiscoveryVC: UICollectionViewDataSourcePrefetching {
530+
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
531+
let imageURLs = indexPaths.compactMap { indexPath -> URL? in
532+
guard indexPath.section == Section.courseList else { return nil }
533+
guard !isAdPosition(at: indexPath.item) else { return nil }
534+
let realIndex = courseIndex(for: indexPath.item)
535+
guard realIndex < courseList.count else { return nil }
536+
return URL(string: courseList[realIndex].image)
537+
}
538+
539+
guard !imageURLs.isEmpty else { return }
540+
ImagePrefetcher(urls: imageURLs).start()
541+
}
542+
543+
func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
544+
let imageURLs = indexPaths.compactMap { indexPath -> URL? in
545+
guard indexPath.section == Section.courseList else { return nil }
546+
guard !isAdPosition(at: indexPath.item) else { return nil }
547+
let realIndex = courseIndex(for: indexPath.item)
548+
guard realIndex < courseList.count else { return nil }
549+
return URL(string: courseList[realIndex].image)
550+
}
551+
552+
guard !imageURLs.isEmpty else { return }
553+
ImagePrefetcher(urls: imageURLs).stop()
554+
}
555+
}
556+
522557
// MARK: - CourseListCVCDelegate
523558

524559
extension CourseDiscoveryVC: CourseListCVCDelegate {
@@ -607,41 +642,86 @@ extension CourseDiscoveryVC: GADAdLoaderDelegate, GADNativeAdLoaderDelegate {
607642

608643
extension CourseDiscoveryVC {
609644
private func getCourseData(pageNo: Int) {
610-
LoadingIndicator.showLoading() // 항상 0.7초 늦게 로딩이 되어 버림 0.5초를 넣은 이유는 pagination을 구현할때 한번에 다 받아오지 않게 하기 위함
611-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [self] in
612-
publicCourseProvider.request(.getCourseData(pageNo: pageNo, sort: sort)) { response in
645+
isFetchingData = true
646+
647+
// 첫 페이지 로드 시에만 로딩 인디케이터 표시 (페이지네이션은 백그라운드 로딩)
648+
let isFirstPage = (pageNo == 1)
649+
if isFirstPage {
650+
LoadingIndicator.showLoading()
651+
}
652+
653+
publicCourseProvider.request(.getCourseData(pageNo: pageNo, sort: sort)) { [weak self] response in
654+
guard let self = self else { return }
655+
656+
if isFirstPage {
613657
LoadingIndicator.hideLoading()
614-
print("‼️ sort= \(self.sort) ‼️\n")
615-
switch response {
616-
case .success(let result):
617-
let status = result.statusCode
618-
if 200..<300 ~= status {
619-
do {
620-
let responseDto = try result.map(BaseResponse<PickedMapListResponseDto>.self)
621-
guard let data = responseDto.data else { return }
622-
623-
guard let totalPageNum = data.totalPageSize, let isEnd = data.isEnd else { return }
624-
self.totalPageNum = totalPageNum
625-
self.isEnd = isEnd
626-
627-
self.courseList.append(contentsOf: data.publicCourses)
658+
}
659+
self.isFetchingData = false
660+
661+
switch response {
662+
case .success(let result):
663+
let status = result.statusCode
664+
if 200..<300 ~= status {
665+
do {
666+
let responseDto = try result.map(BaseResponse<PickedMapListResponseDto>.self)
667+
guard let data = responseDto.data else { return }
668+
669+
guard let totalPageNum = data.totalPageSize, let isEnd = data.isEnd else { return }
670+
self.totalPageNum = totalPageNum
671+
self.isEnd = isEnd
672+
673+
let newCourses = data.publicCourses
674+
675+
if isFirstPage {
676+
// 첫 페이지(초기 로드 또는 정렬 변경): reloadData 사용
677+
self.courseList = newCourses
628678
self.mapCollectionView.reloadData()
629-
print("pageNo= \(pageNo), isEnd= \(self.isEnd), totalPageNum= \(self.totalPageNum)")
630-
} catch {
631-
print(error.localizedDescription)
679+
self.emptyView.isHidden = !newCourses.isEmpty
680+
} else {
681+
// 페이지네이션: performBatchUpdates + insertItems 사용
682+
self.insertNewCourses(newCourses)
632683
}
684+
685+
print("pageNo= \(pageNo), isEnd= \(self.isEnd), totalPageNum= \(self.totalPageNum)")
686+
} catch {
687+
print(error.localizedDescription)
633688
}
634-
if status >= 400 {
635-
print("400 error")
636-
self.showNetworkFailureToast()
637-
}
638-
case .failure(let error):
639-
print(error.localizedDescription)
689+
}
690+
if status >= 400 {
691+
print("400 error")
640692
self.showNetworkFailureToast()
641693
}
694+
case .failure(let error):
695+
print(error.localizedDescription)
696+
self.showNetworkFailureToast()
642697
}
643698
}
644699
}
700+
701+
/// 새 코스를 기존 리스트에 추가하면서 광고 셀도 함께 삽입하는 incremental update
702+
private func insertNewCourses(_ newCourses: [PublicCourse]) {
703+
guard !newCourses.isEmpty else { return }
704+
705+
// 삽입 전 상태 (광고 포함 전체 아이템 수)
706+
let oldTotalItemCount = totalItemCount()
707+
708+
// courseList에 새 데이터 추가
709+
courseList.append(contentsOf: newCourses)
710+
711+
// 삽입 후 상태 (광고 포함 전체 아이템 수)
712+
let newTotalItemCount = totalItemCount()
713+
714+
// 새로 삽입할 IndexPath 계산 (코스 셀 + 광고 셀 모두 포함)
715+
let insertedIndexPaths = (oldTotalItemCount..<newTotalItemCount).map {
716+
IndexPath(item: $0, section: Section.courseList)
717+
}
718+
719+
guard !insertedIndexPaths.isEmpty else { return }
720+
721+
mapCollectionView.performBatchUpdates({
722+
self.mapCollectionView.insertItems(at: insertedIndexPaths)
723+
}, completion: nil)
724+
}
645725

646726
private func scrapCourse(publicCourseId: Int, scrapTF: Bool) {
647727
LoadingIndicator.showLoading()
@@ -676,9 +756,8 @@ extension CourseDiscoveryVC: ListEmptyViewDelegate {
676756

677757
extension CourseDiscoveryVC: TitleCollectionViewCellDelegate {
678758
func didTapSortButton(ordering: String) {
679-
// 기존의 getCourseData 함수 호출을 getSortedCourseData로 변경
680759
pageNo = 1
681-
print("‼️\(ordering)‼️ 터치 하셨습니다. 0.7초 후에 ‼️\(ordering)‼️ 으로 정렬이 되는 데이터가 불러 옵니다.")
760+
isFetchingData = false
682761
sort = ordering
683762
self.courseList.removeAll()
684763
getCourseData(pageNo: pageNo)
@@ -693,3 +772,4 @@ extension CourseDiscoveryVC: TitleCollectionViewCellDelegate {
693772
}
694773
}
695774
}
775+

Runnect-iOS/Runnect-iOS/Presentation/CourseStorage/Views/CVC/CourseListCVC.swift

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import UIKit
99

10+
import Kingfisher
1011
import SnapKit
1112
import Then
1213

@@ -34,10 +35,11 @@ public enum CourseListCVCType {
3435
final class CourseListCVC: UICollectionViewCell {
3536

3637
// MARK: - Properties
37-
38+
3839
weak var delegate: CourseListCVCDelegate?
39-
40+
4041
private var indexPath: Int?
42+
private var currentImageURL: String?
4143

4244
// MARK: - UI Components
4345

@@ -102,6 +104,20 @@ final class CourseListCVC: UICollectionViewCell {
102104
required init?(coder: NSCoder) {
103105
fatalError("init(coder:) has not been implemented")
104106
}
107+
108+
override func prepareForReuse() {
109+
super.prepareForReuse()
110+
courseImageView.kf.cancelDownloadTask()
111+
courseImageView.image = nil
112+
titleLabel.text = nil
113+
locationLabel.text = nil
114+
likeButton.isSelected = false
115+
selectIndicatorButton.isSelected = false
116+
imageCoverView.isHidden = true
117+
courseImageView.layer.borderColor = UIColor(hex: "EAEAEA").cgColor
118+
indexPath = nil
119+
currentImageURL = nil
120+
}
105121
}
106122

107123
// MARK: - Methods
@@ -112,9 +128,16 @@ extension CourseListCVC {
112128
}
113129

114130
func setData(imageURL: String, title: String, location: String?, didLike: Bool?, indexPath: Int? = nil, isEditMode: Bool = false) {
115-
self.courseImageView.setImage(with: imageURL)
116131
self.titleLabel.text = title
117132
self.indexPath = indexPath
133+
134+
// 동일한 URL이면 이미지 재로딩을 건너뛰어 깜빡임 방지
135+
if currentImageURL != imageURL {
136+
currentImageURL = imageURL
137+
courseImageView.kf.cancelDownloadTask()
138+
self.courseImageView.setImage(with: imageURL)
139+
}
140+
118141
if let location = location {
119142
self.locationLabel.text = location
120143
}

0 commit comments

Comments
 (0)