본 포스팅에서는 이전 포스팅글에서 사용하였던 PHPickerViewController에서 이미지를 선택 후, 확인을 눌렀을 때 이미지의 순서가 보장 되지 않는다는 단점을 어떻게 보완했는지 작성하고자 합니다.
PHPickerViewController의 didFinishPicking 델리게이트 구현을 어떻게 했는지 기술하면서, 궁극적으로 이미지들을 어떤식으로 처리했는지를 자세하게 기술하고자 합니다.
이전 포스팅에서 기술하였듯이, PHPickerViewController를 띄우고, 이미지 선택을 한 후, 완료 (또는 OK) 버튼을 눌렀을 때, didFinishPicking 델리게이트를 통해 처리를 해야합니다.
이때, 델리게이트 함수에서도 볼 수 있듯이, 선택한 이미지들이(정확힌 동영상, 라이브 포토 등이 포함된 Asset) PHPickerResult 타입의 배열(Array) 형태로 이루어져 있는 것을 확인할 수 있습니다.
PHPickerResult는 PHPickerViewController를 통해 이미지들을 선택했을 때의 Asset들에 대한 타입이라고 명시되어 있습니다. PHPickerResult 타입 안에는 NSItemProvider을 상속하는 itemProvider 이라는 프로퍼티가 존재합니다.
NSItemProvider 자체가 프로세스간에 데이터 혹은 파일을 전달하는 타입인데, 이는 PHPickerViewController가 전 포스팅에서 명시하였듯이, 실행하고 있는 앱과 별개로 실행하는, 즉 다른 프로세스 상에서 실행하는 뷰컨트롤러이기 때문에 결과값에 대한 실제 데이터가 itemProvider에 들어가있게 되고, 이를 PHPickerResult로 감싸서 선택한 이미지 등의 Asset을 처리하게 됩니다.
결론적으로, didFinishPicking 델리게이트를 통해 띄워진 PHPickerViewController에서 선택이 완료 되었을 때 이미지가 있으면 이를 PHPickerResult로 감싼 형태의 인스턴스 배열 형태로 리턴을 해주게 됩니다.
앞선 포스팅에서도 언급되었듯이, 이미지 등의 Asset을 선택했을 때, 선택했었던 이미지에 대한 순서가 실제 엔진(또는 서버)상에 업로드가 될 때 보장이 되어야하는데, itemProvider로 부터 UIImage 형태로 이미지를 추출하는 과정에서 앞서 선택한 순서대로 이미지가 추출되지 못한다는 점이 문제였습니다.
예를 들어서, 아래의 형태처럼 delegate 메소드를 정의하고, 선택된 이미지에 대한 정보를 출력하게끔 구현을 한다면 아래와 같이 구현할 수 있습니다.
//Objective - C
- (void)picker:(nonnull PHPickerViewController *)picker didFinishPicking:(nonnull NSArray<PHPickerResult *> *)results {
[picker dismissViewControllerAnimated: true completion: nil];
if (results.count == 0) {
return;
}
for (PHPickerResult* result in results) {
[[result itemProvider] loadObjectOfClass: [UIImage class] completionHandler:^(__kindof id<NSItemProviderReading> _Nullable object, NSError * _Nullable error) {
if ([object isKindOfClass: [UIImage class]]) {
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"Image : %@", (UIImage*)object);
});
}
}];
}
}
//Swift
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true, completion: nil)
if(results.count == 0) {
return
}
for result in results {
result.itemProvider.loadObject(ofClass: UIImage.self, completionHandler: { (object, error) in
if let image = object as? UIImage {
DispatchQueue.main.async {
print("Image: \(image)")
}
}
})
}
}
(첫번째 피킹 후 추가하기 클릭시)
(똑같은 순서로 두번째 피킹 후 추가하기 클릭시)
for result in results {
result.itemProvider.loadObject(ofClass: UIImage.self, completionHandler: { (object, error) in
if let image = object as? UIImage {
DispatchQueue.main.async {
print("Image: \(image)")
}
}
})
}
Continuations 의 정의는 공식문서에서도 명시 되어 있듯이, 아래와 같이 설명할 수 있다.
A continuation is an opaque representation of program state.
즉, 프로그램 상태에 대한 불투명한 표현
여기서 이야기하는 불투명한 표현(Opaque Representation) 의 의미는, Continuation은 프로그램의 현재의 상황을 나타내는 객체이지만, 이 객체의 세부적인 구조 또는 정보에 직접적인 접근을 할 수 없다는 것을 의미합니다.
예를 들어서, 옆에는 로봇 조수가 있는 상황에서 쿠키를 만들고 있고, 반죽에 설탕을 넣어야하는 차례가 되었습니다. 이때, 주변에 설탕이 없어서 창고에 가서 설탕을 가져와야 하는 상황입니다.
이때 로봇의 머릿속에는 "설탕을 넣기 전" 이라는 현재의 상황과, "설탕을 부어야 할 차례"를 머릿속에서 기억하고 있을 것입니다. 이때, 이 로봇이 Continuations의 객체라고 이야기 할 수 있습니다.
창고에 가서 설탕을 찾기에 성공하고 다시 주방으로 돌아온다면, Continuation은 이를 보고 "설탕 찾았음. 설탕을 부어야함" 이라고 요리하는 사람에게 알려줍니다. 하지만, 실패를 하고 주방으로 다시 돌아온다면, 삐용삐용 소리를 내며 "설탕 없음. 설탕 없음" 이라고 경고를 주게 됩니다.
다만, 요리하는 사람은 로봇이 도대체 무슨 생각을 하고 있는지 전혀 모릅니다. 로봇에게 알려달라고 이야기를 해도 로봇은 들은 체도 안합니다. 다만, 로봇은 창고에서 설탕을 가져왔는지를 확인하고 이에 따라 취하는 액션이 다르기에 이를 Continuation 이라고 이야기할 수 있습니다.
Continuations의 유형은 크게 두가지가 있습니다.
- CheckedContinuation performs runtime checks for missing or multiple resume operations.
- 런타임때마다, resume이 여러번 불리거나, 또는 아예 불리지 않는 상황을 체크 해주는 유형
- UnsafeContinuation avoids enforcing these invariants at runtime.
- 런타임때 resume을 부르는 횟수에 대한 체킹을 따로 하지 않는 유형
위에서 서술한 것처럼, CheckedContinuation은 resume의 불리는 횟수를 런타임때마다 일일이 체크하며, 결과 값에 대해 직접적으로 처리를 합니다.
반면에, UncheckedContinuation은 성공 여부에 따라서 직접 처리하는 것이 아닌, 단순히 비동기 작업을 처리하고, 이에 대한 결과를 다른(외부의) 매커니즘 (예를 들면 Delegate, Callback 등)을 활용하는 형태로 처리할 때 사용을 합니다. 이렇게 처리를 할 경우, 결과를 처리하기 위해 추가적인 동기화나 대기를 걸지 않아도 되기 때문에 오버헤드를 최소화할 수 있습니다.
또한 Continuation에서 Throwing을 통해 Error 처리를 용이하게 할 수 있습니다.
- withCheckedContinuation( ) 을 사용하면 결과값을 던져줄 때 에러 처리가 안되기 때문에 <리턴타입 , Never>로 리턴을 하게 됩니다.
- 따라서, 보통은 에러 처리를 용이하기 위해서 withCheckedThrowingContinuation( )을 활용하게 됩니다.
이제 본격적으로 PHPickerViewController의 delegate 메소드에 Continuation을 활용하여 궁극적인 목적인 선택한 사진의 순서대로 이미지를 데이터화를 구현해보고자 합니다.
//SwiftViewController.swift
//뷰 상에서 버튼을 눌렀을 때 picker 관련 설정을 하고, 이를 present 합니다.
@IBAction func didTouchedPickerButton(_ sender: Any) {
var pickerConfiguration = PHPickerConfiguration(photoLibrary: .shared())
pickerConfiguration.selectionLimit = 10
pickerConfiguration.filter = .images
pickerConfiguration.selection = .ordered
let picker = PHPickerViewController(configuration: pickerConfiguration)
picker.delegate = self
self.present(picker, animated: true)
}
//PHPickerViewController에서 Add 버튼 선택 후 처리하는 delegate
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
//picker 창 dismiss
picker.dismiss(animated: true)
//아무것도 선택하지 않았다면 아무일도 일어나지 않습니다.
if (results.isEmpty) { return }
}
for result in results {
result.itemProvider.loadObject(ofClass: UIImage.self, completionHandler: { (object, error) in
if let image = object as? UIImage {
DispatchQueue.main.async {
print("Image: \(image)")
}
}
})
}
기본적인 골자는 아래와 같습니다
우선, PHPickerResult 타입의 배열을 (1)UIImage 배열 형태로 받을 수 있게끔 비동기 함수를 하나 선언합니다.
이후, 해당 비동기 함수에서 한장씩 한장씩 이미지를 보내 UIImage로 컨버팅이 될때까지 기다릴 수 있게끔 (2) 또다른 비동기 함수를 선언합니다.
(2)번에서 만든 비동기 함수에 Continuation을 적용하여 성공적으로 이미지 컨버팅이 되면 resume(returning: ) 을 하고, 실패를 하게 되면 resume(throwing: )을 통해 구체적으로 실패 원인을 던져줍니다.**
한장씩 컨버팅을 하다가 실패가 되면, 에러를 던지고 이를 delegate에서 받아 처리를 할 수 있게끔 커스텀 에러와, 에러 핸들링 코드를 작성해줍니다.
//기존 이미지 피커의 delegate 메소드
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
//아무것도 선택하지 않았다면 리턴 처리
if (results.isEmpty) { return }
Task {
//[PHPickerResult] 형태의 results에서 UIImage 형태로 이미지를 전부 추출할 때 까지 기다리기
do {
let images = try await convertImages(results)
for image in images {
print(image)
}
}catch {
//이미지 컨버팅중 문제 발생시에 던져진 에러 핸들링
var errorMsg: String = ""
switch error as! ImagePickerError{
case .fetchError:
errorMsg = "Fetch Image Failed."
case .typeError:
errorMsg = "Type You Picked asset is not provided"
}
let alert = UIAlertController(title: "이미지 로드 실패",
message: errorMsg,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "확인", style: .cancel))
self.present(alert, animated: true)
}
}
}
//(1)번을 통해 생성된 메소드
func convertImages(_ results: [PHPickerResult]) async throws-> [UIImage] {
var images: [UIImage] = [] // 컨버팅된 이미지들을 모으기 위한 변수
for result in results {
do {
//한장씩 순차적으로 컨버팅하기 위해 await형태로 (2)번 메소드에 던지기
let image = try await fetchImage(result)
//성공적으로 이미지가 컨버팅되면 변수에 추가하기
images.append(image)
} catch {
//실패시 delegate에 에러 던지기
throw(error)
}
}
return images
}
//기존의 이미지 처리 방식에서 이미지 한장만 처리하게끔 구성
func fetchImage(_ item: PHPickerResult) async throws -> UIImage {
//이미지 처리 실패시 원활한 에러 핸들링을 위해 throwingContinuation 활용
return try await withCheckedThrowingContinuation { continuation in
let itemProvider = item.itemProvider
if (itemProvider.canLoadObject(ofClass: UIImage.self)){
//itemProvider를 통해 UIImage 타입으로 이미지 추출 시도
itemProvider.loadObject(ofClass: UIImage.self, completionHandler: { imageData, error in
if let error = error {
continuation.resume(throwing: error)
return
}
guard let image = imageData as? UIImage else {
continuation.resume(throwing: ImagePickerError.fetchError)
return
}
continuation.resume(returning: image)
})
} else {
continuation.resume(throwing: ImagePickerError.typeError)
return
}
}
}
//커스텀 에러
enum ImagePickerError: Error {
case typeError
case fetchError
}
코드를 읽어보시면 아시다시피, 기존의 delegate 방식을 사용할 땐 PHPickerResult 타입을 모아놓은 배열을 for 루프를 통해 한꺼번에 처리하게끔 되어 있지만, 위의 코드에서는 이를 한장씩 한장씩 컨버팅 할 수 있게끔 총 세단계(delegate -> convertImages -> fetchImage)를 거쳤습니다.
fetchImage 메소드에서 이미지가 컨버팅에 실패한다면 에러를 던지고, 이 에러를 받은 convertImages 메소드에서는 delegate에서 해당 에러를 처리할 수 있도록 다시 던져주게 됩니다. 이때 기존에 컨버팅이 완료된 이미지들은 보내지 않습니다.
보내지 않는 이유는, 모든 데이터가 전부 전송이 되거나, 그렇지 않다면 아예 보내지 않는게 안정적이다 라고 판단했기 때문입니다. 특히나, 일관되게 이미지를 봐야하는 전자차트 입장에서는 최대한 보수적으로 접근하기 위하여 이러한 판단을 내렸습니다.
(첫번째 피킹 후 추가하기 클릭시)
(똑같은 순서로 두번째 피킹 후 추가하기 클릭시)

본 포스팅에서는 Continuation을 활용하여 기존의 Completion Handler를 async/await 형태의 비동기로 처리할 수 있게끔 하는 것을 PHPickerViewController의 Delegate를 활용하여 구현해보았습니다.
물론, 모든 상황에서 위의 형태처럼 해결하는 것이 정답이거나 효율적이지는 않을 수 있습니다. 다만, 순서가 보장이 되어야한다 같은 특수한 상황에서는 위와 같은 형태로 커스터마이즈를 하기엔 Continuation이 제공해주는 기능이 강력하다는 것을 이번 포스팅을 통해 알 수 있었습니다.
포스팅에 있어서 부족한 점이나 피드백이 있으시다면 부담없이 남겨주시면 감사하겠습니다!!! 긴글 읽어주셔서 정말 감사합니다 :)