axiom-swiftdata について
SwiftUI アプリの開発者向けに、Apple の永続化フレームワーク SwiftData の使い方を解説するスキルです。モデルやクエリに加え、CloudKit 同期、性能、並行処理のパターンも扱います。
主な機能
- 01SwiftData のモデルとコンテナ設定
- 02宣言的な絞り込み・並べ替えクエリ
- 03リレーションと削除ルール
- 04CloudKit 同期のパターンと制約
- 05性能と一括インポートの指針
- 06Swift 6 の並行処理パターン
活用シーン
- 01SwiftUI アプリに CRUD 機能を実装
- 02モデル間の関係とカスケード削除を設定
- 03CloudKit 同期の挙動を調査
- 04大規模データをページングまたはインポート
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftdataインストール後、Claude Code で/axiom-swiftdataを入力して読み込まれたか確認します。
詳細
SwiftData
概要
@Model クラスと宣言的なクエリを使用する、Apple 標準の永続化フレームワークです。Core Data を基盤とし、SwiftUI 向けに設計されています。
基本原則 参照型(class)+@Model マクロ+宣言的な @Query により、SwiftUI とリアクティブに連携します。
必要要件 iOS 17 以降、Swift 5.9 以降 対象 iOS 26 以降(このスキルでは最新機能に焦点を当てます) ライセンス プロプライエタリ(Apple)
SwiftData を使用する場合
次の用途には SwiftData を選んでください
- ✅ SwiftUI とのネイティブな Apple 連携
- ✅ シンプルな CRUD 操作
- ✅
@Queryによる UI の自動更新 - ✅ CloudKit 同期(iOS 17 以降)
- ✅ リレーションシップを持つ参照型(クラス)
次の場合は SQLiteData を使用してください
- 値型(struct)が必要な場合
- CloudKit レコード共有(同期のみではない)が必要な場合
- 特定のパフォーマンス要件を伴う大規模なデータセット(5 万件以上)
次の場合は GRDB を使用してください
- 複雑な生の SQL が必要な場合
- 細かなマイグレーション制御が必要な場合
マイグレーションについて VersionedSchema と SchemaMigrationPlan を使ったカスタムスキーママイグレーションについては、axiom-swiftdata-migration スキルを参照してください。マイグレーションのデバッグについては、axiom-swiftdata-migration-diag を参照してください。
プロンプトの例
このスキルが回答できるよう設計されている、開発者から寄せられる実際の質問です。
基本操作
1. 「フォルダーのあるメモアプリを作っています。フォルダーごとにメモを絞り込み、最終更新日時で並べ替えるには、@Query をどう設定すればよいですか?」
→ このスキルでは、述語や並べ替え、自動的なビュー更新とともに @Query を使う方法を説明します
2. 「ユーザーがタスクリストを削除したとき、すべてのタスクも自動的に削除されるようにしたいです。リレーションシップをどう設定すればよいですか?」
→ このスキルでは、deleteRule: .cascade を指定した @Relationship と逆リレーションシップについて説明します
3. 「User → Messages → Attachments のリレーションシップがあります。削除時に孤立したデータが残らないようにするにはどうすればよいですか?」
→ このスキルでは、カスケード削除、逆リレーションシップ、安全な削除パターンを紹介します
CloudKit と同期
4. 「チャットアプリで CloudKit を使ってメッセージをほかのデバイスと同期しています。ときどきメッセージが競合します。同期の競合にはどう対処すればよいですか?」
→ このスキルでは、CloudKit 連携、競合解決の戦略(最終書き込み優先、カスタム解決)、同期パターンを取り上げます
5. 「アプリに CloudKit 同期を追加しているのですが、'Property must have a default value' エラーが出ます。何が問題ですか?」
→ このスキルでは、CloudKit の制約(すべてのプロパティをオプショナルにするか、デフォルト値を設定する必要があること)と、その理由(ネットワークのタイミング)を説明し、修正方法を紹介します
6. 「データが iCloud と同期中であることや、オフライン時に何が起きるかをユーザーに表示したいです。」
→ このスキルでは、通知による同期状態の監視、ネットワーク接続の検出、オフラインを考慮した UI パターンを紹介します
7. 「ほかのユーザーとプレイリストを共有したいです。CloudKit のレコード共有を実装するにはどうすればよいですか?」
→ このスキルでは、所有者と権限の追跡、共有メタデータを含む CloudKit レコード共有パターン(iOS 26 以降)を取り上げます
パフォーマンスと最適化
8. 「5 万件のメッセージをクエリしつつ、一度に 20 件だけ表示したいです。効率よくページネーションするにはどうすればよいですか?」
→ このスキルでは、パフォーマンスパターン、バッチ取得、クエリの制限、チャンク単位のインポートによるメモリ肥大化の防止を取り上げます
9. 「アプリでリレーションシップを持つタスクを 100 件読み込むと、表示が遅くなります。N+1 クエリが原因だと思います。」
→ このスキルでは、プリフェッチなしで N+1 の問題を特定する方法、プリフェッチパターン、パフォーマンスを 100 倍向上させる方法を紹介します
10. 「API から 100 万件のレコードをインポートしています。メモリ不足にならずにバッチ処理する最善の方法は何ですか?」
→ このスキルでは、定期的な保存を伴うチャンク単位のインポート、メモリ解放パターン、バッチ処理の最適化を紹介します
11. 「どのプロパティにインデックスを追加すべきですか? インデックスを付けすぎると書き込みが遅くなるのではないかと心配です。」
→ このスキルでは、インデックス最適化のパターン(頻繁に絞り込みや並べ替えに使うプロパティに追加する場合、使用頻度が低いプロパティや頻繁に変更されるプロパティでは避ける場合)と、メンテナンスコストを説明します
レガシーフレームワークからのマイグレーション
12. 「Realm/Core Data から SwiftData に移行しています」
→ 下の「移行」セクションにある比較表を確認し、詳細なガイドについては realm-to-swiftdata-migration または axiom-swiftdata-migration に従ってください。
@Model の定義
基本モデル
import SwiftData
@Model
final class Track {
@Attribute(.unique) var id: String
var title: String
var artist: String
var duration: TimeInterval
var genre: String?
init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) {
self.id = id
self.title = title
self.artist = artist
self.duration = duration
self.genre = genre
}
}
主なパターン
structではなくfinal classを使用します(サブクラスが必要な場合はfinalを省略します。下の「クラスの継承」を参照してください)- 主キーのような動作には
@Attribute(.unique)を使用します(CloudKit 同期ではサポートされていません。下の「CloudKit の制約」を参照してください) - 明示的に
initを定義します(SwiftData は自動生成しません) - オプショナルプロパティ(
String?)は null 許容です - オブジェクトの削除後も値を保持するプロパティには
@Attribute(.preserveValueOnDeletion)を使用します(分析や監査証跡に便利です)
リレーションシップ
@Model
final class Track {
@Attribute(.unique) var id: String
var title: String
@Relationship(deleteRule: .cascade, inverse: \Album.tracks)
var album: Album?
init(id: String, title: String, album: Album? = nil) {
self.id = id
self.title = title
self.album = album
}
}
@Model
final class Album {
@Attribute(.unique) var id: String
var title: String
@Relationship(deleteRule: .cascade)
var tracks: [Track] = []
init(id: String, title: String) {
self.id = id
self.title = title
}
}
多対多の自己参照リレーションシップ
@MainActor // Required for Swift 6 strict concurrency
@Model
final class User {
@Attribute(.unique) var id: String
var name: String
// Users following this user (inverse relationship)
@Relationship(deleteRule: .nullify, inverse: \User.following)
var followers: [User] = []
// Users this user is following
@Relationship(deleteRule: .nullify)
var following: [User] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
重要:SwiftData は片側を変更すると、両側を自動的に管理します。
✅ 正しい — 片側だけを変更する
// user1 follows user2 (modifying ONE side)
user1.following.append(user2)
try modelContext.save()
// SwiftData AUTOMATICALLY updates user2.followers
// Don't manually append to both sides - causes duplicates!
❌ 誤り — 両側を手動で更新しないでください
user1.following.append(user2)
user2.followers.append(user1) // Redundant! Creates duplicates in CloudKit sync
フォロー解除(片側だけから削除する)
user1.following.removeAll { $0.id == user2.id }
try modelContext.save()
// user2.followers automatically updated
リレーションシップの整合性を検証する(デバッグ用)
// Check if relationship is truly bidirectional
let user1FollowsUser2 = user1.following.contains { $0.id == user2.id }
let user2FollowedByUser1 = user2.followers.contains { $0.id == user1.id }
// These MUST always match after save()
assert(user1FollowsUser2 == user2FollowedByUser1, "Relationship corrupted!")
CloudKit 同期の復旧(リレーションシップが破損した場合)
// If CloudKit sync creates duplicate/orphaned relationships:
// 1. Backup current state
let backup = user.following.map { $0.id }
// 2. Clear relationships
user.following.removeAll()
user.followers.removeAll()
try modelContext.save()
// 3. Rebuild from source of truth (e.g., API)
for followingId in backup {
if let followingUser = fetchUser(id: followingId) {
user.following.append(followingUser)
}
}
try modelContext.save()
// 4. Force CloudKit resync (in ModelConfiguration)
// Re-create ModelContainer to force full sync after corruption recovery
削除ルール
.cascade- 関連オブジェクトを削除します.nullify- リレーションシップを nil に設定します.deny- リレーションシップが存在する場合、削除を防ぎます.noAction- リレーションシップをそのままにします(注意が必要です!)
クラスの継承
SwiftData は階層モデルのクラス継承をサポートしています。明確な IS-A 関係(例:BusinessTrip IS-A Trip)があり、広範なクエリ(すべての旅行)と型固有のクエリの両方が必要な場合に使用します。
基底クラスとサブクラスのパターン
基底クラスとサブクラスの両方に @Model を適用します。基底クラスでは final を省略します。
@Model class Trip {
@Attribute(.preserveValueOnDeletion)
var name: String
var destination: String
var startDate: Date
var endDate: Date
@Relationship(deleteRule: .cascade, inverse: \Accommodation.trip)
var accommodation: Accommodation?
init(name: String, destination: String, startDate: Date, endDate: Date) {
self.name = name
self.destination = destination
self.startDate = startDate
self.endDate = endDate
}
}
@Model class BusinessTrip: Trip {
var purpose: String
var expenseCode: String
@Relationship(deleteRule: .cascade, inverse: \BusinessMeal.trip)
var businessMeals: [BusinessMeal] = []
init(name: String, destination: String, startDate: Date, endDate: Date,
purpose: String, expenseCode: String) {
self.purpose = purpose
self.expenseCode = expenseCode
super.init(name: name, destination: destination, startDate: startDate, endDate: endDate)
}
}
#Predicate による型ベースのクエリ
すべての基底クラスのインスタンス(サブクラスを含む)をクエリするか、型で絞り込みます。
// All trips (includes BusinessTrip, PersonalTrip, etc.)
@Query(sort: \Trip.startDate) var allTrips: [Trip]
// Only business trips — use `is` in #Predicate
@Query(filter: #Predicate<Trip> { $0 is BusinessTrip }) var businessTrips: [Trip]
// Filter on subclass-specific properties — use `as?` cast
let vacationPredicate = #Predicate<Trip> {
if let personal = $0 as? PersonalTrip {
return personal.reason == .vacation
}
return false
}
@Query(filter: vacationPredicate) var vacationTrips: [Trip]
ポリモーフィックなリレーションシップ
基底クラス型のリレーションシップには、複数のサブクラスのインスタンスを保持できます。
@Model class TravelPlanner {
var name: String
@Relationship(deleteRule: .cascade)
var upcomingTrips: [Trip] = [] // Can contain BusinessTrip and PersonalTrip
init(name: String) { self.name = name }
}
サブクラス固有のプロパティにアクセスするにはキャストします。
for trip in planner.upcomingTrips {
if let business = trip as? BusinessTrip {
print(business.expenseCode)
}
}
継承と代替手段の使い分け
| 判断基準 | 継承を使用 | 代わりに Enum/フラグを使用 |
|---|---|---|
| サブクラスが多くの基底プロパティを共有する | はい | — |
| すべてのモデルを対象とした型ベースのクエリが必要 | はい | — |
| サブクラスが独自のリレーションシップを持つ | はい | — |
| 区別するプロパティが1~2個だけ | — | はい |
| 特殊なプロパティだけをクエリする | — | はい |
| プロトコルへの準拠で十分 | — | はい |
階層は浅く保ってください(1~2階層)。深い継承チェーンはスキーマの移行やクエリを複雑にします。
ModelContainer の設定
SwiftUI アプリ
import SwiftUI
import SwiftData
@main
struct MusicApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Track.self, Album.self])
}
}
カスタム設定
let schema = Schema([Track.self, Album.self])
let config = ModelConfiguration(
schema: schema,
url: URL(fileURLWithPath: "/path/to/database.sqlite"),
cloudKitDatabase: .private("iCloud.com.example.app")
)
let container = try ModelContainer(
for: schema,
configurations: config
)
インメモリ(テスト)
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: schema,
configurations: config
)
SwiftUI でのクエリ
基本的な @Query
import SwiftUI
import SwiftData
struct TracksView: View {
@Query var tracks: [Track]
var body: some View {
List(tracks) { track in
Text(track.title)
}
}
}
自動更新 データが変更されると、ビューが更新されます。
絞り込み、並べ替え、組み合わせ
// Filtered
@Query(filter: #Predicate<Track> { $0.genre == "Rock" }) var rockTracks: [Track]
// Sorted (single)
@Query(sort: \.title, order: .forward) var tracks: [Track]
// Sorted (multiple descriptors)
@Query(sort: [SortDescriptor(\.artist), SortDescriptor(\.title)]) var tracks: [Track]
// Combined filter + sort
@Query(filter: #Predicate<Track> { $0.duration > 180 }, sort: \.title) var longTracks: [Track]
ModelContext の操作
ModelContext へのアクセス
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
// ...
}
CRUD 操作
// Insert
let track = Track(id: "1", title: "Song", artist: "Artist", duration: 240)
modelContext.insert(track)
// Fetch
let descriptor = FetchDescriptor<Track>(
predicate: #Predicate { $0.genre == "Rock" },
sortBy: [SortDescriptor(\.title)]
)
let rockTracks = try modelContext.fetch(descriptor)
// Update — just modify properties, SwiftData tracks changes
track.title = "Updated Title"
// Delete
modelContext.delete(track)
// Batch delete
try modelContext.delete(model: Track.self, where: #Predicate { $0.genre == "Classical" })
// Save (optional — auto-saves on view disappear)
try modelContext.save()
述語
基本的な比較
#Predicate<Track> { $0.duration > 180 }
#Predicate<Track> { $0.artist == "Artist Name" }
#Predicate<Track> { $0.genre != nil }
複合述語
#Predicate<Track> { track in
track.genre == "Rock" && track.duration > 180
}
#Predicate<Track> { track in
track.artist == "Artist" || track.artist == "Other Artist"
}
文字列の一致
// Contains
#Predicate<Track> { track in
track.title.contains("Love")
}
// Case-insensitive contains
#Predicate<Track> { track in
track.title.localizedStandardContains("love")
}
// Starts with
#Predicate<Track> { track in
track.artist.hasPrefix("The ")
}
リレーションシップの述語
#Predicate<Track> { track in
track.album?.title == "Album Name"
}
#Predicate<Album> { album in
album.tracks.count > 10
}
Swift 6 の並行処理
@MainActor による隔離
import SwiftData
@MainActor
@Model
final class Track {
var id: String
var title: String
init(id: String, title: String) {
self.id = id
self.title = title
}
}
理由 SwiftData のモデルは Sendable ではありません。SwiftUI から安全にアクセスできるよう、@MainActor を使用します。
バックグラウンドコンテキスト
import SwiftData
actor DataImporter {
let modelContainer: ModelContainer
init(container: ModelContainer) {
self.modelContainer = container
}
func importTracks(_ tracks: [TrackData]) async throws {
// Create background context
let context = ModelContext(modelContainer)
for track in tracks {
let model = Track(
id: track.id,
title: track.title,
artist: track.artist,
duration: track.duration
)
context.insert(model)
}
try context.save()
}
}
パターン バックグラウンド操作には @Environment(\.modelContext)(メインアクターに束縛されています)ではなく、ModelContext(modelContainer) を使用します。
SwiftUI からの呼び出し
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
var body: some View {
Button("Import") {
Task {
let importer = DataImporter(container: modelContext.container)
try await importer.importTracks(data)
}
}
}
}
CloudKit の統合
CloudKit 同期を有効にする
let schema = Schema([Track.self])
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private("iCloud.com.example.MusicApp")
)
let container = try ModelContainer(
for: schema,
configurations: config
)
必要な機能
- Xcode で iCloud を有効にします(Signing & Capabilities)
- CloudKit を選択します
- iCloud コンテナを追加します:
iCloud.com.example.MusicApp
注記 SwiftData の CloudKit 同期は自動で行われるため、手動で競合を解決する必要はありません。
CloudKit の制約(重要)
CloudKit 同期を使用する場合、すべてのプロパティはオプショナルにするか、デフォルト値を設定する必要があります
@Model
final class Track {
var id: String = UUID().uuidString // ✅ Has default (don't use .unique — CloudKit can't enforce it)
var title: String = "" // ✅ Has default
var duration: TimeInterval = 0 // ✅ Has default
var genre: String? = nil // ✅ Optional
// ❌ These don't work with CloudKit:
// var requiredField: String // No default, not optional
}
理由 CloudKit はプライベートゾーンにのみ同期し、ネットワークの遅延により、新しいレコードのすべてのフィールドがまだ設定されていない場合があります。
リレーションシップの制約 すべてのリレーションシップはオプショナルにする必要があります
@Model
final class Track {
@Relationship(deleteRule: .cascade, inverse: \Album.tracks)
var album: Album? // ✅ Must be optional for CloudKit
}
同期ステータス、競合、オフライン時の処理
SwiftData の CloudKit 同期では、デフォルトで最終書き込み優先が使用されます。同期間のステータス監視、カスタム競合解決、オフラインを考慮した UI パターンについては、axiom-cloud-sync を参照してください。CKShare ベースのレコード共有については、axiom-cloudkit-ref を参照してください。
「Property must be optional or have default value」エラーの解決方法
問題 CloudKit 同期を使用しようとすると、このエラーが発生します:
Property 'title' must be optional or have a default value for CloudKit synchronization
解決方法
// ❌ Wrong - required property
@Model
final class Track {
var title: String
}
// ✅ Correct - has default
@Model
final class Track {
var title: String = ""
}
// ✅ Also correct - optional
@Model
final class Track {
var title: String?
}
CloudKit 同期のテスト(iCloud なし)
let schema = Schema([Track.self])
// Test configuration (no CloudKit sync)
let testConfig = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: testConfig)
実際の CloudKit テストの場合
- テストデバイスで iCloud にサインインします
- Capabilities で CloudKit を有効にします
- 実機を使用します(シミュレーターでの CloudKit は安定しません)
- 「設定」→ [Your Name] → iCloud で iCloud のステータスを確認します
iOS 26 以降の機能
リレーションシップ処理の強化
@Model
final class Track {
@Relationship(
deleteRule: .cascade,
inverse: \Album.tracks,
minimum: 0,
maximum: 1 // Track belongs to at most one album
) var album: Album?
}
一時プロパティ
@Model
final class Track {
var id: String
var duration: TimeInterval
@Transient
var formattedDuration: String {
let minutes = Int(duration) / 60
let seconds = Int(duration) % 60
return String(format: "%d:%02d", minutes, seconds)
}
}
一時プロパティ 永続化されない計算プロパティです。
履歴の追跡
// Enable history tracking
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private("iCloud.com.example.app"),
allowsSave: true,
isHistoryEnabled: true // iOS 26+
)
パフォーマンスパターン
バッチフェッチ
let descriptor = FetchDescriptor<Track>(
sortBy: [SortDescriptor(\.title)]
)
descriptor.fetchLimit = 100 // Paginate results
let tracks = try modelContext.fetch(descriptor)
リレーションシップのプリフェッチ(N+1 クエリの防止)
let descriptor = FetchDescriptor<Track>()
descriptor.relationshipKeyPathsForPrefetching = [\.album] // Eager load album
let tracks = try modelContext.fetch(descriptor)
// No N+1 queries - albums already loaded
重要 プリフェッチを行わないと、ループ内で track.album.title にアクセスするたびに、各トラックに対して個別のクエリが実行されます:
// ❌ SLOW: N+1 queries (1 fetch tracks + 100 fetch albums)
let tracks = try modelContext.fetch(FetchDescriptor<Track>())
for track in tracks {
print(track.album?.title) // 100 separate queries!
}
// ✅ FAST: 2 queries total (1 fetch tracks + 1 fetch all albums)
let descriptor = FetchDescriptor<Track>()
descriptor.relationshipKeyPathsForPrefetching = [\.album]
let tracks = try modelContext.fetch(descriptor)
for track in tracks {
print(track.album?.title) // Already loaded
}
フォールティング(遅延読み込み)
SwiftData では、デフォルトでフォールティング(遅延読み込み)が使用されます:
let track = tracks.first
// Album is a fault - not loaded yet
let albumTitle = track.album?.title
// Album loaded on access (separate query)
フォールティングを効果的に使う
- ✅ リレーションシップにアクセスするのが全体の 10~20%のケースのみであれば適しています
- ✅ 一部しか使用しない大規模なリレーションシップグラフに適しています
- ❌ ループ内でリレーションシップにアクセスする場合は不適切です → 代わりにプリフェッチを使用してください
バッチ操作(大規模データセットでのパフォーマンス)
// ❌ SLOW: 1000 individual saves
for track in largeDataset {
track.genre = "Updated"
try modelContext.save() // Expensive - 1000 times
}
// ✅ FAST: Single save operation
for track in largeDataset {
track.genre = "Updated"
}
try modelContext.save() // Once for entire batch
インデックスの最適化(iOS 26 以降)
頻繁にクエリされるプロパティにインデックスを作成します:
@Model
final class Track {
@Attribute(.unique) var id: String = UUID().uuidString
@Attribute(.indexed) // ✅ Add index
var genre: String = ""
@Attribute(.indexed)
var releaseDate: Date = Date()
var title: String = ""
var duration: TimeInterval = 0
}
// Now these queries are faster:
@Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track]
@Query(filter: #Predicate { $0.releaseDate > Date() }) var upcomingTracks: [Track]
インデックスを追加する場合
- ✅
@Queryのフィルターで頻繁に使用されるプロパティ - ✅ ソート操作で使用されるプロパティ
- ✅ リレーションシップで使用されるプロパティ
- ❌ フィルターにほとんど使用されないプロパティには追加しないでください
- ❌ 頻繁に変更されるプロパティには追加しないでください(メンテナンスコストがかかります)
メモリの最適化:チャンク単位でフェッチする
非常に大規模なデータセット(10 万件以上のレコード)では、チャンク単位でフェッチします:
actor DataImporter {
let modelContainer: ModelContainer
func importLargeDataset(_ items: [Item]) async throws {
let chunkSize = 1000
let context = ModelContext(modelContainer)
for chunk in items.chunked(into: chunkSize) {
for item in chunk {
let track = Track(
id: item.id,
title: item.title,
artist: item.artist,
duration: item.duration
)
context.insert(track)
}
try context.save() // Save after each chunk
// Prevent memory bloat
context.delete(model: Track.self, where: #Predicate { _ in true })
}
}
}
extension Array {
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}
CloudKit 同期での保持サイクルの回避
CloudKit を使用する場合、クロージャ内で self をキャプチャしないようにします:
// ❌ Retain cycle with CloudKit sync
actor TrackManager {
func startSync() {
Task {
for await notification in NotificationCenter.default
.notifications(named: NSNotification.Name("CloudKitSyncDidComplete")) {
self.refreshUI() // Potential retain cycle
}
}
}
}
// ✅ Proper weak capture
actor TrackManager {
func startSync() {
Task { [weak self] in
guard let self else { return }
for await notification in NotificationCenter.default
.notifications(named: NSNotification.Name("CloudKitSyncDidComplete")) {
await self.refreshUI()
}
}
}
}
一般的なパターン
検索
struct SearchableTracksView: View {
@Query var tracks: [Track]
@State private var searchText = ""
var filteredTracks: [Track] {
if searchText.isEmpty {
return tracks
}
return tracks.filter { track in
track.title.localizedStandardContains(searchText) ||
track.artist.localizedStandardContains(searchText)
}
}
var body: some View {
List(filteredTracks) { track in
Text(track.title)
}
.searchable(text: $searchText)
}
}
カスタムソート
struct TracksView: View {
@Query var tracks: [Track]
@State private var sortOrder: SortOrder = .title
enum SortOrder {
case title, artist, duration
}
var sortedTracks: [Track] {
switch sortOrder {
case .title:
return tracks.sorted { $0.title < $1.title }
case .artist:
return tracks.sorted { $0.artist < $1.artist }
case .duration:
return tracks.sorted { $0.duration < $1.duration }
}
}
}
元に戻す/やり直す
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.undoManager) private var undoManager
func deleteTrack(_ track: Track) {
modelContext.delete(track)
// Undo is automatic with modelContext
// Use Cmd+Z to undo
}
}
Realm および Core Data からの移行
主な違いの概要
| 概念 | Realm | Core Data | SwiftData |
|---|---|---|---|
| モデル定義 | Object サブクラス + @Persisted | NSManagedObject + @NSManaged | final class + @Model |
| 主キー | @Persisted(primaryKey:) | エンティティインスペクター | @Attribute(.unique) |
| スレッド処理 | スレッドごとに手動で Realm インスタンスを作成 | context.perform {} ブロック | アクター分離 + ModelContext(container) |
| リレーションシップ | RealmSwiftCollection<T> | エンティティエディター + @NSManaged | 自動的な逆関係を持つ @Relationship |
| バックグラウンド処理 | DispatchQueue + スレッドローカルの Realm | newBackgroundContext() | actor + ModelContext(modelContainer) |
| バッチ削除 | ループ + realm.delete() | NSBatchDeleteRequest | context.delete(model:where:) |
| CloudKit 同期 | Realm Sync(2025 年 9 月に非推奨) | NSPersistentCloudKitContainer | ModelConfiguration(cloudKitDatabase:) |
詳細な移行ガイド
realm-to-swiftdata-migration— Realm からの完全な移行:パターンの対応関係、スレッドセーフティの変換、リレーションシップの移行、CloudKit 同期への移行、タイムラインの計画axiom-swiftdata-migration— SwiftData のスキーマ進化:VersionedSchema、SchemaMigrationPlan、軽量移行とカスタム移行axiom-database-migration— あらゆる永続化フレームワークに適用できる、安全な追加型移行パターン
テスト
テストのセットアップ
import XCTest
import SwiftData
@testable import MusicApp
final class TrackTests: XCTestCase {
var modelContext: ModelContext!
override func setUp() async throws {
let schema = Schema([Track.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: config)
modelContext = ModelContext(container)
}
func testInsertTrack() throws {
let track = Track(id: "1", title: "Test", artist: "Artist", duration: 240)
modelContext.insert(track)
let descriptor = FetchDescriptor<Track>()
let tracks = try modelContext.fetch(descriptor)
XCTAssertEqual(tracks.count, 1)
XCTAssertEqual(tracks.first?.title, "Test")
}
}
比較:SwiftData と SQLiteData
| 特徴 | SwiftData | SQLiteData |
|---|---|---|
| 種類 | 参照型(クラス) | 値型(構造体) |
| マクロ | @Model | @Table |
| クエリ | SwiftUI の @Query | @FetchAll / @FetchOne |
| リレーションシップ | @Relationship マクロ | 明示的な外部キー |
| CloudKit | 自動同期 | 手動の SyncEngine と共有 |
| バックエンド | Core Data | GRDB + SQLite |
| 学習曲線 | 容易(ネイティブ) | 中程度 |
| パフォーマンス | 良好 | 優秀(生の SQL) |
tvOS
tvOS の SwiftData には、永続的なローカルストレージがありません。 tvOS には Documents ディレクトリがなく、Application Support は Caches に割り当てられます。そのため、ストレージに負荷がかかると、システムによってファイルが削除されます。ローカルのみの SwiftData ストアでは、すべてのデータが失われます。
tvOS の SwiftData アプリでは、CloudKit 同期(cloudKitDatabase: .private(...))を使用する必要があります。 iCloud を使わない場合、ユーザーデータはアプリの起動後も保持されません。tvOS のストレージに関する制約の詳細は axiom-tvos を参照してください。
リソース
ドキュメント: /swiftdata, /swiftdata/adopting-inheritance-in-swiftdata
スキル: axiom-swiftdata-migration, axiom-swiftdata-migration-diag, axiom-database-migration, axiom-sqlitedata, axiom-grdb, axiom-swift-concurrency
よくある間違い
❌ 明示的な init を忘れる
@Model
final class Track {
var id: String
var title: String
// No init - won't compile
}
修正 @Model クラスには必ず init を用意してください
❌ 構造体を使う
@Model
struct Track { } // Won't work - must be class
修正 struct ではなく final class を使ってください
❌ メインコンテキストでバックグラウンド処理を行う
@Environment(\.modelContext) var context // Main actor only
Task {
// ❌ Crash - crossing actor boundaries
context.insert(track)
}
修正 バックグラウンド処理には ModelContext(modelContainer) を使ってください
❌ 必要なときに保存しない
modelContext.insert(track)
// Might not persist immediately
修正 すぐに永続化するには try modelContext.save() を呼び出してください
作成日 2025-11-28 対象 iOS 17 以降(iOS 26 以降の機能に重点) フレームワーク SwiftData(Apple) Swift 5.9 以降(Swift 6 の並行処理パターン)