はじめに

こんにちは!システム開発部のYです。

WWDC2023で、SwiftDataが発表されました。iOS17から利用することができる、アプリ内DBです。
https://developer.apple.com/xcode/swiftdata/

アプリ内DBは他にもあり、CoreDataや外部ライブラリのRealmなどがあります。SwiftDataは、SwiftUIのように使いやすくしたCoreDataとされ、実質的なCoreDataの後継となります。そもそも筆者はCoreDataも使用したことがないので、この際CoreDataの学習の意味も兼ねて、CoreDataについての記事も書くことにしました。今回はCoreData編とします。

環境

  • Swift Version: 5.9
  • Xcode Version: 15
  • iOS: 17.0
  • macOS: 13.5

目次

CoreDataとは

モデルオブジェクトを永続化できるフレームです。
オブジェクトとRDB(リレーショナルデータベース)の橋渡し役を担っています。
CoreDataは、iOS3からあり長年使用されてきたフレームワークです。https://developer.apple.com/documentation/coredata/

CoreDataの使い方、サンプルの実装

簡単なTODOアプリを実装します。
Titleと説明を入力するとCoreDataに保存し保存されたデータを取得、リストに表示します。
そしてデータはアプリを閉じた後も表示されます。また、検索バーで値の絞り込み機能も実装します。

CoreData使用準備

データモデルの作成

プロジェクトは作成済とします。
xcdatamodeldの作成を行います。「File → New → File…」から Data Modelの作成を行います。
名前はSampleModelとします。

データモデルの設計

作成したデータモデルにEntityを追加します。
SampleModelを開き、左下のAdd Entityを選択します。


Entitiesが作成されるので名前を今回は「ToDo」という名前にします。
ToDoアプリということなので、今回は以下の5つのAttributeを追加します。
・id: 識別するための一意になる値(UUID)
・title: タイトル(String)
・desc: 説明(String)
・createdTime: 作成時間(Date)

・checked: 完了済みかどうか(Bool)

NSManagedObjectの作成

EntitiesのCodegenを「Manual/None」に変更します。
※デフォルトは、「Class Definision」です。Class Definisionの場合自動的にエンティティの派生クラスが作成されます。カスタマイズをしなければこのままで問題ありません。
Editor → Create NSManagedObject Subclass… を選択して、NSManagedObjectファイルの作成を行います。
1つのDataModelに対して、2つのファイルが作成されます。

NSPersistentContainerの作成と、Storeの読み込む

使用する場所で直接書く事はできますが、今回はPersistenceControllerとして切り出したいと思います。
CoreDataを使用するので、もちろんimport CoreDataをする必要があります。
そして、NSPersistentContainerを宣言しinitでコンテナの初期化、ストアの読み込みと設定を行います。

PersistenceController.swift

import CoreData

struct PersistenceController {
        
    /// Core Dataの永続データストアにアクセスするためのコンテナ
    let container: NSPersistentContainer
    
    init() {
        // Core Dataコンテナの初期化
        container = NSPersistentContainer(name: "SampleModel")
        
        // 永続ストアの読み込みと設定
        container.loadPersistentStores { storeDescription, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
    }
}

NSPersistentContainerをViewで使いまわせる様にする

(プロジェクト名)App.swiftファイルに追加します。

ContentViewで使用するので、使いまわせるように@Environment(\.managedObjectContext)にNSManagedObjectContextを登録する処理を記載します。

SampleCoreDataApp.swift

import SwiftUI

@main
struct SampleCoreDataApp: App {
    
    let persistenceController = PersistenceController()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

データの取得、保存、削除

データの取得(ソート無し)

SwiftUIでは、大変便利なプロパティラッパー(@FetchRequest)が用意されています。
@FetchRequestでデータ取得を行います。プロパティに検索結果が格納され、データの変更に応じて検索結果が常に更新されます。
sortDescriptors(検索結果のソート順)に何もしていない為、ソートはされません。

     @FetchRequest(
        entity: ToDo.entity(),
        sortDescriptors: [],
        predicate: nil,
        animation: .default)
    var toDoList: FetchedResults<ToDo>

FetchRequestについては以下を参照
https://developer.apple.com/documentation/swiftui/fetchrequest

データの取得(ソートあり)

sortDescriptors(検索結果のソート順)を指定します。以下は、createdTimeの昇順で並び替えを行なっています。

    @FetchRequest(
        entity: ToDo.entity(),
        sortDescriptors: [NSSortDescriptor(keyPath: \ToDo.createdTime, ascending: true)],
        predicate: nil,
        animation: .default)
    var toDoList: FetchedResults<ToDo>

またソート順の指定を重ねることもできます。

データの取得(抽出条件の指定)

抽出条件は、predicateにNSPredicateを指定します。以下は、titleがiOSのみのデータを取得しています。

    @FetchRequest(
        entity: ToDo.entity(),
        sortDescriptors: [],
        predicate: NSPredicate(format: "title == 'iOS'"),
        animation: .default)
    var toDoList: FetchedResults<ToDo>

以下は、titleにiOSを含んでいるデータのみ取得しています。

    @FetchRequest(
        entity: ToDo.entity(),
        sortDescriptors: [],
        predicate: NSPredicate(format: "title CONTAINS %@", "iOS"),
        animation: .default)
    var toDoList: FetchedResults<ToDo>

抽出条件のバリエーションは以下のサイトが参考になりました。
https://hajihaji-lemon.com/swift/coredata-nspredicate/

また抽出条件の指定も重ねることができます。

データの保存

NSManagedObjectの派生クラスのインスタンスを生成をし、NSManagedObjectContextに変更を保存することで実現できます。
保存するには、NSManagedObjectContextsaveメソッドを使用します。

@Environment(\.managedObjectContext) var viewContext

    private func addItem() {

        // インスタンスを生成
        let addToDo = ToDo(context: viewContext)
        addToDo.id = UUID()
        addToDo.title = inputToDoModel.title
        addToDo.desc = inputToDoModel.desc
        addToDo.createdTime = Date()

        // データベースファイルに保存    
        try? viewContext.save()                
    }

データの削除

NSManagedObjectContextdeleteメソッドを使用します。
引数に削除するエンティティのインスタンスを指定します。
deleteメソッドを呼んだ後に、データの保存時と同じようにsaveメソッドを呼ぶ必要があります。

    private func deleteItems(offsets: IndexSet) {
        offsets.map { toDoList[$0] }.forEach(viewContext.delete)
        
        try? viewContext.save()
    }

Previewについて

SwiftUIのPreview機能を使用する場合、現状のままだとCrashします。なので、Preview用の初期データを用意します。PersistenceControllerとして切り出したファイルに、Preview用のPersistenceControllerを追加します。

extension PersistenceController {
    
    /// Preview用
    static var previewContainer: PersistenceController = {
        let result = PersistenceController()
        let viewContext = result.container.viewContext
        for i in 0..<10 {
            let toDoItem = ToDo(context: viewContext)
            toDoItem.id = UUID()
            toDoItem.title = "title: \(i)"
            toDoItem.desc = "desc: \(i)"
            toDoItem.createdTime = Date()
        }
                
        do {
            try viewContext.save()
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
        
        return result
    }()
}

そして、Previewで上記のpreviewContainerを呼び出します。

#Preview {
    ContentView()
        .environment(\.managedObjectContext, PersistenceController.previewContainer.container.viewContext)
}

動的な抽出条件の実装(検索バー)

nsPredicateを変更すれば実現できます。

① 入力内容を監視するための変数を用意。

    @State private var filterText = ""


② 検索バーの実装、NavigationViewにsearchable(text:placement:)モディファイアを実装
※ iOS15以上から、下記参照
https://developer.apple.com/documentation/swiftui/view/searchable(text:placement:prompt:)-18a8f

        NavigationView {
             ...
        }
        .searchable(text: $filterText)

③ NavigationViewにonChangeモディファイアを実装し、変更を監視

        .onChange(of: filterText) { oldValue, newValue in
            filterToDoList(text: newValue)
        }

④ 入力されたテキストを元に、NSPredicateを変更します。

    private func filterToDoList(text: String) {
        
        if text.isEmpty {
            toDoList.nsPredicate = nil
        } else {
            toDoList.nsPredicate = NSPredicate(format: "title CONTAINS %@", text)
        }
    }

最終的なコード

import SwiftUI

class InputToDoModel: ObservableObject {
    @Published var title = ""
    @Published var desc = ""
}

struct ContentView: View {
    
    @Environment(\.managedObjectContext) var viewContext
    
    @FetchRequest(
        entity: ToDo.entity(),
        sortDescriptors: [NSSortDescriptor(keyPath: \ToDo.createdTime, ascending: true)],
        predicate: nil,
        animation: .default)
    var toDoList: FetchedResults<ToDo>
    
    @State private var isButtonPressed = false
    
    @State private var filterText = ""

    @ObservedObject private var inputToDoModel: InputToDoModel = .init()
    
    var body: some View {
        
        NavigationView {
            List {
                ForEach(toDoList) { toDo in
                    
                    if let title = toDo.title,
                       let desc = toDo.desc,
                       let createdAtDate = toDo.createdTime {
                        
                        HStack {
                            
                            Image(systemName: toDo.checked ? "checkmark.circle.fill" : "circle")
                            
                            VStack(alignment: .leading) {
                                Text(title)
                                Text(desc)
                                
                                HStack {
                                    Text("作成時間:")
                                    Text(createdAtDate, formatter: itemFormatter)
                                    
                                }
                            }
                        }
                        // contentShapeでタップ領域を広げる
                        .contentShape(Rectangle())
                        .onTapGesture {
                            toDo.checked.toggle()
                            try? viewContext.save()
                        }
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                
                ToolbarItem {
                    Button {
                        isButtonPressed.toggle()
                        
                    } label: {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
        .alert("入力してください。", isPresented: $isButtonPressed) {
            
            TextField("input your Title", text: $inputToDoModel.title)
            TextField("input your Description", text: $inputToDoModel.desc)
            
            Button("OK", role: .cancel){
                addItem()
            }
            Button("Cancel", role: .destructive){
                // Nop
            }
        }
        .searchable(text: $filterText)
        .onChange(of: filterText) { oldValue, newValue in
            filterToDoList(text: newValue)
        }
    }
    
    private func addItem() {
        let addToDo = ToDo(context: viewContext)
        addToDo.id = UUID()
        addToDo.title = inputToDoModel.title
        addToDo.desc = inputToDoModel.desc
        addToDo.createdTime = Date()
            
        try? viewContext.save()
                
        inputToDoModel.title = ""
        inputToDoModel.desc = ""
    }
        
    private func deleteItems(offsets: IndexSet)  {
        offsets.map { toDoList[$0] }.forEach(viewContext.delete)

        try? viewContext.save()
    }
    
    private func filterToDoList(text: String) {
        
        if text.isEmpty {            
            toDoList.nsPredicate = nil
        } else {
            toDoList.nsPredicate = NSPredicate(format: "title CONTAINS %@", text)
        }
    }
        
    private let itemFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.timeStyle = .medium
        return formatter
    }()
}

#Preview {
    ContentView()
        .environment(\.managedObjectContext, PersistenceController.previewContainer.container.viewContext)
}

まとめ

CoreDataの使い方などについてまとめました。データモデルはGUIで定義をするので、テーブル定義をGUIで行いたい方はいいと感じました。また、Core Dataはデータの永続性を自動的に処理してくれるので中身の処理を意識せずに開発できます。
しかし、データモデルの設計やCore Dataの設定について知らないといけないため、学習コストが高いと感じました。
この記事がお役に立てれば幸いです。また、間違っている点などありましたら、教えていただけると幸いです。
次の記事では、SwiftDataについて書こうと思います。

参考リンク



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる