2023. 4. 1. 19:12ㆍiOS/프로젝트
Core Data 사용하기
어쨌든 한번만 사용하고 끝낼 게 아니기 때문에 정보들을 저장해야 합니다. 그래서 1) CoreData를 SwiftUI에서 사용하는 법을 이해하고, 2) 실제 나의 프로젝트에 적용해 볼 것입니다.
목표 달성 (1 - Core Data + SwiftUI)
Core Data의 간단한 개념
- 하나의 기기에서 쓰이는 영구 / 캐시 데이터 또는 CloudKit 을 통해 연결된 여러 기기의 sync 데이터
- 앱의 영구적인 데이터를 저장하거나 일시적 데이터를 캐싱하거나, undo 기능을 구현할 때 사용
SwiftUI 프로젝트에서의 CoreData
먼저 SwiftUI 에서의 큰 그림을 살펴 봅니다. 프로젝트를 만들 때 Core Data를 사용하겠다고 체크하면, 자동적으로 기본 세팅을 해 주고 Preview 까지 제공을 해 줍니다. 그걸 먼저 이해해 보려고 해요.
- 기본적으로 프로젝트 이름으로 된 모델 파일과 Persistence 파일이 함께 생성됨
- 모델 파일을 클릭하면 미리 정의된 Item 엔티티와 그 안에 정의된 timestamp 라는 attribute가 보임 (AudioFile은 내가 테스트로 생성한 것이니 무시)
- 엔티티는 보통 우리가 다룰 독립된 단위입니다. 예를 들어 사용자가 있으면 User, 자동차가 있으면 Car 라는 엔티티를 정의할 수 있을 것입니다.
Persistence (기본으로 주어지는)
Persistence 파일 안에는 PersistenceController 구조체가 정의되어 있습니다.
크게 개념을 이해하자면
- Core Data 스택 이란 것을 관리할 수 있는 Persistent container가 있고 이를 통해서 모델, context, store coordinator에 한 번에 접근할 수 있다
Model, Context, Store coordinator에 관해서는
- model은 엔티티와 1대1 대응되는 관계라고 이해하면 쉽고
- Context는 모델을 조작하고 변화를 감지해주는 친구라고 이해하면 쉽고
- Store coordinator는 아래 그림처럼 실제 영구 저장소에 연결되어 저장 및 불러오기 역할을 한다고 이해하면 그나마 쉬울 것 같습니다.
아래는 Persistence 파일의 전체 코드인데 주석과 함께 흐름을 보는 것이 편할 것 같아요.
눈 여겨 볼 점은 inMemory 옵션. https://developer.apple.com/forums/thread/659777 글을 보면 아주 친절하고도 자세한 설명이 나옵니다. dev/null 경로를 이용하여 실제 테스트를 할 때는 영구 저장소 말고 일시적인 저장소를 사용할 수 있습니다. 출시 전까지 사용할 수 있는 아주 좋은 옵션인 것 같습니다.
import CoreData
struct PersistenceController {
// 싱글톤으로 접근 가능
static let shared = PersistenceController()
// ContentView의 preview로 동작 방식을 확인할 수 있게 만들어진 예제 코드
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true) // persistenceController를 만들고
let viewContext = result.container.viewContext // viewContext(NSManagedObjectContext)를 가져오고
for _ in 0..<10 { // 임의로 10개의 NSManagedObject를 생성하고
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
do {
// 뜻: Attempts to commit unsaved changes to registered objects to the context’s parent store.
try viewContext.save() // 정보 저장
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result // persistenceController를 리턴한다
}()
/// NSPersistentContainer: Core Data 스택을 캡슐화하는 컨테이너. model, context, store coordinator를 한번에 설정할 수 있도록 함
let container: NSPersistentContainer
init(inMemory: Bool = false) {
// NSPersistentContainer 생성 및 할당
container = NSPersistentContainer(name: "HearMyVoice")
if inMemory {
/// 영구 저장소를 쓸 필요 없이 테스트 용으로 사용하는 경우, 일시적으로만 존재하는 경로를 사용하도록 할 수 있다
/// 출처: https://developer.apple.com/forums/thread/659777
/// /dev/null: This is a special place that does NOT persist and will vanish once your app stops running.
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
/// loadPersistentStores: persistent 저장소를 불러오는데, 없으면 새로 만든다
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
/// parent는 persistent store coordinator 일 수도 있고 다른 managed object context 일 수도 있는데, Root 는 무조건 persistent store coordinator 이다.
// TODO: parent에서 변경이 발생하는 경우는?
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
ContentView (기본으로 주어지는)
ContentView에서는 본격적으로 CoreData를 사용하는 법이 나옵니다.
우선 @main 키워드가 붙은 App 파일을 살펴봐야 합니다.
import AVFoundation
import SwiftUI
@main
struct HearMyVoiceApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
- persistenceController의 싱글톤 객체를 변수에 할당했다.
- .environment를 이용해 environment data를 주입한다
- 해당 environment 데이터의 이름은 managedObjectContext
- 그 데이터는 persistenceController 에서 받은 viewContext
즉 이 코드로 인해 ContentView에서는 @Environment 프로퍼티를 추가해 managed object context 를 바로 읽을 수 있게 됩니다.
바로 ContentView로 넘어가 보면 아래와 같습니다.
- Environment 프로퍼티 wrapper 를 사용하여 주입 받은 viewContext를 사용한다
- FetchRequest 프로퍼티 wrapper 를 사용하여 Core Data의 persistent store에서 엔티티들을 가져온다. 이 때 sortDescriptors 사용해 정렬된 정보를 가져올 수 있다.
- 가져온 엔티티들은 items에 저장된다.
- 적절히 항목을 추가하고 삭제할 수 있다.
import SwiftUI
import CoreData
struct ContentView: View {
/// managedObjectContext 라는 키 값을 사용하여 해당 View의 정보를 가져온다
/// HearMyVoiceApp 파일에서 주입한 viewContext를 사용하게 된다
@Environment(\.managedObjectContext) private var viewContext
/// FetchRequest: Core Data의 persistent store에서 엔티티들을 가져오게 해 주는 property wrapper
// FetchedResults 프로퍼티를 선언하는 데 사용 (SwiftUI 뷰에 Core Data managed object들의 집합을 제공한다)
// Item의 timestamp 프로퍼티를 바탕으로 증가하도록 정렬
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
// ...
}
}
private func addItem() {
withAnimation {
// 새로운 Item 생성
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
// 변경사항 적용
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
// 삭제하려는 offset(예제의 경우는 하나밖에 안되는 것 같음..!)들을 삭제
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
// 변경사항 적용
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
목표 달성 (2 - Core Data 적용)
Views
- ContentView 대신 HomeView에 managedObjectContext를 주입합니다.
import AVFoundation
import SwiftUI
@main
struct HearMyVoiceApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
HomeView()
// persistenceController.container.viewContext를 managedObjectContext에 주입한다
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.onAppear {
AudioManager.shared.getUserPermission()
}
}
}
}
2. HomeView에서 VoiceRecordingView로 또다시 managedObjectContext를 주입합니다.
HomeView는 앱의 메인 뷰(첫 화면)이며, TabView로 구성됩니다.
struct HomeView: View {
/// HearMyVoiceApp 파일에서 주입한 viewContext를 사용하게 된다
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
TabView {
// ...
// MARK: - 메시지 녹음
NavigationView {
VoiceRecordView()
// TODO: 이렇게 계속해서 environment를 넣어주는 구조가 괜찮은 것인지?
.environment(\.managedObjectContext, viewContext)
}
.tabItem {
Image(systemName: "waveform")
Text("보내기")
}
// ...
}
}
}
3. VoiceRecordView에서 CoreData에서 가져 온 오디오 파일 목록을 보여줍니다. 각각의 항목을 클릭하면 PlayMessageView로 넘어갑니다.
VoiceRecordView는 음성 메시지를 녹음할 수 있는 화면입니다.
import SwiftUI
// MARK: - 목표: 녹음 후 저장 성공하기
struct VoiceRecordView: View {
// TODO: 매번 새로 property를 정의해 주어야 하나?
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \AudioFile.date, ascending: true)],
animation: .default)
private var audios: FetchedResults<AudioFile>
// TODO: Observable, Observed 제대로 이해
@ObservedObject var audioManager = AudioManager.shared
var body: some View {
VStack {
// ...
// MARK: - 녹음된 메시지 확인
List {
Section("녹음된 메시지들") {
ForEach(audios) { audio in
NavigationLink {
PlayMessageView(audio: audio) // 오디오 파일을 그대로 전달
} label: {
Text(audio.name!)
}
}
.onDelete(perform: deleteAudio)
}
}
}
}
func deleteAudio(offsets: IndexSet) {
// ...
}
}
4. PlayMessageView에서는 전달 받은 오디오 파일의 정보를 화면에 나타내고, 오디오 파일의 url을 사용해 AudioManager로 음악을 재생합니다.
PlayMessageView는 음성 파일을 재생하는 화면입니다.
import SwiftUI
struct PlayMessageView: View {
@ObservedObject var audioManager = AudioManager.shared
@State var isPlaying: Bool = false
var audio: AudioFile
var body: some View {
VStack (alignment: .leading, spacing: 30) {
// 오디오 정보 나타내기 (임시)
Text("파일 이름: \(audio.name!)")
Text("파일 생성일: \(audio.date!.formatted())")
Text("파일 생성자: \(audio.creator!)")
HStack {
// MARK: - 재생 컨트롤
Button {
print("재생")
AudioManager.shared.updatePlayerWith(URL(string: audio.url!)!)
AudioManager.shared.playAudio()
isPlaying = true // TODO: AudioManager에서 값 가져오기
} label: {
Text("재생")
}
.buttonStyle(.bordered)
.disabled(isPlaying)
Spacer()
// TODO: 일시정지를 넣어야 할까?
Button {
print("정지")
AudioManager.shared.endAudio()
isPlaying = false // TODO: AudioManager에서 값 가져오기
} label: {
Text("정지")
}
.buttonStyle(.bordered)
.disabled(!isPlaying)
}
// ...
}
}
}
AudioManager의 변경 사항
- AudioFile 즉 ManagedObject를 생성하는 순간 바로 Context에 저장이 되어서, 녹음을 시작할 때는 정보만 담아 놓고 녹음이 끝났을 때 AudioFile을 생성하도록 하였습니다.
- AudioFileInfo 구조체 생성
- currentAudioFileInfo 변수 사용
- 녹음을 시작할 때 currentAudioFileInfo 에 각종 정보 저장
- 녹음을 끝낼 때 currentAudioFileInfo을 이용해 실제 AudioFile 생성 및 값들 넣어주기
// 오디오 파일을 생성하지 않고 정보만 담아주는 구조체
struct AudioFileInfo {
var id: UUID?
var name: String?
var urlString: String?
var duration: Double?
var creator: String?
var date: Date?
}
final class AudioManager: NSObject, ObservableObject {
// ...
var currentAudioFileInfo = AudioFileInfo()
// ...
func startRecording() {
let currentDate = Date() // 녹음 시작 시간 저장
let nameFormatter = DateFormatter()
nameFormatter.dateFormat = "H시 mm분"
// ...
// 녹음을 시작하면 오디오 정보를 저장
currentAudioFileInfo.name = nameFormatter.string(from: currentDate)
currentAudioFileInfo.urlString = audioFilename.formatted()
currentAudioFileInfo.id = UUID()
currentAudioFileInfo.date = currentDate
currentAudioFileInfo.creator = "dunno" // TODO: 생성자 정보 넣기
// ...
}
// ...
func finishRecording() {
let duration = audioRecorder.currentTime // 녹음 파일 시간 저장
audioRecorder.stop()
// 실제 CoreData에 AudioFile 생성
let newAudio = AudioFile(context: PersistenceController.shared.container.viewContext)
newAudio.name = currentAudioFileInfo.name! + " from 누군가"
newAudio.url = currentAudioFileInfo.urlString
newAudio.id = currentAudioFileInfo.id
newAudio.creator = currentAudioFileInfo.creator
newAudio.date = currentAudioFileInfo.date
newAudio.duration = duration
// ...
}
}
남아있는 질문들
- ManagedObject를 생성할 때 바로 저장하지 않도록 하는 방법?
- 뷰 계층으로 들어갈 때 계속해서 environment를 넣어주는 구조가 괜찮은 것인지? 매번 새로운 @Environment 변수를 생성하는 방법 말고 다른 방법은?
- Observed와 Observable을 제대로 사용하는 법!
- 사용자가 직접 파일 시스템에서 삭제한 경우에 어떻게 CoreData에 연동하지????
대답한 질문들
- 오디오 파일들을 저장하고 가져오는 등 관리할 방법이 필요한데, 어떻게 하지?
→ 실제 저장 및 불러오는 과정은 파일시스템에서 이뤄지는 게 맞다. 그런데 CoreData를 통해서 기억할 정보(메타데이터)를 다루면 훨씬 편리하다. 파일들의 목록과 그 정보들을 CoreData를 가지고 관리하고 실제 불러오고 생성할 때만 파일 시스템을 사용하면 된다.
이전 이야기
https://mila00a.tistory.com/56
개인 프로젝트 기록 - 3 (Recording)
녹음 테스트 재생을 테스트 했으니, 음성 메시지를 녹음할 수 있도록 그 기능을 구현해 보는 것이 목표 목표 달성 AudioSession 카테고리 설정 (앞선 설정과 동일) /// 재생 + 녹음 (playAndRecord) 모드 try
mila00a.tistory.com
다음 이야기
...
'iOS > 프로젝트' 카테고리의 다른 글
개인프로젝트 - 5 (SwiftLint) (0) | 2023.04.17 |
---|---|
개인 프로젝트 기록 - 4 (MessageUI) (0) | 2023.04.12 |
개인 프로젝트 기록 - 2 (Recording) (0) | 2023.04.01 |
개인 프로젝트 기록 - 1 (Ear Speaker) (0) | 2023.04.01 |
개인 프로젝트 기록 - 0 (시작) (0) | 2023.04.01 |