iOS/UIKIT

코어 데이터 사용해보기

밤새는 탐험가89 2024. 7. 11. 06:25

 

 

구현 내용 

  • 영화 리스트를 길게 누르면 해당 영화를 저장한다. 
  • 앱을 껐다 켜도 그 내용이 사라지지 않게 코어 데이터에 저장한다. 

 

구현 코드 

  • 코어 데이터의 Entities를 지정한다.

 

  • 이 때 해당 내용은 데이터 모델과 같아야 한다

(같지 않아도 되지만, 뭐 여기선 그렇게 한다.)

 

import Foundation

struct TrendingTitleResponse: Codable {
    let results: [Title]
}

struct Title: Codable {
    let id: Int
    let media_type: String?
    let original_name: String?
    let original_title: String?
    let poster_path: String?
    let overview: String?
    let vote_count: Int
    let release_date: String?
    let vote_average: Double
}

 

 

  • 코어 데이터의 데이터를 처리할 함수를 구현한다. 
import Foundation
import UIKit
import CoreData

class DatePersistenceManager {
    
    enum DatabaseError: Error {
        case failedToSave
        case failedToFetchData
        case failedToDeleteData
    }
    
    static let shared = DatePersistenceManager()
    
    func downloadTitleWith(model: Title, completion: @escaping (Result<Void, Error>) -> Void) {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
        
        let context = appDelegate.persistentContainer.viewContext
        
        // 먼저 이미 존재하는지 확인
        let request: NSFetchRequest<TitleItem> = TitleItem.fetchRequest()
        request.predicate = NSPredicate(format: "id == %d", model.id)
        
        do {
            let existingTitles = try context.fetch(request)
            if let existingTitle = existingTitles.first {
                // 이미 존재하면 업데이트
                existingTitle.original_title = model.original_title
                existingTitle.original_name = model.original_name
                existingTitle.overview = model.overview
                existingTitle.media_type = model.media_type
                existingTitle.poster_path = model.poster_path
                existingTitle.release_date = model.release_date
                existingTitle.vote_count = Int64(model.vote_count)
                existingTitle.vote_average = model.vote_average
            } else {
                // 존재하지 않으면 새로 생성
                let item = TitleItem(context: context)
                item.id = Int64(model.id)
                item.original_title = model.original_title
                item.original_name = model.original_name
                item.overview = model.overview
                item.media_type = model.media_type
                item.poster_path = model.poster_path
                item.release_date = model.release_date
                item.vote_count = Int64(model.vote_count)
                item.vote_average = model.vote_average
            }
            
            try context.save()
            completion(.success(()))
        } catch {
            completion(.failure(DatabaseError.failedToSave))
        }
    }
    
    func fetchingTitlesFromDataBase(completion: @escaping (Result<[TitleItem], Error>) -> Void) {
        
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
        
        let context = appDelegate.persistentContainer.viewContext
        
        let request: NSFetchRequest<TitleItem>
        
        request = TitleItem.fetchRequest()
        
        do {
            let titles = try context.fetch(request)
            completion(.success(titles))
        } catch {
            completion(.failure(DatabaseError.failedToFetchData))
        }
    }
    
    func deleteTitleWith(model: TitleItem, completion: @escaping (Result<Void, Error>) -> Void) {
        
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
        
        let context = appDelegate.persistentContainer.viewContext
        
        context.delete(model)
        
        do {
            try context.save()
            completion(.success(()))
        } catch {
            completion(.failure(DatabaseError.failedToDeleteData))
        }
    }
}

 

 

  • 클래스 구조: DatePersistenceManager는 싱글톤 패턴을 사용하여 구현했다.
    • (static let shared = DatePersistenceManager()). 이는 앱 전체에서 하나의 인스턴스만 사용하도록 보장한다.
  • 에러 처리: DatabaseError 열거형을 정의하여 데이터베이스 작업 중 발생할 수 있는 오류를 명시적으로 처리한다.
  • CRUD 작업: a. Create (downloadTitleWith):
    • Title 모델을 받아 Core Data의 TitleItem 엔티티로 변환하여 저장한다.
    • AppDelegate를 통해 Core Data 컨텍스트에 접근하고 모든 속성을 설정한 후 컨텍스트를 저장한다.
    • 작업 완료 후 결과를 completion handler를 통해 비동기적으로 반환한다.
    b. Read (fetchingTitlesFromDataBase):
    • 저장된 모든 TitleItem을 가져오는데, NSFetchRequest를 사용하여 쿼리를 실행한다.
    • 결과를 completion handler를 통해 비동기적으로 반환한다.
    c. Delete (deleteTitleWith):
    • 주어진 TitleItem 모델을 Core Data 컨텍스트에서 삭제한다.
    • 변경사항을 저장하고 결과를 completion handler를 통해 반환한다.
  • 비동기 처리:
    • 모든 메서드는 @escaping closure를 사용하여 비동기적으로 결과를 반환하고, 이는 데이터베이스 작업이 메인 스레드를 차단하지 않도록 한다.
  • 에러 핸들링:
    • 각 작업에서 do-catch 블록을 사용하여 오류를 포착하고 적절한 DatabaseError를 반환했다.
  • Core Data 스택 사용:
    • AppDelegate의 persistentContainer를 통해 Core Data 스택에 접근한다.

 

 

  • 컬렉션 뷰의 아이템이 있는 파일 안에 코어데이터 함수를 호출한다.
import UIKit

class CollectionViewTableViewCell: UITableViewCell {
    
    ...
    
    private func downloadTitleAt(indexPath: IndexPath) {
        
        DatePersistenceManager.shared.downloadTitleWith(model: titles[indexPath.row]) { result in
            switch result {
            case .success():
                NotificationCenter.default.post(name: NSNotification.Name("downloaded"), object: nil)
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }
}

extension CollectionViewTableViewCell: UICollectionViewDelegate, UICollectionViewDataSource {
    
    ...
    
    func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? {
        
        guard let indexPath = indexPaths.first else {
            return nil // 유효한 IndexPath가 없으면 nil 반환
        }
        
        let config = UIContextMenuConfiguration(
            identifier: nil,
            previewProvider: nil) { [weak self] _ in
                let downloadAction = UIAction(
                    title: "Download",
                    image: UIImage(systemName: "arrow.down.circle"),
                    identifier: nil,
                    discoverabilityTitle: nil,
                    state: .off) { _ in
                        self?.downloadTitleAt(indexPath: indexPath)
                    }
                return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: [downloadAction])
            }
        return config
    }
}

 

 

private func downloadTitleAt(indexPath: IndexPath) {

    DatePersistenceManager.shared.downloadTitleWith(model: titles[indexPath.row]) { result in
        switch result {
        case .success():
            NotificationCenter.default.post(name: NSNotification.Name("downloaded"), object: nil)
        case .failure(let error):
            print(error.localizedDescription)
        }
    }
}

 

 

 

  • 함수 목적:
    • 이 함수는 특정 인덱스 패스에 해당하는 타이틀(영화나 TV 쇼 등)을 다운로드(또는 저장)하는 역할을 한다.
  • 매개변수:
    • indexPath: 다운로드할 타이틀의 위치를 나타내는 IndexPath 객체다.
  • 구현 내용:
    • DatePersistenceManager.shared.downloadTitleWith(model:completion:) 메서드를 호출한다.
    • titles[indexPath.row]를 통해 해당 인덱스의 타이틀 모델을 전달한다.
  • 비동기 처리:
    • downloadTitleWith 메서드는 비동기적으로 동작하며, 완료 시 클로저를 통해 결과를 반환한다.
  • 결과 처리:
    • 성공 시: NotificationCenter를 통해 "downloaded" 라는 이름의 알림을 발송한다.
    • 실패 시: 에러 메시지를 콘솔에 출력한다.
  • 알림 사용:
    • "downloaded" 알림을 발송함으로써, 앱의 다른 부분(예: UI 업데이트)에 다운로드 완료를 알릴 수 있다.

 

 

import UIKit

class DownloadsViewController: UIViewController {
    
    // MARK: Variables
    private var titles: [TitleItem] = []

    // MARK: UI Components
    private let downloadedTable: UITableView = {
        let table = UITableView()
        table.register(TitleTableViewCell.self, forCellReuseIdentifier: TitleTableViewCell.identifier)
        return table
    }()
    
    // MARK: Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        title = "Downloads"
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationController?.navigationItem.largeTitleDisplayMode = .always
        
        view.addSubview(downloadedTable)
        downloadedTableDelegate()
        fetchLocalStorageForDownload()
        
        NotificationCenter.default.addObserver(forName: NSNotification.Name("downloaded"), object: nil, queue: nil) { _ in
            self.fetchLocalStorageForDownload()
        }
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        downloadedTable.frame = view.bounds
    }
    
    // MARK: Functions
    private func downloadedTableDelegate() {
        downloadedTable.delegate = self
        downloadedTable.dataSource = self
    }
    
    private func fetchLocalStorageForDownload() {
        DatePersistenceManager.shared.fetchingTitlesFromDataBase { [weak self] result in
            switch result {
            case .success(let titles):
                DispatchQueue.main.async {
                    self?.titles = titles
                    self?.downloadedTable.reloadData()
                }
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }
}

// MARK: Extensions
extension DownloadsViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return titles.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        guard let cell = tableView.dequeueReusableCell(withIdentifier: TitleTableViewCell.identifier, for: indexPath) as? TitleTableViewCell else { return UITableViewCell() }
        
        let title = titles[indexPath.row]
        cell.configure(with: TitleViewModel(titleName: (title.original_title ?? title.original_name) ?? "UnKnown", posterURL: title.poster_path ?? ""))
        
        // 셀을 선택할 때 회색 배경 비활성화된다.
        cell.selectionStyle = .none
        return cell
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 140
    }
    
    
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        switch editingStyle {
        case .delete:
            DatePersistenceManager.shared.deleteTitleWith(model: titles[indexPath.row]) { result in
                switch result {
                case .success():
                    print("Deleted from the databse")
                case .failure(let error):
                    print(error.localizedDescription)
                }
                self.titles.remove(at: indexPath.row)
                tableView.deleteRows(at: [indexPath], with: .fade)
            }
        default:
            break;
        }
    }
}

 

 

 

 

private func fetchLocalStorageForDownload() {
    DatePersistenceManager.shared.fetchingTitlesFromDataBase { [weak self] result in
        switch result {
        case .success(let titles):
            DispatchQueue.main.async {
                self?.titles = titles
                self?.downloadedTable.reloadData()
            }
        case .failure(let error):
            print(error.localizedDescription)
        }
    }
}

 

 

  • 목적:
    • 이 메서드는 로컬 데이터베이스(Core Data)에서 저장된 타이틀들을 가져오는 역할을 한다.
  • 구현:
    • DatePersistenceManager.shared.fetchingTitlesFromDataBase를 호출하여 데이터를 비동기적으로 가져온다.
    • 결과를 받으면 성공 또는 실패에 따라 다르게 처리하낟.
  • 성공 시 처리:
    • 메인 스레드에서 UI 업데이트를 수행한다.
    • 가져온 타이틀들을 self.titles에 저장하고, downloadedTable을 리로드하여 UI를 업데이트합니다.
  • 실패 시 처리:
    • 에러 메시지를 콘솔에 출력한다.
  • 메모리 관리:
    • [weak self]를 사용하여 강한 참조 순환을 방지한다.

 

 

 override func viewDidLoad() {
        super.viewDidLoad()
        ...
        fetchLocalStorageForDownload()
        
        NotificationCenter.default.addObserver(forName: NSNotification.Name("downloaded"), object: nil, queue: nil) { _ in
            self.fetchLocalStorageForDownload()
        }
    }

 

  • fetchLocalStorageForDownload() 함수를 viewDidLoad() 와 NotificationCenter 두 군데에서 호출한 이유 
  • viewDidLoad()에서의 호출:
    • 뷰 컨트롤러가 처음 로드될 때 저장된 데이터를 가져와 표시한다.
    • 이는 사용자가 화면에 진입했을 때 최신 데이터를 볼 수 있게 한다.
  • NotificationCenter 옵저버에서의 호출:
    • "downloaded" 알림이 발생했을 때 (즉, 새로운 항목이 다운로드되었을 때) 데이터를 다시 가져온다.
    • 이를 통해 다운로드된 새 항목이 즉시 목록에 반영된다.