프로젝트 설정 방법 보기
🔑 API KEY | 프로젝트를 실행하기 전 API 키를 세팅해야 합니다. |
---|---|
프로젝트 내 Resource 그룹 내에 API_KEY.xcconfig 파일을 생성합니다. |
|
생성한 파일 내에 OPENWEATHER_API_KEY = KEY 를 입력 후 KEY 부분에 OpenWeather의 API 키를 넣으면 네트워킹을 할 수 있습니다. |
일기를 작성하고 간직하세요! 작성하신 일기는 목록으로 볼 수 있습니다!
핵심 개념 및 경험
- SwiftUI
SwiftUI
를 이용하여 선언형 UI 구현- MVVM
- 프로젝트의 가독성 및 역할 분리를 위해
MVVM
패턴 사용- Networking
- 날씨 정보를 가져오기 위해
URLSession
을 이용한 네트워킹- CoreData
- 데이터를 로컬에 저장하기 위해
CoreData
를 이용한 저장 기능 구현- LocationManager
- 위치 정보를 가져오기 위해
CLLocationManager
를 이용한 위치 정보 업데이트- Combine
Combine
을 이용하여 비동기 작업 처리
Erick |
---|
SwiftUI 리펙토링 기간 : 2024.01.15 ~ 2024.01.30
날짜 | 내용 |
---|---|
2024.01.15 | ▫️ Diary 엔티티 생성 ▫️ DiaryListView, DiaryEditView, DiaryRowView 생성 |
2024.01.16 | ▫️ View Component 분리 |
2024.01.18 | ▫️ DiaryDTO 생성 ▫️ CoreData Model 및 PersistenceController 생성 ▫️ DiaryRepository 생성 |
2024.01.19 | ▫️ DiaryListViewModel 생성 |
2024.01.22 | ▫️ DIContainer 생성 ▫️ DiaryDetailViewModel 생성 |
2024.01.22 | ▫️ DIContainer 생성 ▫️ DiaryDetailViewModel 생성 |
2024.01.23 | ▫️ ViewModel Input, Output 로직 수정 |
2024.01.25 | ▫️ SwiftErickNetwork 패키지 추가 |
2024.01.26 | ▫️ EndPoint 생성 ▫️ WeatherService 생성 ▫️ LocationService 생성 |
2024.01.27 | ▫️ WeatherService에 icon fetch를 위한 로직 추가 |
2024.01.29 | ▫️ DiaryRepository, WeatherService 테스트 코드 추가 |
Diary
├── Application
│ ├── DiaryApp.swift
│ └── DIContainer.swift
├── Domain
│ ├── Entity
│ │ └── Diary.swift
│ ├── Repository
│ │ ├── DiaryRepository.swift
│ │ ├── LocationService.swift
│ │ └── WeatherService.swift
│ └── UseCase
│ └── DiaryUseCase.swift
├── Presentation
│ └── DiaryList
│ │ ├── ViewModel
│ │ │ └── DiaryListViewModel.swift
│ │ └── View
│ │ ├── DiaryListView.swift
│ │ └── DiaryRowView.swift
│ └── DiaryDetail
│ ├── ViewModel
│ │ └── DiaryDetailViewModel.swift
│ └── View
│ └── DiaryDetailView.swift
├── Data
│ ├── DTO
│ │ ├── DiaryDTO.swift
│ │ └── WeatherResponse.swift
│ ├── Repository
│ │ ├── DiaryCoreDataRepository.swift
│ │ ├── LocationManagerService.swift
│ │ └── WeatherNetworkService.swift
│ ├── CoreData
│ │ ├── Diary.xcdatamodeld
│ │ │ └── Diary.xcdatamodel
│ │ │ └── contents
│ │ └── PersistenceController.swift
│ └── Network
│ ├── CurrentWeatherEndPoint.swift
│ └── WeatherIconEndPoint.swift
├── Error
│ ├── DiaryRepositoryError.swift
│ └── LocationServiceError.swift
├── Util
│ ├── Extension
│ └── NameSpace.swift
└── Resource
├── Info.plist
├── API_KEY.xcconfig
└── Assets.xcassets
일기 생성 | 일기 수정하기 |
---|---|
스와이프를 이용한 공유 | 스와이프를 이용한 삭제 |
더보기 버튼을 이용한 공유 | 더보기 버튼을 이용한 삭제 |
SwiftUI 내에서 CoreData를 사용하기 위해 PersistenceController를 이용하여 NSPersistentContainer를 생성했습니다.
PersistenceController
NSPersistentContainer를 SceneDelegate에 생성하여 전역적으로 사용하는 것이 아닌, PersistenceController를 이용해 NSPersistentContainer를 생성하여 preview에서 사용할 객체와 실제 프로젝트에서 사용할 기본적인 객체를 static 프로퍼티로 선언하여 접근할 수 있도록 했습니다.
또한 init에서 Store Type을 inMemory로 사용할지의 여부를 Bool 타입으로 받아 인스턴스를 생성하기 때문에 테스트 시에도 inMemory Type의 NSPersistentContainer 쉽게 생성할 수 있다는 장점이 있습니다.
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
// ...
}()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
// ...
}
}
Combine을 이용하여 네트워킹을 하면 선언적 처리가 가능하며, stream을 이용한 반응형 UI 처리가 용이하기 때문에, 네트워킹의 비동기 작업을 Combine을 이용하여 처리했습니다.
WeatherNetworkService
SwiftErickNetwork 패키지에서 제공하는 NetworkManager의 requestPublisher를 이용하여 네트워킹 비동기 처리를 했습니다.
네트워킹을 통해 받아온 DTO 타입에서 필요한 데이터를 매핑하여 Weather 객체를 반환하도록 했습니다.
struct WeatherNetworkService: WeatherService {
private let networkManager: NetworkManageable
init(networkManager: NetworkManageable = NetworkManager()) {
self.networkManager = networkManager
}
func fetchWeatherPublisher(
location: CLLocationCoordinate2D
) -> AnyPublisher<Weather, NetworkError> {
guard let endPoint = CurrentWeatherEndPoint(lat: String(location.latitude),
lon: String(location.latitude))
else { return Fail(error: NetworkError.invalidComponents).eraseToAnyPublisher() }
return networkManager
.requestPublisher(with: endPoint)
.tryMap { weatherResponse in
guard let weather = weatherResponse.weather.first
else { throw NetworkError.emptyData }
return weather
}
.mapError { error in
if let networkError = error as? NetworkError {
return networkError
} else {
return NetworkError.dataTask(error)
}
}
.eraseToAnyPublisher()
}
// ...
}
LoactionPublisher + WeatherPublisher
위치 정보를 받아오는 requestLoactionPublisher() 함수도 Publisher를 반환하기 때문에 완료 처리를 두 번 해주는 것이 아닌 Operator를 이용해 Publisher를 매핑하여 하나의 Stream으로 비동기 처리를 했습니다..
mapError로 에러를 매핑하고 flatMap으로 게시된 location data를 이용해 fetchWeatherPublisher(location: location)를 호출하여 새로운 OutPut으로 Publisher를 매핑하여 비동기 처리를 했습니다.
useCase.requestLoactionPublisher()
.mapError { _ in NetworkError.invalidComponents }
.flatMap { location in
self.useCase.fetchWeatherPublisher(location: location)
}
.receive(on: DispatchQueue.main)
.sink { completion in
if case .failure(let error) = completion {
print(error.localizedDescription)
self.errorMessage = NameSpace.weatherFetchFailed
self.isError = true
}
} receiveValue: { weather in
self.diary.weatherID = weather.icon
self.updateDiary()
}
.store(in: &cancelables)
기존 Diary에서는 ViewController에서 CLLocationManager를 가지고 있고 CLLocationManagerDelegate를 채택하여 Location 데이터를 받아와 처리했습니다.
하지만 SwiftUI의 View는 구조체이기 때문에 CLLocationManagerDelegate를 채택할 수 없고, MVVM으로 프로젝트를 설계하며 Location 데이터를 받아오는 객체를 따로 빼주기 위해 LocationManagerService를 만들었습니다.
LocationService
우선 위치를 가져오는 기능을 가진 LocationService를 정의했습니다. 비동기 작업에 대한 처리는 AnyPublisher<CLLocationCoordinate2D, LocationServiceError>를 반환하여 위치를 받는 객체에서 Publisher를 구독해서 처리하도록 하였습니다.
protocol LocationService {
func requestLoactionPublisher() -> AnyPublisher<CLLocationCoordinate2D, LocationServiceError>
}
LocationManagerService
LocationService를 채택한 실제 객체인 LocationManagerService는 CLLocationManagerDelegate를 채택하여 위치 정보를 요청하는 작업과 위치 정보를 받아 반환하는 역할을 모두 하도록 했습니다.
final class LocationManagerService: NSObject, CLLocationManagerDelegate, LocationService {
private var locationManager: CLLocationManager
private var locationSubject = PassthroughSubject<CLLocationCoordinate2D, LocationServiceError>()
init(locationManager: CLLocationManager = CLLocationManager()) {
self.locationManager = locationManager
super.init()
self.locationManager.delegate = self
self.locationManager.requestWhenInUseAuthorization()
}
func requestLoactionPublisher() -> AnyPublisher<CLLocationCoordinate2D, LocationServiceError> {
locationManager.requestLocation()
return locationSubject.eraseToAnyPublisher()
}
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.last?.coordinate
else { return }
locationSubject.send(location)
locationManager.stopUpdatingLocation()
}
func locationManager(
_ manager: CLLocationManager,
didFailWithError error: Error
) {
locationSubject.send(completion: .failure(LocationServiceError.requestLocation(error)))
}
}
Handling
requestLoactionPublisher()로 위치 정보를 요청하면 PassthroughSubject를 AnyPublisher 타입으로 Type Eraser Wrapped 하여 반환합니다. 또한 locationManager는 위치 정보를 요청합니다.
그리고 위치 정보나 에러가 업데이트되면 Subject.send를 이용해 AnyPublisher에 데이터를 게시하여 구독자가 이를 받아 처리할 수 있도록 했습니다.
LocationManagerService()
.requestLoactionPublisher()
.sink { completion in
if case .failure(let error) = completion {
// Error handling
}
} receiveValue: { location in
// Location data handling
}
.store(in: &cancelables)
- Apple Developer: Combine
- Apple Developer: dataTaskPublisher(for:)
- Apple Developer: eraseToAnyPublisher()
- Apple Developer: CLLocationManager
- Apple Developer: CLLocationManagerDelegate