はじめに
この記事はWWDC2023で紹介があったSwiftDataについてのセッションまとめです。
SwiftDataはデータ永続化のためのフレームワークです。iOS17以上でしか使用できませんが、SwiftUIとの連携も簡単にできるため、将来的にはCoreDataからの置き換えが進められそうです。
同じくWWDC2023で紹介のあったマクロもゴリゴリに使われています。
はじめに、↓の公式サイトからSwiftDataを使用したサンプルプロジェクトのダウンロードをおすすめします。セッション内でもサンプルコードとして出てくるため、手元に置いておくと分かりやすいと思います。
スキーマの定義方法
スキーマの定義には@Model
マクロを使います。百聞は一見に如かずとういことで、旅行プランをスキーマにした例を見てみましょう。
@Model
class Trip {
@Attribute(.unique) var name: String
var destination: String
@Attribute(originalName: "start_date") var startDate: Date
@Attribute(originalName: "end_date") var endDate: Date
@Relationship(.cascade) var bucketList: [BucketListItem]? = []
@Relationship(.cascade) var livingAccommodation: LivingAccommodation?
@Transient var tripViews: Int = 0
}
順を追って説明します。
@Model
マクロはObservable
プロトコルに準拠しています。そのため、各プロパティに@Published
を付与する必要はなく、モデルが更新されると自動でViewに反映されます。
ユニーク制約は@Attribute(.unique)
表せます。また、@Attribute(originalName: "start_date")
によってスキーマとコードでの命名を変えられます。
Tripが削除された際に他の関連するデータも同時に削除したい場合は@Relationship(.cascade)
を付与します。
最後に、一時的なプロパティでデータとして保存したくない場合は@Transient
を使います。
使い方
実際にデータを操作するためにはModelContainer
とModelContext
の2つが必要になります。
@main
struct TripsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [
Trip.self,
LivingAccommodation.self,
])
}
}
struct ContentView: View {
@Query(filter: #Predicate<Trip> {
$0.destination == "New York" &&
$0.name.contains("birthday")
}, sort: \.startDate, order: .reverse) var trips: [Trip]
@Environment(\.modelContext) var modelContext
var body: some View {
NavigationStack() {
List {
ForEach(trips) { trip in
// ...
}
}
}
}
}
// データの追加や削除
modelContext.insert(object: trip)
modelContext.delete(object: trip)
SwiftUIの場合、Viewから直接.modelContainer(for:)
を呼び出すことで、宣言した型のデータをそのサブView内で呼び出せるようになります。データの追加や削除をするためにはModelContext
が必要になりますが、環境変数として用意されているため自分で作る必要はなさそうです。
ModelContainer
とModelContext
はアプリ内で必要な分だけ生成して良いみたいです。(Mac版アプリで別Windowを開く場合などに必要になりそう)
View内のプロパティには@Query
を付与するだけでデータのフィルタリング、ソート、オーダーなどが指定できます。fetchの処理などは必要なく、Viewが生成された際に自動でデータを取ってきてくれます。さらに@State
のように、データの更新に合わせて自動でViewを再描画してくれます。
Previewにテストデータを表示させたい!
Previewにテストデータを表示させたい場合は、モックデータとModelContainer
が必要になります。
@MainActor
let previewContainer: ModelContainer = {
do {
let container = try ModelContainer(
for: Trip.self, ModelConfiguration(inMemory: true)
)
for card in Trip.mockDatas {
container.mainContext.insert(object: card)
}
return container
} catch {
fatalError("Failed to create container")
}
}()
#Preview {
ContentView()
.frame(minWidth: 500, minHeight: 500)
.modelContainer(previewContainer)
}
手順としては、ModelConteiner
の初期化→ModelContext
を取り出してモックデータを挿入して、それをPreviewのModelContainer
に突っ込めばOKです。
あ、Previewもマクロが新しく定義されてめっちゃ簡単に書けるようになりましたね!
マイグレーション時の対応
スキーマに変更があった際、データのマイグレーションを行う必要があります。SwiftDataでは、VersionedSchema
とSchemaMigrationPlan
を使用することで対応ができます。
今回は、2段階でスキーマを変更する場合を考えます。
enum SampleTripsSchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Trip.self, BucketListItem.self, LivingAccommodation.self]
}
static var versionIdentifier: String?
@Model
final class Trip {
var name: String
var destination: String
var start_date: Date
var end_date: Date
var bucketList: [BucketListItem]? = []
var livingAccommodation: LivingAccommodation?
}
// Define the other models in this version...
}
enum SampleTripsSchemaV2: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Trip.self, BucketListItem.self, LivingAccommodation.self]
}
static var versionIdentifier: String?
@Model
final class Trip {
@Attribute(.unique) var name: String
var destination: String
var start_date: Date
var end_date: Date
var bucketList: [BucketListItem]? = []
var livingAccommodation: LivingAccommodation?
}
// Define the other models in this version...
}
enum SampleTripsSchemaV3: VersionedSchema {
// ...
@Model
final class Trip {
@Attribute(.unique) var name: String
var destination: String
@Attribute(originalName: "start_date") var startDate: Date
@Attribute(originalName: "end_date") var endDate: Date
var bucketList: [BucketListItem]? = []
var livingAccommodation: LivingAccommodation?
}
// Define the other models in this version...
}
まず、VersionedSchema
でそれぞれのバージョンのスキーマをカプセル化します。↑の例では、
-
SampleTripsSchemaV1
→SampleTripsSchemaV2
でnameにユニーク制約を追加 -
SampleTripsSchemaV2
→SampleTripsSchemaV3
でstartDateとendDateにoriginalNameを追加して命名変更
という変更を加えています。
次に、SchemaMigrationPlan
を定義します。
enum SampleTripsMigrationPlan: SchemaMigrationPlan {
static var schemas: [VersionedSchema.Type] {
[SampleTripsSchemaV1.self, SampleTripsSchemaV2.self, SampleTripsSchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SampleTripsSchemaV1.self,
toVersion: SampleTripsSchemaV2.self,
willMigrate: { context in
let trips = try? context.fetch(FetchDescriptor<SampleTripsSchemaV1.Trip>())
// De-duplicate Trip instances here...
try? context.save()
},
didMigrate: nil
)
static let migrateV2toV3 = MigrationStage.lightweight(
fromVersion: SampleTripsSchemaV2.self,
toVersion: SampleTripsSchemaV3.self
)
}
ここでは先ほど定義した全てのVersionedSchema
を渡します。そして、それぞれのマイグレーションで必要な処理を記述します。
SampleTripsSchemaV1
→SampleTripsSchemaV2
では、ユニーク制約を追加したため、マイグレーションの前に重複削除が必要になります。この場合、MigrationStage.custom
を使用します。
一方でSampleTripsSchemaV2
→SampleTripsSchemaV3
ではoriginalNameを追加しただけなのでスキーマ自体に変更はなく、MigrationStage.lightweight
を使用するだけで特に追加の処理は必要ありません。
MigrationStage
では、custom
とlightweight
の2種類を適切に使い分ける必要があります。
最後に、アプリにこのマイグレーションを適用します。
@main
struct TripsApp: App {
let container = ModelContainer(
for: Trip.self,
migrationPlan: SampleTripsMigrationPlan.self
)
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
ModelContainer
を生成してそれを突っ込めば終了です!
以上です。数日に分けて編集したのでテンションに起伏がありますがご了承いただければと...