- 매일의 추억을 지도와 네컷사진으로 기록할 수 있는 서비스
2024.09.12 ~ 2024.10.08
클라이언트(iOS) 1명
iOS 15.0 이상
- 카테고리별 로그 조회
- 로그 상세 조회 / 수정 / 삭제
- 네 컷 사진 조회 / 갤러리 저장
- 방문 장소 조회
- 방문장소 검색
- 방문장소 등록 / 삭제
- 방문장소 이미지 등록 / 삭제
- 카테고리 추가 / 삭제
- MVVM, Input-Output
- Repository, Router, Singleton
- SwiftUI
- Combine
- UIKit
- Alamofire
- RealmSwift
- Naver Maps API V3
- BottomSheet
- RealmSwift
- SnapKit
- SwiftUIX
-
ViewModel
- Input과 Output 구조체에 Subject와 View로 내보낼 데이터를 초기화하고 이를 ViewModel의 input, output 프로퍼티에 초기화
- init시점에 input 프로퍼티에 초기화된 PassthroughSubject를 sink가 구독
- action 메서드를 통해 View에서 input 이벤트가 전달되면 매칭되는 PassthroughSubject Stream에서 연산을 위한 이벤트를 방출
- 연산 결과를 output의 프로퍼티에 업데이트하면 output 프로퍼티에 적용된 @Published의 효과로 View에 선언된 @SateObject / @ObservedObject 작동
-
View
- viewModel.action(_ action: Action)을 통해 input 이벤트 전달
- viewModel.output에 변경이 발생했을 때 @StateObject / @ObservedObject의 효과로 새롭게 렌더링되며 이때 output의 변경사항이 반영됨
- NavigationView와 NavigationStack 분기
- ScrollView indicator 메서드 분기
- 그 외 iOS15 기준으로 코드 작성
-
UIHostingController의 rootView에 SwiftUI View 할당
-
UIHostingController는 UIViewController를 상속하므로 UIViewController의 프로퍼티와 메서드들 사용해 이미지의 배경에 사용될 뷰 구성
-
UIGraphicsImageRenderer 인스턴스 생성 및 UIHostingController의 view가 가진 layer Tree를 UIGraphicsImageRendererContext에 작성
-
렌더링을 수행하여 UIImage 생성
-
CGImage.cropping을 사용하여 불필요한 영역 crop
- crop영역 설정 시 현재 사용중인 device의 scale만큼 size를 확대해야 함
- UIImage의 size는 device의 scale을 반영하지 않은 값을 반환하지만, CGImage는 scale을 반영한 size를 갖기 때문
-
Naver Maps API SDK에서도 위 방식으로 변환한 UIImage로 NMFMarkerImage를 생성해 커스텀 마커 적용
-
Naver 지도 UIViewRepresentable 구조체에 사용된 @Binding 프로퍼티
- 현재 선택된 장소의 index
- 지도에 표기할 장소정보 배열
- 장소 index와 이미지 배열을 key-value로 갖는 딕셔너리
- NMGLatLng(좌표) 배열
-
viewModel의 output이 변경되어 현재 선택된 장소 순서가 변경될 때마다 Naver 지도 SDK를 래핑한 UIViewRepresentable 구조체가 새로 렌더링 되어 프로퍼티의 값이 초기화 된다.
- 렌더링 직전의 값이 사라지고 초기화 됨
-
최초 생성 후엔 초기화되지 않는 coordinator 클래스가 @Binding을 통해 업데이트 되는 데이터들의 변경사항을 반영한 뒤 자신의 프로퍼티에 저장해 최신 상태 기억
-
UIViewRepresentable의 updateUIView메서드에서는 cooridnator의 프로퍼티에 저장된 최신 상태를 Naver 지도 뷰에 업데이트하는 작업만 수행
-
@Namespace를 선언해 자신이 저장한 Identifier들이 부여된 View들을 애니메이션 효과를 적용할 하나의 그룹으로 구분
-
애니메이션을 적용할 View들에 matchedGeometryEffect Modifier를 적용하고 각자의 ID 부여하고, Namespace 지정
-
동일한 Namespace를 공유하는 View들끼리 애니메이션이 적용됨
- SwiftUI에서는 화면을 구성하는 View 객체들을 메서드나 구조체로 분리하는 것이 권장된다.
- 한 화면을 구성하는 상위 뷰와 하위 뷰들이 같은 ViewModel 인스턴스와 이벤트를 주고받아야 상호 간의 데이터의 일관성이 유지될 수 있다.
- 하위뷰가 상위뷰가 가진 ViewModel 인스턴스를 전달받기 위해서 필요한 ViewModel 타입의 @EnvironmentObject나 @ObservedObject를 선언
- 전달되는 ViewModel에 @ObservableObject 프로토콜을 채택하고 상위뷰에서 environmentObject Modifier를 사용하거나 생성자에 뷰모델 인스턴스를 인자로 넘겨 공유
- 같은 ViewModel을 공유하는 View 간에는 별도의 로직없이도 최신상태 공유 가능.
-
GeometryReader기반 Custom Infinity Carousel View
- Realm에서 조회한 일기 목록 중 네 장의 사진이 등록되어있는 것만 필터링한 리스트를 @Binding으로 주입
- ViewModel에서 전달받은 data의 last를 0번 Cell, first를 data.count + 1번 Cell에 복사한 후,전체 Cell을 HStack에 생성
- GeometryReader로 화면의 크기를 구한 후 1개 Cell이 차지할 범위를 지정. Drag 이벤트 발생 시 현재 셀의 offset을 기반으로 페이지네이션
- 0번 Cell과 마지막 Cell은 1번과 data.count번 Cell의 옆을 채워줄 더미이고 실제 뷰에 표시되는 Cell의 범위는 1번 ~ data.count까지.
- 실제 뷰에 표시되는 Cell의 시작과 끝 사이의 이동은 0번 / 마지막 Cell로 이동 후, data.count번 / 1번 Cell로 offset을 옮겨서 구현
-
반복적인 Drag 이벤트 발생 제어
- @State에 Drag이벤트 진행중인지 체크하는 Bool 선언
- Cell에 Drag이벤트 발생시 true, true인 동안 Drag 이벤트 발생하여도 guard 문으로 조기탈출
- Drag이벤트가 종료될 때 DispatchQueue.main.asyncAfter로 시간을 지연시킨 후 false 할당
- PHPickerViewController의 UIViewControllerRepresentable에서 이미지 로드 시 비동기로 작동
- PHPickerViewControllerDelegate의 picker( _picker:, didFinishPicking: ) 메서드에서 선택된 사진들을 순회하며 load하기 전에 DispatchGroup을 생성
- 사진들을 순회할 때마다 enter()를 실행하고 각 사진들을 UIImage로 변환하여 ViewModel의 input으로 전달한 후 leave()하는 방식으로 작업완료시점 제어
- 모든 사진들을 ViewModel의 input에 전달한 다음 사진 등록작업 완료 action 전파
-
제네릭 타입 매개변수의 제약조건으로 View를 갖고 프로퍼티로 다음화면에 사용할 View를 갖는 NextViewWrapper를 선언
- 생성자의 view 매개변수에 @autoclosure 키워드를 사용하여 생성자 사용시 입력되는 클로저의 중괄호 묶음 생략
- 또한 @escaping 키워드로 클로저 내부의 View를 NextViewWrapper의 view 프로퍼티에 할당할 수 있도록 허용
-
ForEach문 안에서 NavigationLink 렌더링 시 NextViewWrapper만 렌더링하여 메모리 부하 감소
- 연결된 화면의 View는 클릭 이벤트 발생시에 렌더링된다
-
ViewModel의 Output Stuct의 프로퍼티에 일기의 목록을 담는 @ObservedResult 선언
-
@ObservedResult는 Environment Value인 realmConfiguration을 따르므로 RealmDB 데이터에 변화가 있을 때에 이를 관찰할 수 있다.
-
View에서는 viewModel.output을 참조해 @ObservedResult가 변경될 때마다 새로 렌더링되며 데이터를 갱신하며
-
Repository에서 추가 / 삭제 / 수정이 발생하더라도 별도의 로직 구현없이 실시간으로 View 갱신된다
-
Singleton으로 구현한 FileManager 클래스가 이미지 조회 / 저장 / 삭제 담당
- View에서의 이미지 조회
- ViewMopdel과 Repository에서의 저장 / 삭제 시 호출
-
Realm의 일기 Object가 등록된 이미지 파일명의 배열을 가짐
-
Repository에서 일기 또는 일기의 List를 갖는 카테고리를 삭제할 때 일기가 가진 이미지 목록을 순회하며 삭제.
- 선택된 마커/장소에 대한 이벤트 처리가 1~2초가량 지연되는 이슈
- 사용자가 선택한 장소의 index를 입력받는 프로퍼티에 'view의 변경이 없는 변경 작업은 예측되지 않은 동작을 일으킬 수 있다'는 메모리 이슈 경고 발생
- updateView 메서드의 로직을 Main큐에서 비동기 처리하도록 개선
func updateUIView(_ uiView: NMFNaverMapView, context: Context) {
DispatchQueue.main.async() {
//MARK: 카메라 위치 갱신
......
//MARK: 오버레이 요소 갱신
......
//MARK: 마커에 맵뷰 할당
......
//MARK: 마커 간의 직선 갱신
......
//MARK: 카메라 이동 애니메이션
......
}
}- 마커 선택 및 장소 셀 선택 시 반응속도 개선
- 서비스 기획상 현재 2개의 뷰에서 지도 SDK를 사용해야만 하는 만큼 최소 200MB 가량의 메모리 부하를 디폴트로 감당해야하는 상태.
- 일기 작성 탭의 100MB는 고정, 일기 조회화면의 100MB가량은 화면에서 벗어날 시 해제됨
- 이미지 개수를 최대 4개로 제한했지만 원본 이미지를 그대로 사용하게 되면 4개만 등록해도 메모리에 과도한 부하발생
-
WWDC에서 SwiftUI에서 제공하는 Image의 resizable이나 UIGraphicsImageRenderer보다 더 효율적인 방법으로 소개된 ImageIO를 사용한 다운샘플링 구현
- UIImage Extension
import ImageIO import UIKit extension UIImage { func resize(to size: CGSize) -> UIImage? { let options: [CFString: Any] = [ kCGImageSourceShouldCache: false, kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailFromImageIfAbsent: true, kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height), kCGImageSourceCreateThumbnailWithTransform: true ] guard let data = pngData(), let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else { return nil } let resizedImage = UIImage(cgImage: cgImage) return resizedImage } }
- PHPickerView
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { parent.isPresented = false viewModel.action(.changeLoadingState) let width = ScreenSize.width - 75 let height = ScreenSize.height - 312 let group = DispatchGroup() results.forEach { [weak self] in $0.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (object, error) in group.enter() DispatchQueue.main.async() { if let rawImage = object as? UIImage, // 원본 UIImage를 resize하여 메모리 최적화 let image = rawImage.resize(to: CGSize(width: width, height: height)) { self?.imageList.append(image) if let imageList = self?.imageList, imageList.count == results.count { self?.viewModel.input.pickedImages = imageList self?.viewModel.action(.photoPicked) } } group.leave() } } } viewModel.action(.changeLoadingState) }
-
다운 샘플링 적용 후 사진 4장 추가 시 메모리 부하 개선
- SwiftUI와 Combine을 결합한 MVVM 아키텍처 구현
- 최소버전을 iOS 15로 대응하는 데 성공
- Naver 지도 SDK의 오버레이 객체들을 활용해 지도에 마커, 경로, 사진을 추가하는 로직 구현에 성공
- 선언형 UI인 SwiftUI에서 @ObservedObject, @EnvironmentObject, ViewModel을 여러 View에 걸쳐서 사용하는 것이 좋은 방향인지 의문이 듦. MVI 아키텍처나 TCA를 학습해보면 좋을 것 같다.
- 네트워크, Realm CRUD 등의 예외처리 및 alert등을 통한 결과 안내 로직 추가
- 커스텀으로 구현한 Infinity Carousel View의 딱딱한 스크롤 애니메이션을 SwiftUI에 어울리게 개선
- 지도 SDK 같이 메모리 사용량이 큰 객체를 사용하는 뷰는 탭바에서 최소 한 번의 depth를 주어 사용한다면 메모리 최적화에 유리할 것으로 보임













