[iOS] Custom ImagePicker를 위한 여정(feat.스유)

Youth·2024년 5월 5일
2

TIL

목록 보기
19/21

안녕하세요 킴스캐슬입니다~
오늘은 뭔가 개념을 포스팅한다기보다는 제가 프로젝트를 진행하면서 겪은 문제를 해결하는 과정을 한번 담아보려합니다 ㅎㅎ... 결론적으로 문제해결과정을 담은 글이되겠네요

우선 제가 처음에 구현하려했던 기능은 이러했습니다

  1. iphone 내부에 있는 앨범에 접근한다
  2. 앨범에 있는 사진을 누를때마다 각 사진이 몇번째 사진인지를 UI로 보여줘야한다
  3. 앨범이미지중 n개의 이미지를 선택해야만 추가버튼이 활성화된다
  4. 추가버튼을 누르면 추가된 사진이 UI로 보여져야한다

사실 이 기능명세를 처음 봤을때는 iOS에 기본 구현되어있는 image picker를 쓰면되겠네~라고 생각했지만 막상 기본 image picker를 써보니 1번과 2번기능은 구현이 가능했는데 3번기능을 사용할수가 없었습니다(4번은 가능했습니다)

애플의 기본 Photo picker를 사용한다면 최대 몇장을 고를수있는지는 설정이 가능하지만 몇장이 선택되었을때만 추가가가능하도록 만드는 기능은 제공되지 않았습니다

예를들어서 최대 3장을 선택할수있는 image picker라면 1장 혹은 2장 혹은 3장을 선택할수있게되는거죠. 제가 원하는 기능은 3장을 선택했을때만 추가버튼이 활성화되는 기능인데 말이죠

어떻게든 팀내에서는 개발리소스를 줄이기위해서 기획을 수정하면서까지 기본 image picker를 사용하는 방안을 마련했지만 제가 생각했을때는 UX적으로 너무나 불편한점이 많았습니다

그래서 결국은 image picker를 custom하기로 결정했습니다!

앨범에서 이미지를 보여주는 방식

우선 image picker를 custom하기전에 우리는 기본 imagepicker를 쓰지 않고 직접 image들을 불러와야하기 때문에 앨범에서 이미지를 어떻게 불러오는지에대한 큰 그림을 이해하고 갈 필요가 있습니다

PHAsset 불러오기

가장 기본이 되는 PHAsset입니다

공식문서에서 설명하는 PHAsset을 보면 image나 video자체가 아니라 image나 video의 representaion이라고 합니다

이게 무슨말일지 곰곰히 생각을 해보면 꽤나 간단합니다. 이미지는 텍스트에 비해서 굉장히 무거운 데이터입니다. 그래서 보통 이미지를 불러오는 작업은 무겁고 느리기때문에 global queue에서 동작시킨다는 이야기는 iOS를 개발하신다면 한번쯤은 들어보셨을겁니다

그래서 image자체를 주기보다는 image의 메타정보를 담은 PHAsset이라는걸 주게됩니다

What is the "Meta Data"

메타데이터(Metadata)는 일반적으로 데이터에 관한 구조화된 데이터로, 대량의 정보 가운데에서 확인하고자 하는 정보를 효율적으로 검색하기 위해 원시데이터(Raw data)를 일정한 규칙에 따라 구조화 혹은 표준화한 정보를 의미합니다.

우리는 앨범에 있는 사진들을 불러오기위해서 결국 앨범에있는 사진들의 메타데이터인 PHAsset을 불러오게됩니다

그러면 앨범안에 PHAsset이 여러개가 들어있는 형태겠죠

PHAsset의 class 메서드를 활용한다면 PHAsset을 generic으로 들고있는 PHFetchResult라는 객체를 얻을수있게됩니다. 지금 여기서는 Album이구나~하고 넘어가셔도 큰 무리는 없을겁니다

PHAsset에서 image불러오기

Album을 불러왔고 그 Album안에는 PHAsset들이 실제 앨범의 image갯수만큼 들어있을겁니다

그러면 우리가 UI에 image를 보여주기 위해서는 실제로 Image형태의 데이터가 필요합니다. 하지만 우리가 가지고 있는건 image에 대한 메타데이터인 PHAsset뿐이죠

결국 메타데이터로부터 image데이터를 변환해줄수있는 매개체가 필요하다라는걸 직관적으로 알수있습니다

애플에서는 PHImageManger라는 객체를 제공하고있습니다(PhotoKit에서요)

overview만봐도 PHAsset으로부터 image데이터를 받아올수있도록 도와주는 객체라는걸알 수 있습니다

PHImageManager내부에 requestImage라는 메서드의 인자로 PHAsset을 넣어주면 일정시간이지난후에 UIImage?를 반환해준다는걸 알 수 있습니다

이렇게 completion handler에서 받은 UIImage를 UIkit에서는 바로 UI에 선언해주면될거고 swiftUI에서는 Image(uiImage:_)에 넣어주면 실제 앨범에서의 이미지가 UI로 표현되게됩니다

앨범에서 이미지만 불러오기

사실 저는 video도 필요없고 단순이 앨범에서 사진형태의 데이터만 불러오면되기때문에 앨범에 있는 모든 image데이터를 받아오면됩니다

private var album: PHFetchResult<PHAsset> = PHFetchResult<PHAsset>()

private func fetchAlbum() {
    let options = PHFetchOptions()
    options.includeHiddenAssets = false
    options.includeAssetSourceTypes = [.typeUserLibrary]
    options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
    album = PHAsset.fetchAssets(with: .image, options: options)
}

앨범데이터를 관리하는 객체내부에서 initalize에서 fetchAlbum이라는 메서드를 실행시키면 album이라는 변수내부에는 Image관련 PHAsset들이 전부 들어있게 될겁니다

그리고나서는 어떻게할까요?
album내부에서 실제로 PHImageManger에게 요청하기위핸 PHAsset들을 불러와야합니다

다행히도 PHFetchResult(album의 타입이죠 generic을 PHAsset을 들고있는 객체였습니다!)는 enumerateObjects라는 메서드를 통해서 completion hander를 통해 ObjectType이라는 PHAsset에 접근이 가능합니다

이렇게 가져온 PHAsset을 swiftUI기준으로 grid로 뿌려줄때 각각의 grid에서는 PHAsset만 받았기때문에 UIImage로의 변환이 제각각 필요할겁니다

@Published var image: UIImage?

private let imageManager = PHCachingImageManager()

func loadImage(for asset: PHAsset, size: CGSize) {
    imageManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: requestOptions) {
        [weak self] result, _ in
        if let image = result {
            DispatchQueue.main.async {
                self?.image = image
            }
        }
    }
}

결국 이렇게 이미지를 가져오게되면 @Published변수에 할당되고 해당 published를 관찰하고있는 gird의 cell은 실제 image를 load하게될겁니다

아마 이렇게 하면 큰 문제가 없을겁니다
시뮬레이터에서 돌렸을땐 말이죠 ㅎㅎ(시뮬레이터에는 기본이미지 6장밖에 존재하지 않거든요...)

그런데 실에 가지고계신 폰으로 돌려보시면 앱이 엄청나게 버벅거리게될겁니다

Memory과부화 해결하기

제가 사진이 대략 9000장 정도있는데 절반정도 왔을때 이미 메모리사용량이 최대 1.3GB까지 올라가고 내릴수록 UI가 엄청나게 끊기게됩니다...우리가 원하는건 끊김없이 스크롤이되면서 앱에 부담을 최대한 덜주는 방법일겁니다

사실 lazyvgrid를 사용하기만하면 이런문제가 자동으로 해결될거라고 생각했는데 그렇지는 않더라고요 ㅎㅎ...

아무튼, 이 문제를 해결하기위해서는 우리는 scrollview와 함께 pagination을 구현해야합니다
간단히말해서 사진을 50개씩(꼭 50개가 아니어도 됩니다)불러오고 scrollview가 바닥에 닿으면 그때 다시 50개를 불러오는 방식으로 구현을 하게되면 지금처름 9000장의 데이터를 불러오느라 memory와 main thread에 과부하가 오지 않는거죠

1. scrollview가 바닥에 닿는지 판단하기

우선 pagination을 구현하기 위해서는 scrollview가 바닥에 닿았는지를 확인하는 로직이 필요합니다

(최대한 자세히 그린다고 그렸는데 좀 눈에 확들어오지는 않네요 ㅠㅠ 양해부탁드립니다)
아무튼 결국 scroll이 된다는건 scrollview자체의 크기보다 scrollview내부에 들어가있는 contentsize의 height가 더 크다는게 되겟죠

만약에 scrollview를 끝까지 scroll하면 어떻게될까요?

이런상태가 되겠죠 결국 우리는 scrollview가 바닥에 닿았음을 언제알수있냐면 scrollview를 얼마나 scroll했는지에대한 y값에다가 scrollview자체의 frame높이가 scrollview내부의 contentsize와 일치하는순간이됩니다

결국 scroll이 될때마다 매번

scroll을 얼마나했는지 + scrollview자체의 frame height == scrollview의 content height

를 확인하고 true가 되는순간에 scrollview가 바닥에 닿았으니까 다음 50개 PHAsset을 불러와줘!라고 하면됩니다

실제 코드는 이렇습니다
scrollview자체의 높이는 변하지 않기때문에 view가 만들어지는 처음에 값을 갱신하도록 했고 contentSize는 계속 똑같다가 사진이 추가되면 그때 한번씩 커지기때문에 값이 바뀔때만 갱신되도록했습니다
scroll을 얼마나 했는지는 매순간달라지니까 매번 갱신해서 계산하도록 했습니다

2. 일정한 갯수의 사진을 추가로 가져오기

scrollview가 바닥에 닿아서 사진을 추가로 가져오라고 명령을 했습니다
이제 그 명령을 구현해주기만 하면됩니다

이전에 제가 album자체에 모든 image의 PHAsset이 다들어있었다고 했었죠

만약에 fetch단위가 50개라면
첫 page에서는 0번부터 50번째 PHAseet을 들고오게되겠죠
두번째 page에서는 50번째부터 100번째 PHAsset을 들고오게될겁니다

이렇게되었을때 총 0번부터 100번까지의 PHAsset이 저장되어있을겁니다

@Published var fetchedImages: [ImageAsset] = []
private var currentIndex = 0
private let fetchLimit = 50

최소한 이 세개의 변수는 필요합니다

Q. 엥? PHAsset이 아니라 ImageAsset은 뭐죠?

A. 선택된 index를 추가로 알기위해서 만들어놓은 PHAsset의 wrapper구조체라고보시면됩니다

struct ImageAsset: Identifiable {
    var id: String = UUID().uuidString
    var asset: PHAsset
    var assetIndex: Int = -1
}

fetchLimit은 변하지 않고 늘 currentIndex부터 currentIndex+fetchLimit까지의 PHAsset을 받아와서 fetchImages에 append해주는 로직이 될겁니다

그리고 PHFetchResult에는 특정 범위의 PHAsset들을 들고올수있는 메서드가 존재합니다
자 그러면 우리는 scrollview가 바닥에 닿을 때마다 50개씩 더 가져오라는 코드만 짜주면 우리가 원하는대로 구현이될겁니다

위 프로퍼티에 몇가지 코드를 추가해보겠습니다


@Published var fetchedImages: [ImageAsset] = []
private var currentIndex = 0
private let fetchLimit = 50

// 추가된 코드
private let fetchTrigger = PassthroughSubject<Void, Never>()
func getPhotos() {
    fetchTrigger.send()
}

우선 바닥에 닿았을때를 알려줄 passthroughSubject를 만들고 바닥에 닿을때마다 getPhotos라는메서드를 실행해서 fecthTrigger에 void를 send해주도록합시다

그러면 fetchTrigger가 void를 받았을때 어떻게할지를 operator로 명령할수있습니다

private func setupBindings() {
    fetchTrigger
        .filter { [weak self] in self?.isLoading == false }
        .handleEvents(receiveOutput: { [weak self] in self?.isLoading = true })
        .compactMap { [weak self] in self?.getNextIndexSet() }
        .compactMap { [weak self] in self?.albumService.convertAlbumToImageAsset(indexSet: $0) }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] in
            self?.fetchedImages.append(contentsOf: $0)
            self?.currentIndex += $0.count
            self?.isLoading = false
        }
        .store(in: &cancellables)
}

operator들이 조금 복잡해보이지만 차근차근 봅시다

  1. isLoading이 false라면 아직 pagination의 한 cycle이 끝나지 않았다는 뜻이라서 동작을 수행하지 않게해줍니다
  2. 만약에 해당 flag가 없다면 순간적으로 pagination이 느려진다면 같은 indexSet을 fetch해와서 불필요한데이터를 loading할수도있습니다
  3. .handleEvents(receiveOutput:)를 통해서 위의 filter를 통과했을때 isLoading을 true로 만들어주는 역할을 합니다
  4. 현재기준으로 다음 indexSet을 반환해줍니다
  5. indexSet만큼의 ImageAsset을 반환받습니다
  6. maintread에서 동작하게 합니다
  7. 받은 ImageAsset을 fetch된 배열에 넣고 총 index를 갯수만큼 증가시킵니다. 한 cycle이 끝났으니 isLoading을 false로 바꿔서 다음 요청에 실행가능하도록 flag를 갱신해줍니다

참고 albumService.convertAlbumToImageAsset(indexSet: $0)

func convertAlbumToImageAsset(indexSet: IndexSet) -> [ImageAsset] {
    return indexSet
        .compactMap { album.object(at: $0) }
        .map { ImageAsset(asset: $0) }
}

3. Pagination 구현 결과

gif로보니까 조금 사진을 내리는게 끊겨보이실수도있는데 실제로는 전혀끊기지않고 부드럽게 image들이 업데이트됩니다
메모리사용량도 끝까지 내렸을때 200MB가 채 되지않습니다

이렇게 구현하면 당연히 사진선택을 몇장했냐에 따라서 버튼을 활성화하고 비활성화하는 기능은 아주 쉽게 넣을수있죠

pagination을 구현할수있다면 image picker에 다양한 기능을 넣을수있습니다

사실 combine의 operator를 사용하기전에는 아래와같은 코드를 사용했었는데 코드의 흐름을 직관적으로 보기에는 조금더 이해하기 편할거같아서 추가적으로 첨부해놓겠습니다

pagination을 이렇게도 구현할수있구나 정도로 봐주시면 좋을것같습니다:)

func getPhotosWithPagination() {
    guard !isLoading else { return }
    isLoading = true

    defer { isLoading = false }

    let endIndex = min(currentIndex + fetchLimit, albumService.count)
    guard currentIndex < endIndex else { return }

    let indexSet = IndexSet(currentIndex..<endIndex)
    let assets = albumService.convertAlbumToImageAsset(indexSet: indexSet)
    
    DispatchQueue.main.async {
        self.fetchedImages.append(contentsOf: assets)
        self.currentIndex = endIndex
    }
}

프로젝트를 진행하다보니 당연히 기본 컴포넌트를 사용하는게 빠르고 깔끔하지만 가끔은 이렇게 중요한 기능을 위해서 custom을 해야할때가 종종 있는것같습니다 ㅎㅎ...

물론 시간은 배로 들지만 막상 구현을 딱 하는 순간 정말 뿌듯하고 custom하길 잘했다는 생각이드네요
메모리를 조금더 효율적으로 사용하기위한 paginaion을 직접 구현해본건 이번이 처음이었는데요. 다음에 뭔가 꼭 imagepicker뿐아니라 많은 갯수의 데이터를 불러와야할때 pagination을 먼저 떠올릴수있는 좋은 경험이 되었던것 같습니다:)

혹시나 더 좋은 방식이있거나(저는 scrollview가 바닥에 닿았는지를 판단하는 로직을 짜기가 너무 어려웠는데... 혹시 더 간편한 방법이있으면 댓글로 알려주시면 감사하겠습니다) 좋은 코드가 있으면 언제든지 피드백 부탁드리겠습니다

그럼 즐거운 연휴 보내시고 저도 어린이날을 즐기러 가보겠습니다
그럼 20000!

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

5개의 댓글

comment-user-thumbnail
2024년 5월 9일

PHAsset을 UIImage로 변환하는 코드는 어느 객체에서 담당하셨나요?

저는 Cell에서 PHCachingImageManager가 PHAsset을 UIImage로 변환하고,
Cell의 prepareForReuse이 발동되었을 때, PHCachingImageManager의
stopCachingImagesForAllAssets() 를 호출하였더니 pagination기법없이도 메모리를 크게 잡아먹지 않는 것 같아서욥!

1개의 답글
comment-user-thumbnail
2024년 5월 11일

사진이나 비디오 관련된 구현할때 PHAsset이 무엇인지 참 궁금했었는데 애플의 세심한 배려가 보여지는 클래스였네요..!

지금까지 기본 제공API만 썼었는데 앨범의 사진들을 받아와 페이지네이션을 통한 메모리 처리까지 너무너무 재밌게 읽었습니다☺️☺️

궁금한게 몇가지 있는데 PHAsset으로 불러오는 사진들은 앨범의 모든 사진을 들고오는것인지 아니면 앨범 내부의 특정 폴더에서의 전체사진을 불러오는것인지 궁금합니다 이를 구분지어 가져오는 것이 가능한가 싶어서요! 🙋

1개의 답글
comment-user-thumbnail
2024년 5월 11일

PHAsset을 통해서 Image를 불러오고 메모리 관리까지 깔끔하게 정리하는 글 잘 읽었습니다 ㅎㅎ
기존의 API만 주구장창 사용하다가 PHAsset을 보니까 되게 신기하네요
개발 공부는 참 끝이 없는 것 같습니다 ㅎㅎ

답글 달기