1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftDataのカスタムデータストアを試してみた

Last updated at Posted at 2025-04-21

SwiftDataのカスタムデータストアって?

WWDC2024で発表があったSwiftDataの新機能。
カスタムデータストアを使用することで、JSONファイル・Webサービス・データベースエンジンなどの永続化に対応できるとのこと。

WWDC2024のビデオではModelContextがストアをどのようなやり取りをしているのか図を用いて説明されているので、ビデオを見た方が分かりやすいと思います。

サンプルコード

WWDCのビデオでも紹介されている、JSONファイルをデータストアとしているサンプルコードです。

コード全文
Item.swift
import Foundation
import SwiftData

@Model
class Item {
    #Unique<Item>([\.id])
    
    var id: UUID
    var timestamp: Date
    
    init() {
        self.id = UUID()
        self.timestamp = Date()
    }
}
JSONDataStore.swift
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)
    }
}
ContentView.swift
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)
}
SampleApp.swift
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に切り替えることも可能です。

SampleApp.swift
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を指定

ちょっと解説

RemoteDataStore.swift
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)
    }
}

これらのfetchsaveDataStoreプロトコルの必須メソッドになります。
そのためメソッド自体を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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?