Posts SwiftUI ViewModelからSwiftDataの永続化データを扱う

ViewModelからSwiftDataの永続化データを扱う

はじめに

ObsavableマクロをつけたViewModelで、SwiftDataによるデータの永続化を扱う機会がありました。 SwiftDataで永続化された情報をViewに描画する方法として一般的なものに@Queryマクロを利用するものがあるかと思います。 しかし、この@Queryマクロは、ViewModel上で利用できず、View上に定義する必要があります。 Viewにプロパティが存在していては、ViewModelを作ることで得られるテスト容易性などのメリットを損なってしまいます。 この記事では、ViewModelからSwiftDataを扱う方法を紹介します。

この記事で作成したコードはGitHub Gistに公開しているので、手っ取り早くコードだけ読みたい方や手元で試したい方はご利用ください。

前提条件

環境

  • OS: macOS Sonoma 14.7.1
  • XCode: 16.2.0

対象とするアーキテクチャ

Observationフレームワークを利用したMVVM構成を対象としています。 (ObservableObjectを利用しても同様のことが可能だと思いますが、動作未確認です) 具体的には以下のようなView及びViewModelを考えます。 ContentViewは、ContentViewModelが保持しているcontents配列のタイトルを一覧表示します ContentsViewModelがrepositoryに対して永続化データを要求し、contentsの状態を更新することでViewの表示内容を更新します。

struct ContentView: View {
    @State var viewModel = ContentViewModel()

    var body: some View {
        ScrollView {
            VStack {
                Button("content追加") {
                    Task { await viewModel.insertContents() }
                }
                ForEach(viewModel.contents, id: \.self) { content in
                    Text(content.title)
                }
            }
        }
    }
}

@MainActor
@Observable
final class ContentViewModel {
    /// ContentViewからこのプロパティを監視する
    var contents = [ContentEntity]()

    /// 全ての永続化情報を受け取り、contentsを上書きする
    func fetchContents() async {}

    /// contentを1件追加する関数
    func insertContent() async {}
}

Repositoryを作る

Repositoryにほしい要件は、以下の2つです。

  • ViewModelが@Queryマクロを利用せずに、永続がデータを取得できること
  • UIスレッドを占有しないよう、MainActor以外で動作すること

それぞれ見ていきましょう!

ViewModelが永続化データを取得できる

こちらは簡単です! SwiftDataには、手動で永続がデータを取得できるfetchメゾットが用意されています! https://developer.apple.com/documentation/swiftdata/modelcontext/fetch(_:) これを利用すれば、ViewModelからも永続化データの取得ができそうですね!

UIスレッドを占有しない

ModelActor

SwiftDataには、バックグラウンドスレッドで処理を行うためのマクロ@ModelActorが用意されているようですので、これを利用しましょう! @ModelActorマクロは、modelContainerを引数としてとるイニシャライザを生成するので、modelContainerを管理するModelContainerManagerクラスも作ることにします。 今回は本題とずれるので詳細は省略しますが、表示するViewに.modelContainerモディファイアをつけ、ModelContainerManagerが保持しているmodelContainerを渡すことも必要です。

永続化情報をActor境界を超えてViewModelに渡す

MainActor以外で処理を行う都合上、ViewModelとRepositoryの間でアクター境界をまたぐことになります。 SwiftDataで@Modelマクロを使って作成するモデルクラスはSendableではないので、アクター境界を超えることができません。 解決策はいくつかあると思いますが、今回はSendableなstruct作成し、必要な情報を詰めてViewModelに渡すことにします。 この記事では、SwiftDataの永続化のためのModelをContentModel、アクター境界を超えるためのSendableなstructをContentEntityと区別することにします。

出来上がったコード

ここまでを踏まえて、以下のようなコードを書きました

/// SwiftDataの永続化モデル
@Model final class ContentModel {
    @Attribute(.unique) var id: UUID
    var title: String

    init(id: UUID, title: String) {
        self.id = id
        self.title = title
    }
}

/// アクター境界を超えるためのSendableなクラス
struct ContentEntity: Sendable, Hashable {
    let id: UUID
    let title: String
}

/// modelContainerを管理するクラス
final class ModelContainerManager {
    static let shared = ModelContainerManager()
    let modelContainer: ModelContainer

    private init() {
        let schema = Schema([ContentModel.self])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        modelContainer = try! ModelContainer(for: schema, configurations: modelConfiguration)
    }
}

/// 永続化情報を返すRepository
@ModelActor
actor ContentRepository {
    /// 全ての永続化情報を返す関数
    func fetchAll() async -> [ContentEntity] {
        let fetchDescriptor = FetchDescriptor<ContentModel>()
        let models = try? modelContext.fetch(fetchDescriptor)

        guard let models else { return [] }
        return models.map {
            ContentEntity(
                id: $0.id,
                title: $0.title
            )
        }
    }

    /// 新しいcontentを永続化する処理
    func insertContent(contentEntity: ContentEntity) async {
        let model = ContentModel(
            id: contentEntity.id,
            title: contentEntity.title
        )
        modelContext.insert(model)

        guard modelContext.hasChange else { return }
        try? modelContext.save()
    }
}

ViewModelにContentEntityを渡す

これで準備は整いました! さっそくViewModelから永続化データを取り出してみましょう!

まずはシンプルに永続化データを取り出す

シンプルな方法で永続化データを取り出してみます。 viewModel作成時のイニシャライザでContentRepositoryから永続化データを取り出し、contents変数に値を代入します。 insertContent関数では、ContentRepositoryから永続化データを追加したあと、fetchContentsメゾットでcontents変数の値を更新しています。

@MainActor
@Observable
final class ContentViewModel {
    let contentRepository = ContentRepository(modelContainer: ModelContainerManager.shared.modelContainer)
    var contents = [ContentEntity]()

    init() {
        Task { await fetchContents() }
    }

    func insertContents() async {
        let contentEntity = ContentEntity(
            id: UUID(),
            title: Date().description
        )
        await contentRepository.insertContent(contentEntity: contentEntity)
        await fetchContents()
    }

    private func fetchContents() async {
        let contents = await contentRepository.fetchAll()
        self.contents = contents
    }
}

combineを使ってcontents変数の更新を管理する

このままでは、contentsの更新を忘れると、内容が画面に反映されません。 そこで、CombineとNotificationCenterを利用して永続化データが追加されるたびに通知を出すようにしてみます。 この方法であれば、viewModel側はNotificationCenterの通知を監視するだけで永続化データを更新することができます

@MainActor
@Observable
final class ContentViewModel {
    @ObservationIgnored let contentRepository = ContentRepository(modelContainer: ModelContainerManager.shared.modelContainer)
    var cancellables = Set<AnyCancellable>() // 追加
    var contents = [ContentEntity]()

    init() {
        observeContents() // 追加
        Task { await fetchContents() }
    }

    func insertContents() async {
        let contentEntity = ContentEntity(
            id: UUID(),
            title: Date().description
        )
        await contentRepository.insertContent(contentEntity: contentEntity)
        // fetchContents関数を削除
    }

    /// 新規実装
    /// NotificationCenterの通知を購読し、fetchContentsメゾットを実行
    private func observeContents() {
        NotificationCenter.default.publisher(for: Notification.Name("shouldUpdateContents")).sink { [weak self] _ in
            Task { await self?.fetchContents() }
        }.store(in: &cancellables)
    }

    private func fetchContents() async {
        let contents = await contentRepository.fetchAll()
        self.contents = contents
    }
}

@ModelActor
actor ContentRepository {
    /// 全ての永続化情報を返す関数
    func fetchAll() async -> [ContentEntity] {
        let fetchDescriptor = FetchDescriptor<ContentModel>()
        let models = try? modelContext.fetch(fetchDescriptor)

        guard let models else { return [] }
        return models.map {
            ContentEntity(
                id: $0.id,
                title: $0.title
            )
        }
    }

    /// 新しいcontentを永続化する処理
    func insertContent(contentEntity: ContentEntity) async {
        let model = ContentModel(
            id: contentEntity.id,
            title: contentEntity.title
        )
        modelContext.insert(model)

        guard modelContext.hasChanges else { return }
        try? modelContext.save()

        // 追加
        NotificationCenter.default.post(
            name: Notification.Name("shouldUpdateContents"),
            object: nil
        )
    }
}

データが取得できたことを確認

ここまでで、ViewModelからSwiftDataの永続化データを取得することができました。 「contentsを追加」ボタンを押すことで、永続化データを追加し、画面に反映しています。 demo.gif

終わりに

SwiftDataでViewModelから永続化データを扱う方法をまとめました。 @Queryマクロに比べるとかなりコード量が増えてしまうのが欠点ですが、ViewModelをTestableに保ちながらSwiftDataを扱えるのではないかと思います。 補足・指摘等ありましたら、コメントまでよろしくお願いします。

補足1: SwiftDataのsaveメゾットについて

ModelContextには、自動で更新内容を保存する仕組みが実装されています。 ModelContext.autosaveEnabledで設定が可能で、デフォルトがtrueなので、基本的には意識せずとも自動で更新内容が保存されます。

ただ、この自動保存の仕組みは、ModelContextMainActorで扱ったときのみ動作します。 @ModelActorを利用している場合など、MainActor以外で更新処理を行った場合には、明示的に保存を行うsaveメゾットを利用するか、ModelContext.transaction(block:)を利用する必要があります。

補足2: RealmのCollectionPublisher

iOSのデータ永続化を実現するOSSに、Realm-swiftというライブラリがあります。 RealmにはcollectionPublisherというものが用意されており、Combineと組み合わせることで、更新時に通知を受け取れます。 collectionPublisherを利用する場合、filter済みのデータに変更があった場合のみ通知が流れます。 NotificationCenterを利用する方法では、例えば画面の描画に必要ないデータが追加された場合であっても通知が流れてしまうので、Realmの方がより柔軟な通知を提供できそうです。 今後のSwiftDataのアップデートに期待ですね!

補足3: Repositoryのテスト

今回作成したContentRepositoryは、modelContainerをDIする仕様のため、テストも容易です。 例えば、DIするmodelContainermodelConfigurationを以下のように変更してDIすることで、テスト時にはSwiftDataをメモリ上で動作させることができます。

let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)