SwiftData
SwiftDataは「SwiftUIに最適化されたCore Dataの代替」として2023年のWWDC23でAppleが発表した、SwiftとSwiftUIのためのデータ永続化フレームワークです。「宣言的でSwiftらしいデータモデリング」をコンセプトに、@Modelマクロによる直感的なモデル定義、@Queryによる自動的なSwiftUIビュー更新、CloudKitとの自動同期等を提供。Core Dataの堅牢なストレージエンジンを活用しながら、現代的なSwift開発体験を実現するAppleの次世代データ永続化ソリューションです。
GitHub概要
トピックス
スター履歴
ライブラリ
SwiftData
概要
SwiftDataは「SwiftUIに最適化されたCore Dataの代替」として2023年のWWDC23でAppleが発表した、SwiftとSwiftUIのためのデータ永続化フレームワークです。「宣言的でSwiftらしいデータモデリング」をコンセプトに、@Modelマクロによる直感的なモデル定義、@Queryによる自動的なSwiftUIビュー更新、CloudKitとの自動同期等を提供。Core Dataの堅牢なストレージエンジンを活用しながら、現代的なSwift開発体験を実現するAppleの次世代データ永続化ソリューションです。
詳細
SwiftData 2025年版はAppleエコシステムにおけるデータ永続化の新しい標準として位置づけられています。マクロベースの宣言的API(@Model、@Query、@Relationship)により、従来のCore Dataより大幅に簡潔なコード記述を実現。SwiftUIとの深い統合により、データ変更が自動的にUIに反映される反応的プログラミングを標準でサポート。iOS 17.0+、macOS 14.0+等の最新プラットフォーム対応で、CloudKit同期、マイグレーション、バックアップ・リストア等のエンタープライズ機能も完備。ただし、発表から1年以上経過した現在でも安定性の課題が指摘されており、プロダクション採用には慎重な検討が必要です。
主な特徴
- 宣言的API: @Modelマクロによる直感的なデータモデル定義
- SwiftUI統合: @Queryによる自動UI更新と反応的プログラミング
- CloudKit同期: デバイス間データ同期の自動化
- 型安全: Swift型システムとの完全統合
- マイグレーション: スキーマ変更への自動対応
- Core Data互換: Core Dataストレージエンジンの活用
メリット・デメリット
メリット
- Core Dataより圧倒的に簡潔なコード記述で開発効率向上
- SwiftUIとの深い統合により反応的UIの実装が容易
- マクロベースのAPIでボイラープレートコード大幅削減
- CloudKitとの自動同期でマルチデバイス対応が簡単
- Swift型システムとの完全統合でタイプセーフティ確保
- 最新のSwift言語機能(macros、property wrappers)活用
デメリット
- iOS 17+、macOS 14+の最新プラットフォームのみ対応
- 発表から1年以上経過もバグや不安定性の報告が多数
- Core Dataに比べて機能が限定的で高度な要件に未対応
- プロダクション環境での実績が少なく信頼性に不安
- 複雑なデータモデルやカスタマイゼーションが困難
- デバッグツールやドキュメントがCore Dataより限定的
参考ページ
書き方の例
インストールと基本設定
// iOS 17.0+, macOS 14.0+, watchOS 10.0+, tvOS 17.0+
import SwiftUI
import SwiftData
// アプリケーション設定
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [User.self, Post.self])
}
}
// カスタム設定でのモデルコンテナ
@main
struct MyApp: App {
var container: ModelContainer
init() {
let schema = Schema([User.self, Post.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
cloudKitDatabase: .automatic
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to configure SwiftData container: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
基本的なモデル定義
import SwiftData
import Foundation
// 基本的なモデル定義
@Model
class User {
@Attribute(.unique) var id: UUID
var name: String
var email: String
var age: Int
var isActive: Bool
var createdAt: Date
var updatedAt: Date
// リレーションシップ
@Relationship(deleteRule: .cascade, inverse: \Post.author)
var posts: [Post] = []
@Relationship(deleteRule: .nullify)
var profile: UserProfile?
init(name: String, email: String, age: Int) {
self.id = UUID()
self.name = name
self.email = email
self.age = age
self.isActive = true
self.createdAt = Date()
self.updatedAt = Date()
}
}
@Model
class Post {
@Attribute(.unique) var id: UUID
var title: String
var content: String
var isPublished: Bool
var publishedAt: Date?
var createdAt: Date
var updatedAt: Date
// 逆リレーションシップ
var author: User?
@Relationship(deleteRule: .cascade, inverse: \Comment.post)
var comments: [Comment] = []
init(title: String, content: String, author: User) {
self.id = UUID()
self.title = title
self.content = content
self.isPublished = false
self.createdAt = Date()
self.updatedAt = Date()
self.author = author
}
func publish() {
isPublished = true
publishedAt = Date()
updatedAt = Date()
}
}
@Model
class Comment {
@Attribute(.unique) var id: UUID
var content: String
var createdAt: Date
var post: Post?
var author: User?
init(content: String, post: Post, author: User) {
self.id = UUID()
self.content = content
self.createdAt = Date()
self.post = post
self.author = author
}
}
@Model
class UserProfile {
@Attribute(.unique) var id: UUID
var bio: String
var website: String?
var location: String?
var avatar: Data?
var user: User?
init(bio: String, website: String? = nil, location: String? = nil) {
self.id = UUID()
self.bio = bio
self.website = website
self.location = location
}
}
SwiftUIでのデータ操作
import SwiftUI
import SwiftData
// メインビュー
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var users: [User]
@Query(sort: \Post.createdAt, order: .reverse) private var recentPosts: [Post]
var body: some View {
NavigationView {
List {
Section("Users") {
ForEach(users) { user in
NavigationLink(destination: UserDetailView(user: user)) {
UserRowView(user: user)
}
}
.onDelete(perform: deleteUsers)
}
Section("Recent Posts") {
ForEach(recentPosts.prefix(5)) { post in
PostRowView(post: post)
}
}
}
.navigationTitle("SwiftData Demo")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink("Add User", destination: AddUserView())
}
}
}
}
private func deleteUsers(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(users[index])
}
}
}
}
// ユーザー行表示
struct UserRowView: View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.caption)
.foregroundColor(.secondary)
HStack {
Text("Age: \(user.age)")
Spacer()
Text("\(user.posts.count) posts")
if user.isActive {
Image(systemName: "circle.fill")
.foregroundColor(.green)
.font(.caption)
}
}
.font(.caption)
.foregroundColor(.secondary)
}
}
}
// ユーザー追加ビュー
struct AddUserView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var email = ""
@State private var age = 25
var body: some View {
NavigationView {
Form {
TextField("Name", text: $name)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
Stepper("Age: \(age)", value: $age, in: 18...100)
}
.navigationTitle("Add User")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveUser()
}
.disabled(name.isEmpty || email.isEmpty)
}
}
}
}
private func saveUser() {
let newUser = User(name: name, email: email, age: age)
modelContext.insert(newUser)
do {
try modelContext.save()
dismiss()
} catch {
print("Failed to save user: \(error)")
}
}
}
条件付きクエリとフィルタリング
import SwiftUI
import SwiftData
// 条件付きクエリを使用するビュー
struct FilteredUsersView: View {
@Environment(\.modelContext) private var modelContext
@State private var searchText = ""
@State private var selectedAgeRange = 0
@State private var showActiveOnly = false
// 動的クエリの構築
private var filteredUsersQuery: Query<User, [User]> {
var predicate = #Predicate<User> { user in
true // ベースとなる条件
}
// 検索テキストによるフィルタリング
if !searchText.isEmpty {
let searchPredicate = #Predicate<User> { user in
user.name.localizedStandardContains(searchText) ||
user.email.localizedStandardContains(searchText)
}
predicate = #Predicate<User> { user in
predicate.evaluate(user) && searchPredicate.evaluate(user)
}
}
// アクティブユーザーのみのフィルタリング
if showActiveOnly {
let activePredicate = #Predicate<User> { user in
user.isActive == true
}
predicate = #Predicate<User> { user in
predicate.evaluate(user) && activePredicate.evaluate(user)
}
}
// 年齢範囲によるフィルタリング
switch selectedAgeRange {
case 1: // 18-30
let agePredicate = #Predicate<User> { user in
user.age >= 18 && user.age <= 30
}
predicate = #Predicate<User> { user in
predicate.evaluate(user) && agePredicate.evaluate(user)
}
case 2: // 31-50
let agePredicate = #Predicate<User> { user in
user.age >= 31 && user.age <= 50
}
predicate = #Predicate<User> { user in
predicate.evaluate(user) && agePredicate.evaluate(user)
}
case 3: // 51+
let agePredicate = #Predicate<User> { user in
user.age >= 51
}
predicate = #Predicate<User> { user in
predicate.evaluate(user) && agePredicate.evaluate(user)
}
default:
break
}
return Query(filter: predicate, sort: \User.name)
}
var body: some View {
VStack {
// フィルタリングコントロール
VStack(spacing: 16) {
SearchBar(text: $searchText)
Picker("Age Range", selection: $selectedAgeRange) {
Text("All Ages").tag(0)
Text("18-30").tag(1)
Text("31-50").tag(2)
Text("51+").tag(3)
}
.pickerStyle(SegmentedPickerStyle())
Toggle("Active Users Only", isOn: $showActiveOnly)
}
.padding()
// フィルタリングされたユーザーリスト
FilteredUsersList(query: filteredUsersQuery)
}
.navigationTitle("Filtered Users")
}
}
// クエリ結果を表示するビュー
struct FilteredUsersList: View {
let query: Query<User, [User]>
var body: some View {
List(query.wrappedValue) { user in
UserRowView(user: user)
}
}
}
// 検索バーコンポーネント
struct SearchBar: View {
@Binding var text: String
var body: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search users...", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
if !text.isEmpty {
Button(action: {
text = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
}
}
データマイグレーションとバージョニング
import SwiftData
// バージョン1のスキーマ
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[UserV1.self, PostV1.self]
}
@Model
class UserV1 {
var name: String
var email: String
var age: Int
init(name: String, email: String, age: Int) {
self.name = name
self.email = email
self.age = age
}
}
@Model
class PostV1 {
var title: String
var content: String
init(title: String, content: String) {
self.title = title
self.content = content
}
}
}
// バージョン2のスキーマ(新しいフィールドを追加)
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[UserV2.self, PostV2.self]
}
@Model
class UserV2 {
@Attribute(.unique) var id: UUID
var name: String
var email: String
var age: Int
var isActive: Bool
var createdAt: Date
init(name: String, email: String, age: Int) {
self.id = UUID()
self.name = name
self.email = email
self.age = age
self.isActive = true
self.createdAt = Date()
}
}
@Model
class PostV2 {
@Attribute(.unique) var id: UUID
var title: String
var content: String
var isPublished: Bool
var createdAt: Date
init(title: String, content: String) {
self.id = UUID()
self.title = title
self.content = content
self.isPublished = false
self.createdAt = Date()
}
}
}
// マイグレーションプランの定義
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// マイグレーション前処理
print("Starting migration from V1 to V2")
},
didMigrate: { context in
// マイグレーション後処理
print("Completed migration from V1 to V2")
// デフォルト値の設定
let users = try context.fetch(FetchDescriptor<SchemaV2.UserV2>())
for user in users {
if user.id == UUID(uuidString: "00000000-0000-0000-0000-000000000000") {
user.id = UUID()
}
user.isActive = true
user.createdAt = Date()
}
let posts = try context.fetch(FetchDescriptor<SchemaV2.PostV2>())
for post in posts {
if post.id == UUID(uuidString: "00000000-0000-0000-0000-000000000000") {
post.id = UUID()
}
post.isPublished = false
post.createdAt = Date()
}
try context.save()
}
)
}
// マイグレーション付きアプリケーション設定
@main
struct MigratableApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [SchemaV2.UserV2.self, SchemaV2.PostV2.self], migrationPlan: MigrationPlan.self)
}
}
CloudKitとの統合
import SwiftUI
import SwiftData
import CloudKit
// CloudKit統合設定
@main
struct CloudKitApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [User.self, Post.self]) { result in
switch result {
case .success(let container):
// CloudKit設定
container.cloudKitDatabase = .automatic
container.persistentStoreDescriptions.forEach { storeDescription in
storeDescription.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
storeDescription.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
case .failure(let error):
print("Failed to create container: \(error)")
}
}
}
}
// CloudKit同期状態の監視
struct CloudKitSyncView: View {
@Environment(\.modelContext) private var modelContext
@State private var syncStatus: CloudKitSyncStatus = .unknown
@State private var lastSyncDate: Date?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Sync Status:")
Spacer()
StatusIndicator(status: syncStatus)
}
if let lastSync = lastSyncDate {
HStack {
Text("Last Sync:")
Spacer()
Text(lastSync, style: .relative)
.foregroundColor(.secondary)
}
}
Button("Force Sync") {
forceSyncWithCloudKit()
}
.disabled(syncStatus == .syncing)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.onAppear {
monitorSyncStatus()
}
}
private func forceSyncWithCloudKit() {
syncStatus = .syncing
// SwiftDataではCloudKit同期は自動的に行われるため、
// 手動での強制同期は通常不要
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
syncStatus = .synced
lastSyncDate = Date()
}
}
private func monitorSyncStatus() {
// CloudKit同期状態の監視実装
// 実際の実装では、NSPersistentCloudKitContainerのイベント監視を行う
}
}
enum CloudKitSyncStatus {
case unknown, syncing, synced, error
}
struct StatusIndicator: View {
let status: CloudKitSyncStatus
var body: some View {
HStack {
switch status {
case .unknown:
Image(systemName: "questionmark.circle")
.foregroundColor(.gray)
Text("Unknown")
case .syncing:
ProgressView()
.scaleEffect(0.8)
Text("Syncing...")
case .synced:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Synced")
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text("Error")
}
}
.font(.caption)
}
}