SwiftDataのカスタムデータストアって?
WWDC2024で発表があったSwiftDataの新機能。
カスタムデータストアを使用することで、JSONファイル・Webサービス・データベースエンジンなどの永続化に対応できるとのこと。
WWDC2024のビデオではModelContextがストアをどのようなやり取りをしているのか図を用いて説明されているので、ビデオを見た方が分かりやすいと思います。
サンプルコード
WWDCのビデオでも紹介されている、JSONファイルをデータストアとしているサンプルコードです。
コード全文
import Foundation
import SwiftData
@Model
class Item {
#Unique<Item>([\.id])
var id: UUID
var timestamp: Date
init() {
self.id = UUID()
self.timestamp = Date()
}
}
import Foundation
import SwiftData
final class JSONStoreConfiguration: DataStoreConfiguration {
typealias Store = JSONDataStore
var name: String
var schema: Schema?
var fileURL: URL
init(name: String, schema: Schema? = nil, fileURL: URL) {
self.name = name
self.schema = schema
self.fileURL = fileURL
}
static func == (lhs: JSONStoreConfiguration, rhs: JSONStoreConfiguration) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
}
final class JSONDataStore: DataStore {
typealias Configuration = JSONStoreConfiguration
typealias Snapshot = DefaultSnapshot
var configuration: JSONStoreConfiguration
var name: String
var schema: Schema
var identifier: String
init(_ configuration: JSONStoreConfiguration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
self.configuration = configuration
self.name = configuration.name
self.schema = configuration.schema!
self.identifier = configuration.fileURL.lastPathComponent
}
func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
if request.descriptor.predicate != nil {
throw DataStoreError.preferInMemoryFilter
} else if request.descriptor.sortBy.count > 0 {
throw DataStoreError.preferInMemorySort
}
let objects = try self.read()
let snapshot = objects.values.map { $0 }
return .init(descriptor: request.descriptor, fetchedSnapshots: snapshot)
}
func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
var serializedItems = try self.read()
for snapshot in request.inserted {
let permanentIdentifier = try PersistentIdentifier.identifier(
for: identifier,
entityName: snapshot.persistentIdentifier.entityName,
primaryKey: UUID()
)
let permanentSnapshot = snapshot.copy(persistentIdentifier: permanentIdentifier)
serializedItems[permanentIdentifier] = permanentSnapshot
remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
}
for snapshot in request.updated {
serializedItems[snapshot.persistentIdentifier] = snapshot
}
for snapshot in request.deleted {
serializedItems[snapshot.persistentIdentifier] = nil
}
try self.write(serializedItems)
return .init(for: self.identifier, remappedIdentifiers: remappedIdentifiers, snapshotsToReregister: serializedItems)
}
private func read() throws -> [PersistentIdentifier: DefaultSnapshot] {
guard FileManager.default.fileExists(atPath: configuration.fileURL.path(percentEncoded: false)) else {
return [:]
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let items = try decoder.decode([DefaultSnapshot].self, from: try Data(contentsOf: configuration.fileURL))
var result = [PersistentIdentifier: DefaultSnapshot]()
items.forEach {
result[$0.persistentIdentifier] = $0
}
return result
}
private func write(_ items: [PersistentIdentifier: DefaultSnapshot]) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(items.values.map({ $0 }))
try jsonData.write(to: configuration.fileURL)
}
}
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Item.timestamp) private var items: [Item]
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
VStack {
Text("id: \(item.id)")
Text("timestamp: \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
}
} label: {
Text(item.id.uuidString)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item()
modelContext.insert(newItem)
do {
try modelContext.save()
} catch {
print(error)
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
}
import SwiftUI
import SwiftData
@main
struct SampleApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("item.json")
let modelConfiguration = JSONStoreConfiguration(name: "JsonStore", fileURL: fileURL)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}
このコードではDocuments配下にitem.json
ファイルが作成され、そのJSONファイルをデータストアとしています。

そのためJSONファイルを削除しない限りアプリキルをしてもデータは永続化されています。
また、以下のようにContentViewに渡すModelContainerを変えることでデータストアをデフォルトのCore Dataに切り替えることも可能です。
import SwiftUI
import SwiftData
@main
struct SampleApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("item.json")
// let modelConfiguration = JSONStoreConfiguration(name: "JsonStore", fileURL: fileURL)
let modelConfiguration = ModelConfiguration(isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}
外部データベースを使ってみたい
WWDCのビデオやSwiftDataのページではWebサービスやデータベースエンジンなど、さまざまな種類の永続化に対応できると書かれています。
それならFirebaseだったりAPIを介して外部のデータベースをデータストアとして扱うことも可能なのでは?と思ったのですが、データを取得するfetch
メソッドがasyncではなく、戻り値に表示したいデータを渡す必要があります。
そのため、通常は非同期処理を行う場合はカスタムデータストアとしてFirebaseや外部のデータベースを指定するのは難しいのかな、と思います。
(むしろAppleはどういうケースを想定してWebサービスやデータベースエンジンを例に挙げたんだろう...)
ただ、非同期処理の完了を待ってデータを渡すことができればいけるのでは?と思い試してみました。
サンプルコード
コード全文は以下を見てください。
サーバーはHummingbirdでローカルにAPIサーバーを立てています。
データベースに関しては、今回APIサーバーからデータを返せればなんでもいいため採用していません。
(2024/4/22 追記)
SQLiteを使用するよう変更しました。
変更点としては以下になります。
- リモート用のDataStoreConfigurationを作成
- リモート用のDataStoreを作成
-
.modelContainer
に作成したリモート用DataStoreConfigurationを指定
ちょっと解説
final class RemoteDataStore: DataStore {
...
func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
var result: Result<[DefaultSnapshot], Error>!
let semaphore = DispatchSemaphore(value: 0)
Task {
defer { semaphore.signal() }
do {
let snapshot: [DefaultSnapshot] = try await APIClient.fetch()
result = .success(snapshot)
} catch {
print(error)
result = .failure(error)
}
}
semaphore.wait()
return .init(descriptor: request.descriptor, fetchedSnapshots: try result.get())
}
func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
let identifier = identifier
let semaphore = DispatchSemaphore(value: 0)
Task {
defer { semaphore.signal() }
do {
let insertItems = try request.inserted.map {
let persistentIdentifier = try PersistentIdentifier.identifier(
for: identifier,
entityName: $0.persistentIdentifier.entityName,
primaryKey: UUID()
)
return $0.copy(persistentIdentifier: persistentIdentifier)
}
if !insertItems.isEmpty {
try await APIClient.register(insertItems)
}
} catch {
print(error)
}
}
semaphore.wait()
return .init(for: identifier)
}
}
これらのfetch
とsave
はDataStore
プロトコルの必須メソッドになります。
そのためメソッド自体をasyncにすることが不可能なため、DispatchSemaphore
を使って非同期処理が完了するまで待機するようにしています。
それ以外の処理に関してはJSONファイルの時とほぼ同じで、読み取りor書き込みをAPIリクエストに置き換えただけになります。
結果
追加 & 取得できました。


注意点
今回のサンプルコードではDataStoreSnapshot
を独自で定義せずにDefaultSnapshot
を用いており、JSONファイルにも、APIレスポンスにもpersistentIdentifier
などのデータが自動で追加されています。
このpersistentIdentifier
ですが、DataStoreSnapshot
プロトコルでrequiredとなっているため、独自のDataStoreSnapshotを定義しても必要となります。
JSONファイルでは特に意識する必要は無いのですが、APIサーバー側の実装時はリクエストで渡されるデータにpersistentIdentifier
などのデータが入ってることに注意しないとエンコードやデコードに失敗してしまいます。
そのため今回は同じフィールドを持つ構造体をAPIサーバー側で定義して無理矢理エンコード/デコードを成功するようにしています。
おわりに
SwiftDataのカスタムデータストアで、JSONファイルとAPIを介した外部データベースの2種類を試しました。
感想としては個人的にイマイチ使い所が思い浮かばず...
(デフォルトのCore Dataでいいんじゃ...)
ただ、まだ発表されたばかりなのでこれから機能が拡張していけばもっと使いやすくなるのかな、と思います。
参考
https://developer.apple.com/jp/videos/play/wwdc2024/10138/
https://wwdcnotes.com/documentation/wwdcnotes/wwdc24-10138-create-a-custom-data-store-with-swiftdata/
https://qiita.com/Kyome/items/075ec302978c4d9670b4
https://theswiftdev.com/hummingbird-routing-and-requests/
https://youtu.be/lu0Ge0td1Kg?feature=shared