기본 ImagePicker 는 다양한 이벤트에 대한 처리가 부족하다고 느꼈다. 다양한 이벤트라 함은 사용자가 갤러리 이미지가 아닌 "취소" 버튼을 눌렀을 경우, 이미지 형식이 맞지 않아서 load 에 실패한 경우, loading time 이 너무 오래 걸리는 경우, 여러 사진을 동시에 로딩하는 경우 등이 있다.
그래서 기본 ImagePicker 를 커스터마이징 하기로 했다. 기본 ImagePicker 를 손대지 않고 재사용 가능하도록 냅두고 싶었다. 하지만 ImagePicker 가 struct 로 구현되어 있어 상속 overriding 이 불가능하고 extension 도 기능을 수정하진 못하고 '추가' 밖에 할 수 없으므로 직접 손을 댈 수 밖에 없었다. 기본 ImagePicker 코드는 링크를 걸어 두었다.
발생할 수 있는 이슈들을 생각해 보았다.
- 취소 버튼이 눌린 경우
- 이미지 확장자 등 형식이 안 맞아 로드에 실패한 경우
- 로딩에 시간이 너무 오래 걸리는 경우
- 여러 이미지를 동시에 로드하는 경우
이런 이슈들에 대한 처리를 따로 안 해도 문제가 없다면 ImagePicker 를 그대로 가져다 쓰면 되지만 에러가 발생했을 때 사용자가 그 이유를 알고 모르고의 차이는 꽤 크다고 생각한다.
다른 코드가 많지만 핵심 부분은 사용자가 어떤 것을 눌렀을 때의 이벤트가 처리되는 func picker 부분이다.
@Binding var uiImage: UIImage?
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider else { return }
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, _ in
self.parent.uiImage = image as? UIImage
}
}
}
간단히 설명하면 먼저 ImagePicker 에서 선택된 것이 results 에 담긴다. results 가 비어있지 않고 load 가능하다면, 바인딩된 image 변수에 로드된 이미지를 담아 ImagePicker 밖의 @State 이미지를 업데이트하는 방식이다.
이제 이벤트들을 처리하기 위해 이것을 수정해 보자.
ImagePicker 는 struct 로 또 다른 하나의 View 다. 그런데 일반적으로 우리는 ImagePicker 가 아니라 ImagePicker 를 부른 밖의 외부 View 를 업데이트하길 원한다. 어떻게 할 수 있을까?
image 변수처럼 @Binding 을 이용하여 두 View 사이를 공유하는 변수로 소통할 수도 있다. 하지만 업데이트하고자 하는 변수가 많아지거나, 단순한 변수 변경이 아닌 여러 로직을 실행하고자 한다면 @Binding 은 복잡해 질 수 있다.
따라서 밖의 View 에서 실행되길 원하는 함수를 만들고 ImagePicker 에 그 함수를 전달하면서 해결할 수 있다. 이는 Reference Capture 와 클로저의 복사 특성상 가능하다.
간단히 설명하면 이렇다. 함수를 만들 때 함수 밖의 변수 사용 시 그 변수가 캡처되는데 이때 Value 가 아닌 Reference 가 캡처된다. 따라서 함수 안에서 그 변수를 수정할 수도, 수정이 반영된 그 변수를 사용할 수도 있다. 또한 함수는 일종의 클로저로 복사 시 함수의 Reference 가 복사된다. 결론적으로 ImagePicker 로 전달 받은 함수를 ImagePicker 안에서 실행시키면 ImagePicker 밖에 정의가 선언된 함수의 메모리 주소를 찾아서 실행시키게 된다. 함수 안의 변수가 Reference 캡처되어 있기 때문에 함수 안에서 변수를 수정하면 변수의 메모리 주소를 찾아가 수정하므로 업데이트가 반영된다. 이로써 ImagePicker 밖의 변수를 직접 건드릴 수 있는 것이다.
그래서 이벤트 처리 함수들을 담는 struct 인 ImagePickerHandlers 를 선언하여 handlers 인스턴스를 전달하는 방식으로 진행했다. 예를 들면 이런 식이다.
struct ImagePickerHandlers {
let cancelAction: () -> ()
let imageLoadFailAction: () -> ()
// more functions ..
}
struct ImagePicker: UIViewControllerRepresentable {
@Binding var uiImage: UIImage?
let handlers: ImagePickerHandlers
// func picker .. and so on
}
// caller View
func cancelAction() {
// your action
}
func imageLoadFailAction() {
// ex) View update
}
ImagePicker(uiImage: $uiImage, handlers: ImagePickerHandlers(cancelAction: cancelAction, imageLoadFailAction: imageLoadFailAction))
처음엔 .onTapGesture 처럼 취소 버튼 처리를 할 수 있는 부분이 내장되어 있을 것으로 기대했으나 없었다. 비슷하게 picker.dismiss 에도 completion 핸들러가 있었으나 취소 버튼 뿐만 아니라 정상적인 이미지가 선택되었을 때도 completion 함수가 불려지면서 의도한 바를 벗어났다. 그래서 찾은 방법이 results 가 비어있는 지 확인하는 것이다. 취소 버튼이 눌리면 이미지가 선택되지 않으면서 results 에 비어있는 배열 [] 이 담긴다.
guard let provider = results.first?.itemProvider else { return }
부분을 이렇게 바꾼다.
guard let provider = results.first?.itemProvider else {
self.parent.handlers.cancelAction()
return
}
basic code 에선 로드 가능하고 UIImage 로 다운 캐스팅에 성공하면 업데이트를 하는 방식이다. 이 방식의 문제는 이미지 로드를 했으나 실패하거나 다운 캐스팅에 실패해도 uiImage 에 nil 이 들어간다는 것이다. 그래서 아무것도 없는 uiImage 를 보고 사용자는 왜 이러는지 알 수가 없다.
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, _ in
self.parent.uiImage = image as? UIImage
}
}
이를 canLoad 가 아닐 때, 다운 캐스팅에 실패할 때, 로드했으나 error 일 때 모두 imageLoadFailAction 이 불리도록 했다. 사용자 입장에선 큰 에러 이유만 알면 되므로 일부러 케이스를 나누지 않았다.
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, error in
guard error == nil, let uiImage = image as? UIImage else {
self.parent.handlers.imageLoadFailAction()
return
}
// all success: canLoad && no error && can down casting
self.parent.uiImage = uiImage
return
}
} else {
self.parent.handlers.imageLoadFailAction()
return
}
웬만한 갤러리 사진들은 로드하는 데에 그리 오래 걸리지 않는다. 늦어도 약 1초 내에 작업이 완료된다. 그런데 공유 앨범에서의 수 천 장의 사진과 같은 경우는 아직 로드되지 않은 사진도 많다. 이 경우 썸네일이 안보이는데 이걸 선택하면 많으면 10초 넘게 load time 이 소요된다.
이 케이스도 ProgressView(loading View) 나 어떠한 메시지가 없다면 사용자는 지금 무슨 일이 일어나는지 모르고 다른 이미지를 선택하러 갈 것이다. 따라서 본인 기준 이미지 로딩 최대 시간을 5초로 잡았고 그 동안 ProgressView 를 돌려 로딩 중에 있음을 알렸다. 5초가 지나면 로딩에 시간이 오래걸림을 alert 하고 ProgressView 를 없앴다.
▲ ProgressView Example
5초 구현은 timer 와 onReceive 를 사용하였다.
@State var imageLoadTimeRemaining: Int = -1 // initial value
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() // every 1 second
Text("hello")
.onReceive(timer) { _ in
// count down
if imageLoadTimeRemaining > 0 {
imageLoadTimeRemaining -= 1
}
if imageLoadTimeRemaining == 0 {
imageLoadTimeRemaining = -1
// time out code
// ex) alert or hide ProgressView
}
}
View 에 onReceive 를 달지 않고 View 없이 쓰는 타이머도 있으나 간단한 onReceive 를 사용하였다. 뷰는 매 초 마다 타이머를 받아서 imageLoadTimeRemaining 을 계산한다. 5초 타이머를 시작하고자 한다면 imageLoadTimeRemaining 에 5를 대입만 하면 된다. 그리고 0이 될 때 time out 처리를 해준다.
여기까지가 로딩이 5초가 넘을 때의 View 처리인데, 중요한 건 ImagePicker 처리다. 예를 들어 로드에 7초 걸리는 이미지가 있다면 5초에 ProgressView 가 없어지긴 하지만 2초 후에 로드가 끝난 후 image 가 들어와 버린다. 5초로 제한을 한다는 의미는 5초가 초과되는 사진은 로드를 안하겠다는 것이므로 의도에 맞지 않다. 그렇다면 단순히 ImagePicker 에서 5초에 로드 작업을 중단하면 되지 않을까?
이제 ImagePicker 를 보자. 7초나 걸리는 바로 그 구간은 loadObject 부분이다. 그런데 이미지 로드 연산을 계속 하는데도 우리가 화면을 옮기고 다른 작업을 할 수 있는 이유는 이 loadObject 함수가 async(비동기) 로 구현되어 있기 때문이다. 이는 함수 정의를 찾아 보면 나와 있다. loadObject 뒤에 오는 { image, error in ~ } 는 completionHandler 이다. 그러니까 이미지 로드 작업은 비동기로 처리되다가 로드가 끝나고 나서야 비로소 completionHandler 가 실행된다는 것이다. 이것이 의미하는 바는 우리가 강제로 load 작업을 중단할 수 없다 이다. 쉽게 말해 7초 짜리 이미지가 비동기로 로드되기 때문에 5초에 "야 너 그만하고 break 해" 하지 못한다는 것이다. 그렇다고 동기로 처리하면 5초 동안 사용자는 로드가 끝나기만을 손꼽아 기다려야 한다.
우리가 궁극적으로 원하는 것은 이미지가 바뀌지 않는 것이다. 그래서 loadObject 에 걸린 시간을 재서 5초가 넘었다면 uiImage 를 변경하지 않도록 하여 이미지 업데이트를 막는 방식으로 해결했다.
let imageLoadStartTime = CFAbsoluteTimeGetCurrent() // 시작 시각
provider.loadObject(ofClass: UIImage.self) { image, error in
let imageLoadEndTime = CFAbsoluteTimeGetCurrent() // 로드 완료 시각
let totalImageLoadTime = imageLoadEndTime - imageLoadStartTime // 총 소요 시간(초 단위)
// totalImageLoadTime 에 따른 이미지 처리 ..
}
이미지 로딩에 4초, 2초가 걸리는 두 개의 이미지가 있다고 하자. 사용자가 4초 짜리 이미지를 눌렀고 로딩이 오래 걸리는 것 같아 1초 만에 2초 짜리 이미지를 눌렀다. 그럼 어떤 현상이 벌어질까?
위에서 봤듯 loadObject 는 async 하게 돌아가기 때문에 언제 끝날지 모르고, 끝나면 completion 핸들러를 호출할 뿐이다. 4초 이미지가 로드되는 중간에 2초 이미지 로드에 들어가게 되고 로드 작업을 마친 2초 이미지는 3초에 이미지가 업데이트된다. 이어서 1초 뒤인 4초에 첫 번째 이미지로드가 끝나고 2초 이미지를 덮어 쓰게 된다. 다시 이미지를 고르면 되지 이게 큰 문제냐 할 수 있지만 큰 문제가 될 수 있다.
5초라는 제한을 두지 않고 100초 짜리 이미지와 1초 짜리 이미지가 있다고 하자. 100초 로딩 사이에 1초가 작업을 끝내고 빠르게 로드되었다. 그리고 사용자는 사진이 잘 올라온 것을 보고는 다른 일을 하다가 5분 뒤에 다시 확인한다. 어떤가? 저장했던 이미지와는 다른 이미지가 있는 것을 보고 앱을 바로 삭제할 것이다.
이를 막기 위해 일종의 mutex 방식을 사용했다. 메인 쓰레드가 이미지 로드에 들어가면 block 을 걸어 다른 이미지가 로딩으로 들어가는 것을 막는 것이다. 하지만 위처럼 100초 짜리 이미지에 100초 동안 block 을 걸면 안되므로 제한 시간인 5초가 끝나면 block 을 해제하여 하나의 이미지 로딩이 5초가 넘지 않는 것을 보장할 수 있다.
struct ImagePicker: UIViewControllerRepresentable {
@Binding var uiImage: UIImage?
@Binding var imageLoadBlock: Bool
// 타이머가 포함된 View 와 공유되어야 하므로 @Binding 선언
// ..
// func picker() {
if provider.canLoadObject(ofClass: UIImage.self) {
guard self.parent.imageLoadBlock == false else { // already in loading first image
// block 되었을 때의 action
return
}
self.parent.imageLoadBlock = true // 로딩 들어가기 전 block!
provider.loadObject(ofClass: UIImage.self) { image, error in
self.parent.imageLoadBlock = false // 로딩 완료 후 block 해제
// ..
}
}
}
// View
Text("hello")
.onReceive(timer) { _ in
// count down
if imageLoadTimeRemaining > 0 {
imageLoadTimeRemaining -= 1
}
// time out
if imageLoadTimeRemaining == 0 {
imageLoadTimeRemaining = -1
imageLoadBlock = false // block 해제
// time out action
}
}
단점은 사용자가 5초를 견디지 못하고 그 안에 다른 이미지를 눌렀을 때 블락된다는 것이다. 이미지 피커를 열었다 닫는 시간까지 포함해 5초 안에 10장의 이미지를 선택했다면 첫 번째가 로드되는 동안 9장은 block 된다. 물론 첫 번째 이미지가 0.5 초 만에 끝나면 바로 다음 사진을 선택할 수 있다. 또한 제한 시간 5초는 언제든 변경할 수 있다.
핵심은 첫 번째 이미지 로드가 빨리 끝나면 빨리 끝나는 대로, 늦게 끝나도 5초를 넘지 않으면서 메인 쓰레드 하나만의 critical section(image loading section) 진입을 보장한다는 것이다.
이미지 로딩에 관한 이슈들을 처리해 보았다. 앱을 사용하다 보면 생각지도 못한 곳에서 이슈가 더 발생할 수 있기 때문에 많이 테스트 해야겠다는 생각이 든다.
잘 보고 갑니다~~!