[Xcode/SwiftUI] SwiftDataをざっくりと理解する

1. はじめに

1.1 対象読者と到達ゴール

対象

  • SwiftUIでローカル保存を始めたい初心者〜中級者
  • Core Dataは難しそうと感じている人
  • 小規模アプリで「一覧・追加・編集・削除」を素早く作りたい人

到達ゴール

  • SwiftDataとは何かを言語化して説明できること
  • 主要コンポーネント(@Model / ModelContainer / ModelContext / @Query / #Predicate)の役割を把握
  • 5分で動くメモアプリを自力で作れる(CRUDの流れがわかる)

前提知識が全くなくても問題なく進められるので、マイペースでゆっくりと理解を深めていきましょう🍵


2. そもそもSwiftDataとは

2.1 Core Dataとの関係と違い

SwiftData = Apple製の新しいデータ永続化レイヤ、内部でCore Dataを活用しつつSwiftらしい宣言的API

とまずはざっくり認識をしておきましょう。

ざっくりと違いを見てみる

  • @ModelSwiftの型に注釈を付けるだけでエンティティ定義OK(コード生成や.xcdatamodel不要)
  • @Queryビューが自動更新(フェッチ結果が状態のように扱える)
  • 型安全な述語 #Predicate補完が効く & 間違いにくい
  • 低ボイラープレートで学習コストが小さい

なので、まずはSwiftDataから入って、必要に応じてCore Dataの高度な機能を足すという方針でもOK


2.2 Swiftらしい宣言的データモデル(@Model

import SwiftData
import Foundation

@Model
final class Note {
    // 保存されるプロパティ(基本はOptionalでなくてOK)
    var title: String
    var body: String
    var createdAt: Date

    // 便利イニシャライザ
    init(title: String, body: String, createdAt: Date = .now) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
    }

    // ※ 計算プロパティは原則保存対象外(必要なら別名プロパティに保持)
    var previewText: String { body.prefix(40) + "..." }
}

ポイント

  • @Model を付けると、この型は永続化対象になる
  • String/Int/Date/Boolなどの基本型や、@Model同士のリレーションも扱える

2.3 自動フェッチ(@Query)と型安全な述語(#Predicate

import SwiftUI
import SwiftData

struct HomeView: View {
    // 作成日の降順で常に最新が上に表示される
    @Query(sort: [SortDescriptor(\Note.createdAt, order: .reverse)])
    private var notes: [Note]
    private var filteredNotes: [Note] {
        if keyword.isEmpty { return notes }

        let p = #Predicate<Note> {
            $0.title.contains(keyword) || $0.body.contains(keyword)
        }
        // Predicateの評価でエラーが起きる可能性があるため、do-catchで包む
        do {
            return try notes.filter(p)
        } catch {
            print("⚠️ Filtering failed with error: \(error)")
            return notes // fallback to unfiltered
        }
    }

    // 検索ワードで動的にフィルタする例
    @State private var keyword = ""

    var body: some View {
        List(filteredNotes) { note in
            VStack(alignment: .leading) {
                Text(note.title).font(.headline)
                Text(note.previewText).font(.subheadline).foregroundStyle(.secondary)
            }
        }
        .searchable(text: $keyword)
    }
}

ポイント

  • @Query常に最新のフェッチ結果を提供し、データ変更に応じてビューが再描画されるので便利
  • #Predicateコンパイル時チェックが効く述語。手書きの文字列クエリより安全

2.4 永続化の流れ:ModelContainerModelContextの役割

  • ModelContainer:アプリ全体の保管庫。どのモデル(@Model)を扱うか、どこに保存するか(オンディスク/インメモリ)を管理。
  • ModelContext:画面・機能単位の作業場insert/delete/save等を行う。SwiftUIでは環境値として注入。
@main
struct QuickMemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // アプリ全体にコンテナを用意し、Noteを保存対象にする
        .modelContainer(for: Note.self)
    }
}

struct ContentView: View {
    // その画面で使う“作業場”(DBのトランザクション的なもの)
    @Environment(\.modelContext) private var context
    @Query(sort: [SortDescriptor(\Note.createdAt, order: .reverse)]) private var notes: [Note]

    var body: some View {
        List {
            ForEach(notes) { note in
                Text(note.title)
            }
            .onDelete { indexSet in
                indexSet.compactMap { notes[$0] }.forEach(context.delete) // ← 削除
                // 基本は自動保存されるが、即時反映したい場面では save() も可能
                try? context.save()
            }
        }
        .toolbar {
            Button {
                let n = Note(title: "New", body: "Hello SwiftData")
                context.insert(n) // ← 追加
                // 多くのケースで自動保存に任せてOK。失敗ハンドリングしたい時は try? save()
            } label: {
                Label("Add", systemImage: "plus")
            }
        }
    }
}

// ポイント
自動保存 vs. 明示的save()
SwiftDataは多くの更新を自動的に保存する。
ただし「今このタイミングで確実に書き込みたい」「エラーハンドリングしたい」場合は try context.save() を呼ぶ、という住み分けが実務上Good。

2.5 どんな時に使うべきか(ユースケースと限界)

向いている例

  • 単一デバイスでの軽量〜中規模のローカル保存
  • ToDo、メモ、家計簿、学習ログ、オフラインキャッシュ 等
  • SwiftUIで素早くCRUDを作りたいとき

注意・限界

  • 非常に大規模なデータや、細かなパフォーマンス最適化が必要なケースでは、Core Dataや別ストレージの知識が必要になる場合あり
  • 非同期大量インポート/複雑な関連の整合性管理などは設計の工夫が求められる
  • iCloud同期は可能だが、同期要件が厳しい場合は事前検証をおすすめ

3. SwiftDataの主要コンポーネントをざっくり把握

まずは主要コンポーネントの紹介

  • @Model:保存対象の型を宣言(スキーマ定義をSwiftで書く感覚)
  • ModelContainer:アプリ全体の保存先やスキーマ集合を管理(オンディスク/インメモリ)
  • ModelContext:挿入・更新・削除・保存を行う作業場(環境値として受け取る)
  • @Query:ビューの入力としてのフェッチ結果(変更に追従)
  • #Predicate:型安全なフィルタ条件(補完が効き、リファクタに強い)
  • SortDescriptor:並び替え条件(@Queryにそのまま渡せる)

最小構成サンプル(コピペで動くはず、多分)

import SwiftUI
import SwiftData

// 1) モデル:これだけで永続化対象に
@Model
final class Note {
    var title: String
    var body: String
    var createdAt: Date
    init(title: String, body: String, createdAt: Date = .now) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
    }
}

// 2) Appエントリ:コンテナを用意
@main
struct QuickMemoApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
            .modelContainer(for: Note.self) // ← これだけでオンディスク保存が有効に
    }
}

// 3) 画面:Query+ContextでCRUD
struct ContentView: View {
    @Environment(\.modelContext) private var context  
    @Query(sort: [SortDescriptor(\Note.createdAt, order: .reverse)])
    private var notes: [Note]                         

    @State private var title = ""
    @State private var bodyText = ""

    var body: some View {
        NavigationStack {
            List {
                // 既存メモの表示
                ForEach(notes) { n in
                    VStack(alignment: .leading) {
                        Text(n.title).font(.headline)
                        Text(n.body).font(.subheadline).foregroundStyle(.secondary)
                    }
                }
                .onDelete { indexSet in
                    indexSet.compactMap { notes[$0] }.forEach(context.delete) // 削除
                    try? context.save() // 即時確定したいときに
                }

                // 追加フォーム
                Section("Add New") {
                    TextField("Title", text: $title)
                    TextField("Body", text: $bodyText, axis: .vertical)
                    Button {
                        guard !title.isEmpty else { return } // ざっくりバリデーション
                        context.insert(Note(title: title, body: bodyText)) // 追加
                        title = ""; bodyText = ""
                        // 自動保存に任せてもOK。必要なら try? context.save()
                    } label: {
                        Label("Save", systemImage: "tray.and.arrow.down")
                    }
                }
            }
            .navigationTitle("Quick Memo")
            .toolbar {
                // 並び替えや検索UIは後述。
                EditButton()
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Note.self, inMemory: true) // ← プレビュー専用、インメモリストアにすると、削除しても本体に影響なし
}

5分で作る:クイックメモ(実装手順)

4.1 プロジェクト作成とテンプレート選び(SwiftDataチェック有無)

  1. Xcode > File > New > Project… を開き、iOS > App(または「App」) を選択
  2. Interface は SwiftUI、言語は Swift を選択
  3. SwiftData(or Core Data)に関するチェックを入れる
  4. 作成後は最小構成でビルドが通ることを確認

4.2 モデル定義: Note(タイトル・本文・作成日)

import Foundation
import SwiftData

@Model
final class Note {
    // @Model を付けるだけで永続化対象に
    var title: String
    var body: String
    var createdAt: Date

    // init(作成日はデフォルトで現在時刻)
    init(title: String, body: String, createdAt: Date = .now) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
    }

    // 表示用の計算プロパティ(保存はされない)
    var previewText: String {
        // 先頭40文字+…
        let head = body.prefix(40)
        return head + (body.count > 40 ? "…" : "")
    }
}

4.3 ModelContainer@main に組み込む

import SwiftUI
import SwiftData

@main
struct QuickMemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // アプリ全体の「保管庫」を用意。Note を保存対象に登録
        .modelContainer(for: Note.self)
    }
}

4.4 一覧画面:@Query でメモ表示(ソート付き)

import SwiftUI
import SwiftData

struct ContentView: View {
    // 画面で使う作業場(挿入・削除・保存を行う)
    @Environment(\.modelContext) private var context

    // 作成日が新しい順に並べる(常に最新状態へ自動更新)
    @Query(sort: [SortDescriptor(\Note.createdAt, order: .reverse)])
    private var notes: [Note]

    // 追加用シートの表示フラグ
    @State private var showAddSheet = false
    // 編集対象(nil で未選択)。SwiftDataの @Model は Identifiable 扱い可
    @State private var editingNote: Note?

    var body: some View {
        NavigationStack {
            List {
                ForEach(notes) { note in
                    VStack(alignment: .leading, spacing: 4) {
                        Text(note.title).font(.headline)
                        Text(note.previewText).font(.subheadline).foregroundStyle(.secondary)
                    }
                    .contentShape(Rectangle())               // 行全体をタップ領域に
                    .onTapGesture { editingNote = note }     // タップで編集シート表示
                    .swipeActions {
                        Button(role: .destructive) {
                            context.delete(note)             // 左スワイプで削除
                            try? context.save()              // 即反映したいので明示保存(任意)
                        } label: {
                            Label("Delete", systemImage: "trash")
                        }
                    }
                }
            }
            .navigationTitle("Quick Memo")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showAddSheet = true
                    } label: {
                        Label("Add", systemImage: "plus")
                    }
                }
                ToolbarItem(placement: .topBarLeading) {
                    EditButton() // まとめて削除したい時に便利
                }
            }
            // 追加&編集の共通エディタ
            .sheet(isPresented: $showAddSheet) {
                NoteEditorView(note: nil)                   // 新規作成モード
            }
            .sheet(item: $editingNote) { note in
                NoteEditorView(note: note)                  // 既存編集モード
            }
        }
    }
}

4.5 追加/編集/削除(insert/直接更新/delete

追加・編集を担うシートの最小実装

  • note == nil → 新規作成として insert
  • note != nil → 既存の値を書き換え(直接更新でOK。参照型なので値を代入すれば反映)
  • 保存は自動保存に任せても大半はOKですが、ここでは「確実にこのタイミングで反映したい」ため try? context.save() を呼んでいる
// NoteEditorView.swift
import SwiftUI
import SwiftData

struct NoteEditorView: View {
    // 既存なら編集、nil なら新規作成
    let note: Note?

    @Environment(\.dismiss) private var dismiss
    @Environment(\.modelContext) private var context

    // フォームの編集用一時状態(表示時に初期化)
    @State private var title: String
    @State private var bodyText: String

    init(note: Note?) {
        self.note = note
        _title = State(initialValue: note?.title ?? "")
        _bodyText = State(initialValue: note?.body ?? "")
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Title") {
                    TextField("メモのタイトル", text: $title)
                        .textInputAutocapitalization(.none)
                }
                Section("Body") {
                    TextField("本文", text: $bodyText, axis: .vertical)
                        .lineLimit(5, reservesSpace: true)
                }
            }
            .navigationTitle(note == nil ? "New Note" : "Edit Note")
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        // ざっくりバリデーション(タイトル必須)
                        guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }

                        if let note {
                            // 既存更新:プロパティを書き換えるだけでOK
                            note.title = title
                            note.body = bodyText
                        } else {
                            // 新規作成:モデルを生成して insert
                            let new = Note(title: title, body: bodyText)
                            context.insert(new)
                        }

                        // 明示保存(エラーハンドリングを簡略化)
                        try? context.save()
                        dismiss()
                    }
                    .bold()
                }
            }
        }
    }
}

4.6 自動保存と明示的 save() の使い分け

  • 自動保存に任せる
    • 小規模CRUDで、ユーザー操作のたびに即時のディスク反映が厳密に要らない場合
    • コード量が減り、間違いも起きにくい
  • 明示的に try context.save() を呼ぶ
    • 「この操作が完了したら確実に保存されていてほしい」とき(例:編集シートの保存ボタン、一括削除)
    • エラー処理を行いたいとき(do-catchでユーザーへアラート表示など)

実務では「UIの明確な完了アクション(保存・削除・確定)で save()」と覚えると運用が安定するかも。

4.7 Xcode Previews のためのインメモリ設定

プレビューではインメモリストアにすると、実機のデータを汚さずに試せる。

初期データを挿入しておくと、List の見た目確認も速いので便利。

#Preview {
    ContentView()
        .modelContainer(for: Note.self, inMemory: true)
}

// 発展Ver
#Preview("List Preview") {
    ContentView()
        .modelContainer(for: Note.self, inMemory: true)
        .task {
            // デモ用の初期データを投入(プレビュー表示時のみ)
            // modelContext は環境値から取り出せるので、軽く遅延して挿入
            await MainActor.run {
                let context = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?
                    .windows.first?.rootViewController?
                    .view?.environment(\.modelContext)
            }
        }
}

5. Tips:設計と拡張のコツ

5.1 リレーションの入門(Folder–Note の一対多)

目的Folder に複数の Note をぶら下げ、フォルダ単位でメモを管理する。

@RelationshipdeleteRuleinverse を付けると意図が明確になり、削除連鎖も安全に扱えるのでGood。

import SwiftData

@Model
final class Folder {
    var name: String
    // フォルダ削除時に配下ノートも一緒に削除したい → .cascade
    // 逆関係は Note.folder であることを明示
    @Relationship(.cascade, inverse: \Note.folder)
    var notes: [Note] = []

    init(name: String) { self.name = name }
}

@Model
final class Note {
    var title: String
    var body: String
    var createdAt: Date
    // 親フォルダ(無所属も許すなら optional)
    var folder: Folder?

    init(title: String, body: String, createdAt: Date = .now, folder: Folder? = nil) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
        self.folder = folder
    }
}

UIで「フォルダのノートだけ」を効率よく出す

@Query は初期化時に述語を渡せます。引数で受け取った folder に合わせて動的にクエリを組み立てていくとGood(persistentModelID を比較すると安定)。

import SwiftUI
import SwiftData

struct FolderNotesView: View {
    let folder: Folder
    @Environment(\.modelContext) private var context

    // 引数に基づく動的クエリ(作成日の降順)
    @Query private var notes: [Note]
    init(folder: Folder) {
        self.folder = folder
        let pred = #Predicate<Note> { $0.folder?.persistentModelID == folder.persistentModelID }
        _notes = Query(filter: pred, sort: [SortDescriptor(\Note.createdAt, order: .reverse)])
    }

    var body: some View {
        List(notes) { note in
            VStack(alignment: .leading) {
                Text(note.title).font(.headline)
                Text(note.body).foregroundStyle(.secondary).lineLimit(1)
            }
        }
        .navigationTitle(folder.name)
        .toolbar {
            Button {
                context.insert(Note(title: "New in \(folder.name)", body: "", folder: folder))
                try? context.save()
            } label: { Label("Add", systemImage: "plus") }
        }
    }
}

ポイント

  • @Queryfilter/sort 初期化子で型安全な述語#Predicate)とソートを指定できる
  • initQuery を組むパターンは、動的フィルタページングで便利

5.2 スキーマ変更の考え方(軽いマイグレーションの指針)

意識すべきは軽量(自動)マイグレーションで済ませること。代表的な安全手は以下。

  • プロパティの追加var isPinned: Bool = false のようにデフォルト値を付ける(または Optional)
  • プロパティ名の変更@Attribute(originalName: "oldName") を付ける
  • 関係の追加:Optional で始め、UI側で徐々に埋まる前提にする

例:プロパティのリネーム

@Model
final class Note {
    // 以前は "title" だったが "headline" にリネームしたい
    @Attribute(originalName: "title")
    var headline: String

    var body: String
    var createdAt: Date
    var folder: Folder?
}

5.3 パフォーマンスとデータ量の扱い(フェッチ最適化の基本)

基本戦略

  • 最初から絞って取る:述語(#Predicate)とソートで必要な行だけ
  • ページング:大量データは FetchDescriptorfetchLimit 等を設定し小分け取得
  • 大きなバイナリは外出し:画像などは @Attribute(.externalStorage) を使いDB本体を軽く
  • @Query は画面用、重い処理は手動 fetch:集計や一括操作は try context.fetch(_:) に寄せる。

例:最新50件だけ取得する手動フェッチ

let sort = [SortDescriptor(\Note.createdAt, order: .reverse)]
var desc = FetchDescriptor<Note>(predicate: nil, sortBy: sort)
desc.fetchLimit = 50                  // ← ページングの基本
let recent = try? context.fetch(desc) // 必要分だけ読み込む

例:画像を外部保存で軽くする

@Model
final class Attachment {
    @Attribute(.externalStorage)      // ← DB外に退避
    var imageData: Data?
}

5.4 iCloud 同期を見据える場合の注意点

SwiftData は CloudKit で自動同期が可能。最小手順は次の通り:

  1. ターゲットの CapabilitiesiCloud → CloudKit を有効にし、コンテナIDを設定。
  2. ModelContainer 構築時に cloudKitContainerIdentifier を渡す。
  3. 同期要件に合わせてモデル定義をクラウド向けに調整

コンテナの設定例

import SwiftData

@main
struct QuickMemoApp: App {
    // 明示的にCloudKitコンテナIDを指定(例)
    private let container: ModelContainer = {
        try! ModelContainer(
            for: [Folder.self, Note.self],
            .init(cloudKitContainerIdentifier: "iCloud.com.example.QuickMemo")
        )
    }()

    var body: some Scene {
        WindowGroup { ContentView() }
            .modelContainer(container)
    }
}

CloudKit向けモデルのコツ(よくある落とし穴)

  • デフォルト値を付ける:非Optionalなら必ず初期値を(クラウド取り込みで要求される)
  • 関係は Optional を基本に:一部構成では必須関係がエラーの原因になり得る
  • ユニーク制約は避ける:CloudKit統合ではunique制約が不許可(必要ならアプリ側で整合)
  • 衝突(競合):端末間の同時編集に備え、最終編集日時や**“勝ちルール”**を決める

6. トラブルシュート

6.1 @Query が更新されない/プレビューで挙動が違う

まずはここをチェック

  • 同じ ModelContainer を共有しているか
  • App本体とプレビューで別々のコンテナを作っていないか: レビューでは同じスキーマ配列inMemory: true を使うのが定石
  • @Query のソート/フィルタが厳しすぎないか
  • “ダブル注入”していないか: 画面側でさらに .modelContainer(for:) を付けると別データベースになる

6.2 競合や同時編集での注意

起こりやすい場面

  • iPadの複数ウインドウ、Macの複数ウインドウiCloud同期中に別デバイスでも編集、など。

実務での基本戦略

// 最終更新日時を持たせる
@Model final class Note {
  var title: String; var body: String
  var createdAt: Date; var updatedAt: Date = .now
}

// 保存前に updatedAt = .now。エディタ起動時のスナップショット時刻と異なる=他所で更新→ユーザーに「上書き / 取り消して再読み込み / マージ(追記)」
// “編集シートの完了時に明示 save()”:競合があればその時点で捕捉しやすく、ユーザーに説明できる(エラー→再読み込み)
// 粒度の小さいドメインモデル:巨大1モデルに詰め込むより、分割して競合率を下げる

6.3 既存データの取り込み時の落とし穴

よくある失敗

  • 一気に何万件も insert
  • 重複生成
  • 巨大バイナリ(画像・PDF)を直格納
  • 長大な初回フェッチで固まる

対策

  • 小さなバッチに分け、バッチごとに save()。UIはプログレス表示
  • 取り込みデータに外部IDを持たせて uniqueKey 的に扱い、#Predicate で存在チェックしてからInsert
  • モデルを分け、バイナリは必要時に読み込む/ファイル外部保存を使う(DB肥大化防止)
  • 最初は fetchLimit 付きで最近分だけを表示し、スクロールでページング。

まとめ

いきなり全ての機能、仕組みを理解しようとするとパンクするので、データフェッチのTipsと同じく分割して理解することをおすすめします。

データ管理ロジックは慣れが大切だと個人的に感じているので、今回紹介した簡易メモアプリのように、何か小さくても動くアプリを一個作って、そこから機能を派生させて学んでいくとより理解が深まるのでぜひ今回のサンプルも活用してよりよいSwiftDataライフをお過ごしください ☕️


参考リンク