はじめに
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を追加」ボタンを押すことで、永続化データを追加し、画面に反映しています。
終わりに
SwiftDataでViewModelから永続化データを扱う方法をまとめました。
@Query
マクロに比べるとかなりコード量が増えてしまうのが欠点ですが、ViewModelをTestableに保ちながらSwiftDataを扱えるのではないかと思います。
補足・指摘等ありましたら、コメントまでよろしくお願いします。
補足1: SwiftDataのsaveメゾットについて
ModelContext
には、自動で更新内容を保存する仕組みが実装されています。
ModelContext.autosaveEnabledで設定が可能で、デフォルトがtrue
なので、基本的には意識せずとも自動で更新内容が保存されます。
ただ、この自動保存の仕組みは、ModelContext
をMainActor
で扱ったときのみ動作します。
@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するmodelContainer
のmodelConfiguration
を以下のように変更してDIすることで、テスト時にはSwiftDataをメモリ上で動作させることができます。
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)