👋 들어가기 전
현재 하고있는 프로젝트는 이미지가 굉장히 중요하다.
이미즈는 크게 2가지 종류가 있을 수 있다.
첫 번째는 링크 또는 서버에서 제공해주는 이미지고 두 번째는 나의 로컬 갤러리를 통해
불러오는 이미지가 있다.
여기서 애플에서 제공해주는 PHPickerViewController를 사용하면 쉽게 앨범 이미지를
불러올 수 있지만 디자이너님의 요구는 커스텀이 가능한 뷰를 원하고 있어 직접 자체 개발을 해야했다.
현재 UI Framework가 SwiftUI로 개발되어 ScrollView와 LazyVGrid를 이용하여 어렵지 않게 UI는 구성했다.
여기서 처음했던 기술적인 도전은 사진을 가져오는 과정을 최대한 modern concurrency를 이용하여
나름 최적화와 completion Handler를 continuation으로 리팩하여 가독성을 챙기는 도전을 해봤다.
나름 잘 구현했다고 생각했는데 큰 문제가 발생했다.
내 갤러리는 많아야 100개 미만의 사진이 있었고 테스트할 때 전혀 문제가 없었는데
같이 일하시는 분께 테스트를 부탁했는데 . 6만개의 사진을 불러오니 사진이 아예 나오지 않는다.
지금부터 발생한 문제 분석과 해결과정을 흐름에 따라 정리해보자.
(문제 해결은 같이하시는 개발자 분과 함께 했고, 이 블로그를 보여주고 허락을 받게 된다면
개발자님의 깃 링크를 남견두려한다.)
당사자분께서 엠바고를 원해서 공개는 힘들 것 같다.
✊ 문제 파악
1. 이미지 매니저
가장 먼저 범인으로 의심되는 타겟은 역시 이미지를 불러오는 매니저 쪽 이다.
a. 보여지기 전에 이미 [Asset] 형태를 갖고 있는 문제
코드를 살펴보면 fetchPhotoFromResults 안에서 TaskGroup의 addTask...를 통해
각 Task로 results.objec(at: index)에 해당하는 asset을 병렬적으로 가져와 최종적으로 정렬한 후
[PHAseet]의 배열을 비동기적으로 만들어내는 기능이다.
여기서 asset 자체의 개수가 많으면 아직 UI에 보여지지 않을 불필요한 Asset까지
미리 앞에서 선행으로 패치하기 때문에 굉장히 안좋은 UX를 줄 수 있다고 의심된다.
final class ImageManager: NSObject {
...
private func fetchAllPhotos() async throws -> [PhotoItem] {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
options.includeHiddenAssets = false
let results = PHAsset.fetchAssets(with: options)
return try await fetchPhotoFromResults(by: results)
}
private func fetchPhotoFromResults(by results: PHFetchResult<PHAsset>) async throws -> [PhotoItem] {
fetchResult = results // for update
return try await withThrowingTaskGroup(of: PhotoItem?.self) { group in
let objectCount = results.count
for index in 0..<objectCount {
let asset = results.object(at: index)
let isCompleted = group.addTaskUnlessCancelled { [weak self] in
guard let self else { throw ImageFetchError.ownerCatchfailed }
let photoItem = PhotoItem(asset: asset)
return photoItem
}
}
var imageArray: [PhotoItem] = []
for try await image in group {
if let image = image {
imageArray.append(image)
}
}
return imageArray.sorted(by: { $0.asset.creationDate ?? Date() > $1.asset.creationDate ?? Date() })
}
}
}
extension ImageManager: PHPhotoLibraryChangeObserver {
func photoLibraryDidChange(_ changeInstance: PHChange) {
guard let prevResult = fetchResult else { return }
if let updates = changeInstance.changeDetails(for: prevResult) {
let updateResults = updates.fetchResultAfterChanges
Task {
await delegate?.imagesDidChange(self, results: try await fetchPhotoFromResults(by: updateResults))
}
}
}
}
b. PHImageRequestOptions
옵션의 중요성을 이번에 뼈저리게 느꼈다.
asset을 이미지로 바꿀 때 사용되는 옵션인데 이게 최적화에 정말 크게 작용했다.
static func getImageFromAsset(asset: PHAsset) async throws -> UIImage {
let cachingManager = PHCachingImageManager()
cachingManager.allowsCachingHighQualityImages = true
let imageOptions = PHImageRequestOptions()
imageOptions.deliveryMode = .highQualityFormat
imageOptions.isSynchronous = false
let size = CGSize(width: 512, height: 512)
let image: UIImage = try await withCheckedThrowingContinuation { continutation in
cachingManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: imageOptions) { image, _ in
guard let resizedImage = image else {
continutation.resume(throwing: ImageFetchError.invalidImage)
return
}
continutation.resume(returning: resizedImage)
}
}
return image
}
여기서 highQualityFormat은 처음부터 고화질 형태로 불러오게되는데, 최적화에 악영향을
많이 준다고 생각했다. 그래서 찾아보니 opportunistic옵션을 찾았다
위 옵션은 초기에는 저화질을 빠르게 보여주고 이후 고화질로 고치는 옵션이다.
그런데 위 옵션으로 바꾸니 갑자기 앱이 터져버리는 것이 아닌가 ??
정말 돌아버릴 지경이다..
무슨 이유인가
CONTINUATION안에서 resume 함수는 반드시 한번 (리턴 또는 에러 방출)만 호출되야하는데
more than once인 것을 보니 한번 이상 호출됐다는 것 같은데 옵션 하나 바꿨다고 갑자기 에러가 왜??
잠시만 바뀐 옵션이 어떻게 동작하는 거였지?? , 먼저 저화질이후 고화질로 교체.. 😂 여기 였구나..
그러면 최적화에 영항을 주는 원인은 크게 2개 정도 찾은 것 같다.
2. UI Framwork
두번 째 의심되는 범인은 SwiftUI이다.
갤러리는 UIKit으로 구현할 때 컬렉션뷰를 이용하는데 컬렉션뷰와 테이블 뷰의 큰 장점은
cell을 재활용하고 swiftUI의 LazyVGrid는 재활용 여부도 모를지 언정,
재활용 시점을 우리가 컨트롤 할 수 없다.
일단 최대한 swiftUI 안에서 해결해보자.
struct GalleryItemView: View {
@StateObject private var vm: GalleryItemViewModel
init(photoItem: PhotoItem? = nil, garment: GarmentItem? = nil) {
self._vm = StateObject(wrappedValue: GalleryItemViewModel(photoItem: photoItem, garment: garment))
}
var body: some View {
ZStack {
if let image = vm.image {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else if let garment = vm.garment {
KFImage(garment.image.toURL)
.cancelOnDisappear(true)
.placeholder({ progress in
ProgressView()
})
.resizable()
.scaledToFill()
} else {
ProgressView()
}
}
.task {
await vm.loadImage()
}
.onDisappear {
vm.cancelLoadImage()
}
}
}
task에서 이미지 불러오기를 시도하고 Disappear 될 때 로드를 취소한다.
func loadImage() async {
guard let photoItem = photoItem else { return }
task = Task<UIImage, Error> {
let image: UIImage = try await withCheckedThrowingContinuation { continutation in
cachingManager.requestImage(for: photoItem.asset, targetSize: targetSize, contentMode: .aspectFill, options: option) { image, _ in
guard let resizedImage = image else {
continutation.resume(throwing: ImageFetchError.invalidImage)
return
}
continutation.resume(returning: resizedImage)
}
}
return image
}
do {
self.image = try await task?.value
} catch {
STLogger.printError("이미지 로드 실패")
}
}
func cancelLoadImage() {
task?.cancel()
}
한번 퍼포먼스를 볼까 ??
약 2200개의 사진인데도 빠르게 스크롤 해보니 프레임 드랍과 함께 메모리도 굉장히 치솟았다.
확실히 좋은 퍼포먼스는 아닌 것 같다.
☝️Main Thread는 놔둬라.
농구를 즐겨보지는 않지만 신명호는 놔둬라는 짤은 기억이난다.
자세한 건 모르지만 신명호라는 선수는 수비를 굉장히 잘하는 선수지만, 중거리 슛이 수비만큼 잘하지
못 한다고 한다.
사진을 보면 서로 다른 감독님들인데 신명호라는 선수가
중거리 슛을 해도 마크하지말고 자유롭게 놔두라는 의미로 파악된다.
Main Thread도 마찬가지다.
위 영상에서 좋지 않은 UX를 제공하는 것은 결국 UI와 관련 없는 코드가 Main Thread에서 무거운 임무를
할당하는 바람에 주요 임무인 UI 관련 동작에 영향을 준 것 같다.
첫 번째 솔루션은 UI 업데이트만 Main Thread에서 돌아가도록 최적화를 한다.
1. 배열 형태로 저장하지마라
위 이미지 매니저의 첫번째 개선점은 앨범 데이터를 모두 [Asset] 데이터로 변환하려고 했던 것이다.
그렇기 때문에 우리는 다음과 같이 PHFetchResult<PHAsset>를 갖고 있는다.
private var cachedResult: PHFetchResult<PHAsset>?
private func assets(
collection: PHAssetCollection, mediaType: PHAssetMediaType
) -> PHFetchResult<PHAsset> {
let options = PHFetchOptions()
options.predicate = makePredicate(for: .image)
options.sortDescriptors = [
NSSortDescriptor(key: "creationDate", ascending: false)
]
return PHAsset.fetchAssets(in: collection, options: options)
}
이후 collectionView의 IndexPath와 PHFetchResult 함수의 object(at index: Int)를
이용 한다면 굳이 배열 데이터는 필요 없이 각 셀에 올바른 asset을 넘겨줄 수 있다.
2. PHImageRequestOptions 변경
이전 과정에서 opportunistic이 continuation과 함께 쓰여 앱이 강제로 종료되는 심각한
문제를 겪었다. 원인은 앞서 언급했던 resume called more than once 였다.
이 문제를 해결하기 위해 Combine을 이용한다. 즉 스트림 개념을 이용한다.
a. create operator (Any Publisher)
rx에서는 손쉽게 publisher를 정의할 수 있지만 Combine은 따로 만들어야한다.
그래서 다음과 같이 미리 정의 해둔다.
struct AnyObserver<Output, Failure: Error> {
let onNext: ((Output) -> Void)
let onError: ((Failure) -> Void)
let onComplete: (() -> Void)
}
struct Disposable {
let dispose: () -> Void
}
extension AnyPublisher {
static func create(
subscribe: @escaping (AnyObserver<Output, Failure>) -> Disposable
) -> Self {
let subject = PassthroughSubject<Output, Failure>()
var disposable: Disposable?
return
subject
.handleEvents(
receiveSubscription: { subscription in
disposable = subscribe(
AnyObserver(
onNext: { output in subject.send(output) },
onError: { failure in
subject.send(completion: .failure(failure))
},
onComplete: { subject.send(completion: .finished) }
))
}, receiveCancel: { disposable?.dispose() }
)
.eraseToAnyPublisher()
}
}
b. image 생성은 concurrent하게
이제 드디어 나온다 메인 쓰레드는 놔둬! 이미지 생성은 UI 작업과 상관이 없다.
이미지를 UIImage에 넣어줄 때 메인쓰레드를 써야 된다.
public func requestImage(asset: PHAsset) -> AnyPublisher<UIImage?, Error> {
return AnyPublisher<UIImage?, Error>.create { [weak self] observer in
guard let self else {
observer.onError(ClientError.failToRetainSelf)
observer.onComplete()
return Disposable {}
}
let id = imageManager.requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFit,
options: option
) { image, info in
if info?[PHImageResultIsDegradedKey] as? Bool == true {
observer.onNext(image)
} else {
observer.onNext(image)
observer.onComplete()
}
}
return Disposable { [weak self] in
self?.imageManager.cancelImageRequest(id)
}
}.subscribe(on: DispatchQueue.global())
.eraseToAnyPublisher()
}
위에서 정리한 create 오퍼레이터를 통해 스트림을 만든다.
이 때 주목할 점은 바로 PHImageResultIsDegradedKey이다.
공식문서 링크를 살펴보면 해상도 퀄리티를 알려준다. 값이 true면 저해상도 , false면 고해상도이다.
opportunistic옵션은 둘다 사용하기 때문에 각각 마다 image를 방출하고 고해상도를 방출했다면
publisher를 complete 한다.
이후 disposable을 통해 imageRequest를 cancel한다.
그렇다면 여기서 쓰레드 관련 코드는 어딨을까??
바로 가장 아래에 subscribe(on: ) operator다.
수신을 받기 전 구독을 생성하고 데이터 발행을 모두 공유 스레드 풀에서 즉, 메인이 아닌 다른 스레드에서
동작하게 된다.
continuation 개념을 combine의 스트림 개념을 통해 opportunistic을 적극활용하여 최적화를 진행했다.
c. image 삽입은 main thread
func configure(asset: PHAsset, thumbnail: AnyPublisher<UIImage?, Error>) {
thumbnail
.receive(on: DispatchQueue.main)
.sink { _ in
} receiveValue: { [weak self] image in
self?.thumbnailView.image = image
}.store(in: &cancellables)
}
이후 송신을 받는 cell에서는 receive(on:) operator를 이용해서 메인스레드에서 동작하게한다.
✌️cell reusing
UIKit의 reusing 기능을 이용하여 보이지 않는 이미지는 과감하게 메모리에서 내린다.
override func prepareForReuse() {
super.prepareForReuse()
self.cancellables.removeAll()
}
이렇게 이미지를 만들어주는 스트림을 할당해제하여 메모리 최적화를 진행했다.
✅ init load
구조를 개선한 후 작은 이슈가 하나 있었다.
바로 초기 이미지 가져올 때 조금 시간이 걸렸다.
이전에는 모든 asset을 배열로 바꿨기 때문에 이런 이슈는 없었지만 구조를 바꾸며 이런 이슈가 생겼다.
그런데 해결방법은 생각보다 엉뚱한 곳에서 해결됐다.
바로 PHFetchOptions의 predicate를 바꿔줬다.
이전에는 NSPredicate(format: "mediaType = image") 형태로 이미지만 가져올 꺼니깐
image만 표시해 줬는데 지금은 가져오지 않을 데이터를 알려줘는 방식으로 변경됐다.
= 가 아닌 != 형태로 변경했더니 초기 fetch 속도가 개선됐다. 이유는 정확히 모르겠지만
애플 샘플 앱에서 아이디어를 얻었던 것 같다. (이유를 정확히 알게되면 추후 내용을 추가하겠다.)
private func makePredicate(for mediaOptions: PhotoPickerMediaOptions) -> NSPredicate {
var removedMediaTypes: Set<PHAssetMediaType> = [.audio, .image, .video, .unknown]
if mediaOptions.contains(.image) {
removedMediaTypes.remove(.image)
}
if mediaOptions.contains(.video) {
removedMediaTypes.remove(.video)
}
let subPredicates = removedMediaTypes.map {
NSPredicate(format: "mediaType != \($0.rawValue)")
}
return NSCompoundPredicate(
andPredicateWithSubpredicates: subPredicates
)
}
👍리팩 결과
UX 개선은 뭐 말할 것도 없고 메모리가 좀 충격적이다.
309에서 42로 7배 개선된 것처럼 보이지만 사진이 많았을 때는 309가 아닌 GB까지 간 적도 있다.
😀 소감 및 마무리
개선이라는 것은 이런 과정을 말하는 것 같다.
문제 분석을 철저히 했고 그 개선에 대한 방법론 역시 하나하나 살펴보면 고민의 깊이가 굉장히 깊었다.
이 개선점이 추후 서비스가 출시하고 더 좋은 경험을 주는 밑거름이 되면 좋겠다.
출처
'iOS > UIKit' 카테고리의 다른 글
Priority (1) | 2024.12.15 |
---|---|
IntrinsicContentSize (1) | 2024.12.15 |
Auto Layout이란 (0) | 2024.12.15 |
특정 시기에 아이콘 자동 변경하기 (0) | 2024.10.26 |
iOS Cache (7) | 2024.10.17 |