iOS/프로젝트

개인 프로젝트 기록 - 3 (CoreData)

Mila00a 2023. 4. 1. 19:12

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

  1. 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

 

다음 이야기


...