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

# 기존 코드

39-1

UI에 이미지들이 바인딩 되기까지의 흐름을 정리해보겠습니다. 가장 먼저 포즈피드 뷰 컨트롤러에서 viewDidLoad 호출 시 포즈피드 뷰모델에 인풋 이벤트가 전달됩니다.

final class PoseFeedViewModel {

    struct Input {
        let viewDidLoadEvent: Observable<Void>
        // ...
    }

    struct Output {
        let contents = PublishRelay<[Section<PoseFeedPhotoCellViewModel>]>()
        /// ...
    }

    func transform(input: Input, disposeBag: DisposeBag) -> Output {
        let output = Output()

        /// 1. viewDidLoad 이후 초기 데이터 요청
        input.viewDidLoadEvent
            .subscribe(onNext: { [weak self] in
                self?.posefeedUseCase.fetchFeedContents()
            })
            .disposed(by: disposeBag)

        // 중략

        self.posefeedUseCase
            .feedContents
            .subscribe(onNext: {
                output.contents.accept($0)
            })
            .disposed(by: disposeBag)
    }
}

뷰모델에 인풋 데이터가 전달됨과 동시에 유스케이스 - 레파지토리를 거쳐 피드에 바인딩 할 데이터를 요청합니다. JSON 디코딩 후 구조체 인스턴스 생성 및 컬렉션뷰 셀 뷰모델을 생성합니다.

구조체 인스턴스를 생성하는 코드를 더 자세히 뜯어보도록 하겠습니다.

final class DefaultPoseFeedRepository: PoseFeedRepository {
    let networkService: NetworkService

    init(networkService: NetworkService) {
        self.networkService = networkService
    }

    func fetchFeedContents() -> Observable<[Section<PoseFeedPhotoCellViewModel>]> {
        networkService
            .requestSingle(.retrieveFilteringPoseFeed(peopleCount: peopleCount, frameCount: frameCount, filterTags: filterTags, pageNumber: pageNumber))
            .asObservable()
            .withUnretained(self)
            .flatMapLatest { (owner, filteredContents: FilteredPose) -> Observable<[Section<PoseFeedPhotoCellViewModel>]> in
                return Observable.combineLatest(
                    owner.cacheItem(for: filteredContents.filteredContents),
                    owner.cacheItem(for: filteredContents.recommendedContents)
                )
                .flatMapLatest { filterSection, recommendSection in
                    let relay = BehaviorRelay<[Section<PoseFeedPhotoCellViewModel>]>(value: [
                        Section(header: "", items: filterSection),
                        Section(header: "이런 포즈는 어때요?", items: recommendSection)
                    ])

                    return relay.asObservable()
                }
            }
    }

    private func cacheItem(for contents: [Pose]) -> Observable<[PoseFeedPhotoCellViewModel]> {
        let viewModelObservable = BehaviorRelay<[PoseFeedPhotoCellViewModel]>(value: [])

        contents.forEach { pose in
            ImageCache.default.retrieveImageInDiskCache(forKey: pose.poseInfo.imageKey) { result in
                switch result {
                case .success(let value):
                    if let image = value?.images?.first {
                        let viewModel = PoseFeedPhotoCellViewModel(image: image)
                        viewModelObservable.accept(viewModelObservable.value + [viewModel])
                    } else if let url = URL(string: pose.poseInfo.imageKey) {
                        KingfisherManager.shared.retrieveImage(with: url) { downloadResult in
                            switch downloadResult {
                            case .success(let downloaded):
                                let viewModel = PoseFeedPhotoCellViewModel(image: downloaded.image)
                                viewModelObservable.accept(viewModelObservable.value + [viewModel])
                            case .failure(let error):
                                print("error in first: ", error)
                                return
                            }
                        }
                    }
                case .failure:
                    return
                }
            }
        }

        return viewModelObservable.skip(while: { $0.count < contents.count }).asObservable()
    }
}

먼저 페이지네이션이 적용된 서버로부터 포즈 정보를 8개씩 묶어서 전달받습니다. 각 포즈에는 이미지가 저장된 S3 버킷 URL 정보가 담겨있습니다.

JSON 디코딩을 마쳐 URL 값을 불러왔다면, 8개 이미지 URL을 키값으로 하여 캐시 접근을 진행합니다. 캐시 히트인 경우 이미지 데이터를 UIImage 객체로 로드하여 뷰모델을 생성해줍니다. 캐시 미스인 경우 이미지를 다운로드합니다.

위 동작이 cacheItem함수에서 이루어지는데, 해당 함수는 클로저를 통해 캐시 접근과 관련된 비동기 작업들을 진행합니다. 중첩 클로저를 통해 이미지 다운로드 태스크가 또 다른 큐로 배치됩니다.

이 모든 작업들을 마칠 때까지 옵저버블은 뷰모델 데이터를 방출하지 않습니다. 중첩 클로저에 전달되는 파라미터를 캡처하게 되는데, Pose 모델 데이터가 구조체로 모두 값 타입이기 때문에 직접 주소를 참조하게 됩니다.

클로저 파라미터의 원본을 그대로 참조하고 있어야 변경되는 데이터를 계속해서 참조하게 됩니다. contents.forEach 함수 호출 후 스택 프레임 내에 지역변수로 할당된 pose값의 원본을 계속해서 참조하는 것입니다.

forEach 클로저 자체는 동기적으로 동작하는 코드이기 때문에 전체 비동기 태스크를 킹피셔에서 생성한 ioQueue에 할당합니다.

retrieveImageInDiskCache 클로저 호출 후 이미지를 성공적으로 불러왔다면 이미지와 함께 새로운 뷰모델 객체 생성 후 클로저를 종료합니다.

ioQueue = DispatchQueue(label: ioQueueName)

킹피셔 라이브러리의 ioQueue 코드를 보면 DispathQueue 생성자 함수 파라미터에 attribute값이 설정되어 있지 않은 것을 볼 수 있습니다. 속성값을 지정하지 않으면 자동으로 직렬 큐가 됩니다.

attributes - Apple Document
The attributes to associate with the queue. Include the concurrent attribute to create a dispatch queue that executes tasks concurrently. If you omit that attribute, the dispatch queue executes tasks serially.

태스크 실행되고 있는 큐 이름 출력하기

print(String(cString: __dispatch_queue_get_label(nil), encoding: .utf8))

`String(cString:encoding:)`은 널문자로 끝나는 데이터를 문자열로 변환해줍니다.

# 문제점

직렬 큐 기반으로 동작하게 되면서 뷰모델 skip 오퍼레이터가 적용된 옵저버블의 참조가 최종적으로 리턴되는데, 이때 모든 작업이 순차적으로 마무리 될때까지 기다리게 됩니다.

8개의 이미지가 캐시 히트 혹은 캐시 미스 이후 다운로드가 완료될 때까지 기다린다는 것인데, 로딩 인디케이터만 띄워놓는 것이지 사실상 사용자들이 비동기에 대한 이점을 경험하지 못하는 것입니다.

이를 해결하기 위해서는 이미지에 대한 메타 데이터 중 종횡비에 대한 데이터를 알아야 합니다. 각 이미지는 촬영 환경에 따라 종횡비가 천차만별이며, 이에 따라 구현하려는 핀터레스트 형태의 격자 UI 각 셀의 높이값도 달라지게 됩니다.

중학교때 배운 비례식을 적용하면 우리가 필요한 실제 값은 다음과 같습니다.

이미지 원본 가로길이 : 이미지 원본 세로 길이 = (UIScreen.main.bounds.width - 이미지 좌우 패딩값) / 2 : 불규칙한 셀 높이값

여기서 비례식 가장 마지막에 해당하는 값이 미지수로 구해야 하는 값이고 이미지 원본에 대한 종횡비를 선제적으로 알고 있어야 합니다.

컨텐츠는 서버에서 S3 버킷을 통해 관리하기 때문에 사이즈에 대한 정보는 이미 가지고 있는 상태입니다. 서버로부터 종횡비를 얻어왔다고 가정하면 컬렉션뷰 셀 뷰모델의 프로퍼티들을 완전히 다르게 구성할 수 있게 됩니다.

기존 코드는 아래와 같습니다.

class PoseFeedPhotoCellViewModel {
    let image = BehaviorRelay<UIImage?>(value: nil)
    let poseId = BehaviorRelay<Int>(value: -1)
    let bookmarkCheck = BehaviorRelay<Bool>(value: false)

    init(image: UIImage?, poseId: Int, bookmarkCheck: Bool) {
        self.image.accept(image)
        self.poseId.accept(poseId)
        self.bookmarkCheck.accept(bookmarkCheck)
    }
}

뷰모델에 이미지 옵저버블에 데이터를 먼저 방출한 뒤, 레파지토리를 빠져나온 이후에야 이미지 사이즈를 계산하여 뷰 컨트롤러에서 UI를 그릴 수 있게 됩니다.

하지만 사이즈를 미리 알고 있다면 아래와 같이 프로퍼티 구성이 달라집니다.


class PoseFeedPhotoCellViewModel {
    let image = BehaviorRelay<UIImage?>(value: nil)
    let poseId = BehaviorRelay<Int>(value: -1)
    let bookmarkCheck = BehaviorRelay<Bool>(value: false)
    let ratio = BehaviorRelay<CGSize>(value: CGSize(width: 0, height: 0))

    init(image: UIImage?, poseId: Int, bookmarkCheck: Bool) {
        self.image.accept(image)
        self.poseId.accept(poseId)
        self.bookmarkCheck.accept(bookmarkCheck)
    }
}

이미지는 킹피셔를 통해 비동기적으로 불러올 것이기 때문에 실제 UI 바인딩까지 틈이 존재합니다. 이때, 서버로부터 가져온 사이즈를 미리 셀 뷰모델에 바인딩 해주면 셀 크기를 계산하여 스켈레톤 뷰를 구성할 수 있게 됩니다.

셀 사이즈를 따로 계산해야 하는 이유는 다음 글을 (opens new window)참고해주세요.

# 구현 결과

46-1

위의 로직대로 구현을 하게 되면 예시로 삽입해둔 스켈레톤 이미지가 사이즈 기반으로 먼저 보이게 되고, 이후 비동기적으로 다운로드 완료된 이미지들이 하나씩 UI에 바인딩됩니다.

# Reference

  1. Medium - How to simulate poor network conditions on iOS Simulator and iPhone (opens new window)
  2. Apple Document - init(label:qos:attributes:autoreleaseFrequency:target:) (opens new window)