프로젝트 포즈피커 개발 이야기입니다! 궁금하시다면 놀러와주세요 😃 [포즈피커 다운받기🔗] (opens new window)

# 킹피셔 이미지 캐싱 기반의 핀터레스트 레이아웃 구현 + RxSwift

# 핀터레스트 레이아웃

34-1

핀터레스트 화면을 보면 이미지 고유의 비율값을 유지하면서 자유분방하게 레이아웃 배치를 진행하는 것을 볼 수 있다. 가로 값은 기기 자체 화면 너비를 반으로 나눈 값으로 고정하고 이 값과 이미지 고유의 가로 세로 비율을 비교하여 세로값을 동적으로 산정하는 식의 로직이다.

웹의 경우 계산된 이미지들을 하나씩 붙여넣는 방식으로 쉽게 구현이 되지만 스위프트에서는 새로 생기는 객체들을 동적으로 UI에 붙이면서 스크롤 형태로 기존의 이미지 레이아웃을 재사용하지 않으면 성능적 이슈가 발생할 가능성이 농후하다.

따라서 컬렉션 뷰로 핀터레스트 레이아웃을 구현해야만 한다.

실제 구현 결과물을 먼저 살펴보고, 구현 과정을 살펴보도록 하자.

34-2

코드 자체에 대한 작성이나 설명은 다음 링크 (opens new window)에 가장 자세히 나와있으니 참고하자.

여타 다른 블로그들이 위의 글을 원본으로 하여 정리해둔 것이 일반적인 모습이라 이 글에서는 코드의 흐름 정도만 짚어보고 이미지 캐시 처리와 엮었을때 발생하는 문제점을 중점적으로 톺아보려 한다.

# 이미지 고유사이즈 계산

UIScreen.main.bounds.width를 활용하면 기기별 너비값을 얻을 수 있다. 컬렉션뷰 좌우 패딩값과 이미지 사이 틈새값 모두 상수 형태로 고정되어 있으므로 이미지 너비값도 고정된다.

34-3

즉 위 그림을 토대로 고정된 이미지 하나의 너비값을 계산하면 (UIScreen.main.bounds.width - (imagePadding * 2) - imageSpacing) / 2의 값을 갖게 된다.

고정된 너비 상수값이 있고 이미지 고유 사이즈에 대한 비율 역시 계산할 수 있다. 이로부터 세로 길이를 도출하기 위해서는 아래와 같은 함수를 작성해볼 수 있다.

func newSizeImageWidthDownloadedResource(image: UIImage) -> UIImage {
    let targetWidth = (UIScreen.main.bounds.width - 56) / 2 // 이미지 하나의 너비
    let newSizeImage = image.resize(newWidth: targetWidth) // UIImage 리사이징 익스텐션 함수
    return newSizeImage
}

// UIImage+.swift
// UIImage 익스텐션
func resize(newWidth: CGFloat) -> UIImage {
    let scale = newWidth / self.size.width // 가로 비 계산
    let newHeight = self.size.height * scale // 비율 기준으로 새로 도출된 높이값 계산

    let size = CGSize(width: newWidth, height: newHeight)
    let render = UIGraphicsImageRenderer(size: size)
    let renderImage = render.image { context in
        self.draw(in: CGRect(origin: .zero, size: size))
    } // 정의된 가로 세로값을 기준으로 이미지 redraw
    return renderImage
}

위 과정을 거치고 나면 기본 UICollectionViewFlowLayout를 기준으로 배치를 했을때 다음과 같은 결과물이 나타난다.

# 커스텀 레이아웃 구현

iOS에서 컬렉션뷰 구성을 위해서는 UICollectionView 셀 등록과 같은 객체 자체에 대한 설정들도 필요하지만 그만큼 중요한 것이 레이아웃 객체를 전달하는 것이다. 보통의 경우 itemSize나 아이템간 간격에 대한 수치들을 전달하여 기본 UICollectionViewFlowLayout객체를 사용하지만 핀터레스트 레이아웃의 경우 완전히 커스텀을 진행한 레이아웃 객체이다.

컬렉션뷰나 테이블 뷰의 레이아웃을 커스텀하고자 할때 가장 먼저 생각해야할 것은 시스템 내부적으로 레이아웃 계산이 완전히 끝난 시점에 해당 레이아웃 수치들을 초기화하고 하나부터 열까지 배치를 직접 한다는 것이다.

이미지 고유 사이즈 비율로 리사이징을 진행한 뒤 UICollectionViewFlowLayout 기본 객체로 컬렉션뷰 레이아웃 배치를 지시하게 되면 다음과 같은 화면이 나타나게 된다.

34-4

컬렉션뷰는 만약 vertical 스크롤을 기반으로 동작한다고 가정했을때 같은 행에 위치한 뷰의 크기 차이가 날때 더 사이즈가 큰 뷰를 기준으로 중앙정렬 처리를 한다. 오른쪽 이미지의 사이즈가 더 크기 때문에 왼쪽 이미지가 오른족 이미지의 centerY값으로 자동 맞춤이 이루어진 것이다.

커스텀 레이아웃 배치를 하기 위해서는 UICollectionViewFlowLayout를 상속받는 클래스를 새롭게 정의해야 한다. 컬렉션뷰 레이아웃 클래스를 상속받은 형태이기 때문에 참조하고 있는 컬렉션뷰 객체도 얻어올 수 있다.

이때 컬렉션뷰 객체에서 제공하는 collectionView.numberOfItems(inSection: Int) 메서드를 통해 섹션별 셀 갯수를 얻을 수 있고 셀을 하나씩 순회하며 offset값을 직접 조정해주는 흐름을 갖는다.

34-5

코드 주석을 잘 참조하면 화면 구현 자체에 어려움은 없다.

# 핀터레스트 델리게이트

핀터레스트 레이아웃 코드의 가장 중요한 점은 레이아웃 구성 코드 자체도 있지만, 레퍼런스에 나와있듯 커스텀 델리게이트 객체가 정말 중요하다.

protocol PinterestLayoutDelegate: AnyObject {
    func collectionView(_ collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat
}

위의 델리게이트 메서드는 셀 순회 과정에서 indexPath를 통해 셀 컨텐츠의 높이를 반환하는 역할을 한다.

for item in 0..<collectionView.numberOfItems(inSection: 0) {
    // IndexPath 에 맞는 셀의 크기, 위치를 계산합니다.
    let indexPath = IndexPath(item: item, section: 0)
    let imageHeight = delegate?.collectionView(collectionView, heightForPhotoAtIndexPath: indexPath) ?? 180
    // .....
    // .....
}

핀터레스트 레이아웃 객체는 속성값으로 yOffSet을 갖는다. 컬럼 수 만큼 배열 원소 수를 할당받는다. 핀터레스트 레이아웃의 경우 컬럼 수가 두개이므로 초기 offset값이 1번째, 2번째 컬럼 마지막 오프셋 값이 [0, 0]으로 지정되는 것을 알 수 있다.

var yOffSet: [CGFloat] = .init(repeating: 0, count: numberOfColumns)

셀을 순회하며 높이값과 이미지 사이 패딩값의 합을 offset 컬럼 위치에 더해주는 형태로 레이아웃 배치를 진행하는 것이다.

뷰 컨트롤러에서 컬렉션뷰 각 셀 내부 컨텐츠들을 보통 배열 형태로 가지고 있을텐데, 이 데이터들을 핀터레스트 레이아웃 객체에 전달하여 각 indexPath에서 사이즈를 참조할 수 있게 되는 것이다.

예를 들어 위의 UIImage 익스텐션 함수인 resize로 생성된 이미지의 size프로퍼티를 참조하여 높이값을 배열로 추출한 뒤 핀터레스트 레이아웃에 전달하면 핀터레스트 레이아웃 객체가 배치 과정에서 내부의 값을 적절히 정리한다는 것이다.

# 킹피셔 이미지 캐싱 + RxSwift

구현 결과를 다시 살펴보자.

34-2

스크롤 과정에서 이미지들이 계속해서 로드되는 것을 볼 수 있다. 우선 이미지는 서버로부터 이미지 데이터 자체를 전달받는 것이 아닌 아마존 S3에 저장된 URL을 전달받는다. 이미지들은 URL 문자열값을 가지고 URL 객체를 생성한 뒤 비동기적으로 다운로드를 받거나(다운로드 받은 이미지는 URL을 키값으로 한 뒤 캐싱된다) URL 문자열을 키값으로 하여 캐시 저장소에서 이미지를 불러오게 된다.

이때 이미지들의 높이값을 핀터레스트 레이아웃에 전달해야 하는데 셀에 바인딩할 UIImage 객체 자체를 배열에 매번 저장하고 업데이트 하는 등의 로직을 처리하게 되면 앱 자체의 무게가 무거워지기 때문에, 이미지 사이즈값만 뷰모델 객체 속성값으로 추가하여 관리하는 방식으로 구현하였다.

페이지 당 불러올 이미지 수가 10개라고 가정했을때 페이지 전체 이미지를 불러온 이후에 RxCocoa의 컬렉션뷰 items메서드를 통해 UI 바인딩을 진행한다. 킹피셔 라이브러리를 기반으로 하여 작성한 코드는 아래와 같다.

func retrieveCacheObservable(posefeed: [PosePick]) -> Observable<[PoseFeedPhotoCellViewModel]> {
    let viewModelObservable = BehaviorRelay<[PoseFeedPhotoCellViewModel]>(value: [])

    posefeed.forEach { posepick in
        ImageCache.default.retrieveImage(forKey: posepick.poseInfo.imageKey, options: nil) { [weak self] result in
            guard let self = self else { return }
            switch result {
            case .success(let value):
                if let image = value.image {
                    let newSizeImage = self.newSizeImageWidthDownloadedResource(image: image) // 이미지 리사이징
                    self.filteredContentSizes.accept(self.filteredContentSizes.value + [newSizeImage.size]) // 사이즈 배열에 업데이트 된 이미지 사이즈 추가

                    let viewModel = PoseFeedPhotoCellViewModel(image: newSizeImage, poseId: posepick.poseInfo.poseId)
                    viewModelObservable.accept(viewModelObservable.value + [viewModel]) // 뷰모델 옵저버블 next 방출
                } else {
                    guard let url = URL(string: posepick.poseInfo.imageKey) else { return } // URL 객체 생성

                    KingfisherManager.shared.retrieveImage(with: url) { downloadResult in // 이미지 다운로드
                        switch downloadResult {
                        case .success(let downloadImage):
                            let newSizeImage = self.newSizeImageWidthDownloadedResource(image: downloadImage.image)
                            self.filteredContentSizes.accept(self.filteredContentSizes.value + [newSizeImage.size]) // 사이즈 배열에 업데이트 된 이미지 사이즈 추가

                            let viewModel = PoseFeedPhotoCellViewModel(image: newSizeImage, poseId: posepick.poseInfo.poseId)
                            viewModelObservable.accept(viewModelObservable.value + [viewModel]) // 뷰모델 옵저버블 next 방출
                        case .failure:
                            return
                        }
                    }
                }
            case .failure:
                return
            }
        }
    }
    // 이미지 다운로드 갯수만큼 skip하고 최종 방출
    return viewModelObservable.asObservable().skip(while: { $0.count < posefeed.count })
}

PosePick은 서버에서 제공해준 API를 따라 직접 작성한 모델이다. 포즈픽 모델 내에 이미지 URL 정보가 담겨있어 해당 값을 가지고 캐시로부터 이미지 로드 작업을 진행한다.

  1. ImageCache.default.retrieveImage(forKey: 이미지 URL) 메서드를 통해 캐시 저장소에서 이미지를 불러온다.
  2. retrieveImage 함수는 컴플리션 핸들러 파라미터에 콜백함수를 전달할 수 있는데, 콜백함수의 인자값으로는 Result타입의 객체가 전달된다.
  3. switch case문으로 분기처리를 하게되며 KingfisherError에 해당하지 않는 한 success 케이스 내에 value값을 가지고 다시 한번 분기처리를 진행하게 된다.
  4. value 객체 내에는 image속성이 포함되어 있는데, KFCrossPlatformImage? 타입을 갖는다. if let 바인딩으로 이미지 추출이 이루어지면 이미지 리사이징을 진행하고 새로 생성된 이미지 높이값을 뷰모델 속성값 배열에 추가한다.
  5. 이때 생성하는 모든 이미지 높이값을 뷰모델 객체의 사이즈 배열에 추가한다.
  6. 만약 이미지 if let 바인딩에 실패한 경우 이미지를 다운로드 받아야 한다. 이미지 url을 swift URL객체로 생성한 뒤 KingfisherManager.shared.retrieveImage(with: url) 메서드를 호출하고 컴플리션 핸들러에서 한번 더 switch - case 분기처리를 진행한다.

캐시에서 로드된 이미지들은 그 즉시 컬렉션뷰 셀 아이템으로 바인딩 되는 것이 아니라 초기에 API로 전달된 URL갯수만큼 이미지 로드가 끝날때까지 skip 연산자로 대기했다가 하나의 옵저버블로 최종 방출되는 방식으로 구현된다.

한 번에 방출해야 컬렉션뷰 리로딩의 횟수도 적어지고 그만큼 레이아웃 계산도 효율적으로 이루어지기 때문이다.

위의 retrieveCacheObservable 함수를 실제로 사용하는 예시 코드를 보면 다음과 같다.

input.triggerObservable
    .flatMapLatest { [unowned self] _ -> Observable<PoseFeed> in
        loadable.accept(true)
        return self.apiSession.requestSingle(.retrieveAllPoseFeed(pageNumber: self.currentPage, pageSize: 8)).asObservable()
    }
    .flatMapLatest { [unowned self] posefeed -> Observable<[PoseFeedPhotoCellViewModel]> in
        return self.retrieveCacheObservable(posefeed: posefeed)
    }
    .subscribe(onNext: {
        loadable.accept(false)
        filterSection.accept($0) // 섹션 옵저버블에 이미지데이터 방출
    })
    .disposed(by: disposeBag)
// ...

triggerObservable에 next 이벤트가 방출되었을때 flatMapLatest 오퍼레이터를 통해 이미지 캐싱 옵저버블을 리턴하는데 무한 스크롤 페이지네이션의 각 페이지별 사이즈값만큼 이미지 로드가 끝날때까지 skip하고 최종 로드가 끝났을때 다음 작업으로 넘어간다.

filterSection이라는 옵저버블에 로드가 끝난 이미지 전체를 뷰모델로 래핑하여 전달하는 식으로 현재 구현되어 있으며 데이터 전달과 함께 컬렉션뷰가 한 번에 리로딩 되며 핀터레스트 레이아웃이 구현된다.

캐시 업데이트 과정에서 만들어진 이미지 높이값 배열은 뷰모델 내에 저장되고 뷰 컨트롤러 델리게이트 구현체에서 다음과 같은 코드를 통해 뷰모델을 참조하게 된다. 아래 코드는 섹션별로 분리된 컨텐츠로 인해 작성된 것이다.

extension PoseFeedViewController: PinterestLayoutDelegate {
    func collectionView(_ collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat {
        if indexPath.section == 0 {
            return viewModel.filteredContentSizes.value[indexPath.item].height
        } else {
            return viewModel.recommendedContentsSizes.value[indexPath.item].height
        }
    }
}

prepare 메서드

retrieveCacheObservable을 통해 캐시에서 이미지 데이터 로드가 끝나면 컬렉션뷰에서 리로딩 작업이 진행되도록 로직 구현이 되어있는데 핀터레스트 레이아웃 객체는 레이아웃의 불필요한 재계산이 이루어지지 않게끔 컬렉션뷰 레이아웃 어트리뷰트 객체들을 캐싱하고 있다.

private var cache: [UICollectionViewLayoutAttributes] = []

각 셀의 레이아웃 속성들을 해당 배열에 추가한 뒤 다음 오버라이딩 함수가 내부적으로 호출되면 레이아웃 배치가 마무리된다.

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    return cache[indexPath.item]
}

위 함수는 직접 호출하는 것이 아닌 내부적으로 호출되는 함수이다. 문제는 이러한 레이아웃 캐싱 작업으로 인해 무한스크롤을 통해 추가되는 셀들에 대한 어트리뷰트 계산이나 이미지데이터 초기화 등의 상황에서 레이아웃 수치들이 꼬여버릴 수 있다는 것이다.

따라서 컬렉션뷰 내부적으로 리로딩 될 때에 prepare 메서드가 호출되는데 이때마다 기존 캐싱된 레이아웃 수치들을 초기화 하도록 코드를 새롭게 작성해줘야 한다.

override func prepare() {
    cache.removeAll() // 레이아웃 캐시 수치 전부 비우기
    contentHeight = 0 // 컬렉션뷰 컨텐츠 높이값 초기화
    // ...
    // ...
}

# 최종 정리

핀터레스트 레이아웃과 킹피셔 비동기 이미지 로드 구현을 위한 흐름을 정리하면 다음과 같다.

  1. 이미지의 너비값이 고정되어 있다면 이미지 고유 사이즈로부터 비율을 계산한 뒤 높이값을 동적으로 구한다.
  2. 계산된 높이값은 뷰모델 내에 배열로 추가한다.
  3. 핀터레스트 델리게이트 프로토콜 구현 함수 내에서 indexPath객체를 통해 이미지 높이값을 참조한다.
  4. 핀터레스트 레이아웃 델리게이트 함수 호출 시점은 컬렉션뷰 객체의 리로드 시점인데, 리로드 횟수를 최소화 하기 위해 페이지네이션 페이지 사이즈만큼 이미지 로드가 끝날때까지 옵저버블 이벤트 방출을 skip한다.

# Reference

  1. Kodeco - UICollectionView Custom Layout Tutorial: Pinterest (opens new window)