はじめに
以前、ViewModelからSwiftDataの永続化データを扱うという記事を公開しました。
この記事、知人などから、記事を見たと言っていただけることが多く、大変嬉しいです。
ModelActorを利用すればViewと永続化層を分離することができ、テストコードが書きやすくなります。
AI利用時のガードレールとしてテストコードの重要性が上がっていることもあり、更に利用したい場面が増えるのではないかと考えています。
上記の記事内では、永続化データの更新をViewModelなどのプレゼンテーション層で検知する仕組みとして、NotificationCenterでの通知をCombineで監視する方法を紹介しました。
しかし、NotificationCenterはアプリ内のどこからでもアクセスできるため、通知の発行元や監視先がわかりづらく、スパゲッティなコードを生む原因になりがちです。
また、Swift Concurrencyとの相性もあまり良いとは言えません。
この記事では、NotificationCenter + Combineで通知をやり取りする方法に代わる、永続化データの更新検知の方法を2つ紹介します
前置き
この記事では、前回のViewModelからSwiftDataの永続化データを扱うと同様のアーキテクチャを前提とします。
アーキテクチャに関する詳しい内容はこの記事を読むか、以下から展開してご確認ください。
今回の記事では、永続化データの更新時にViewModel側でその更新を検知する方法について考えます。
この記事で対象とするアーキテクチャの概要
Observationフレームワークを利用したMVVM構成を対象としています。
具体的には以下のような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 {}
}
@ModelActor
actor ContentRepository {
/// 全ての永続化情報を返す関数
func fetchAll() async -> [ContentEntity] {}
/// 新しいcontentを永続化する処理
func insertContent(contentEntity: ContentEntity) async {}
}
方法1: AsyncStreamをfor await-inループで購読
AsyncStreamを利用することで永続化データの更新を通知できます。
この方法は、Combineなどでの情報の監視に比べてSwift Concurrencyライクな処理の記載ができることが強みです。
一方で、AsyncStreamはCombineと違ってマルチキャストに対応していません。
そのため、購読するView1件ごとに独立したstreamを管理することが必要となります。
また、Task内でfor await-inループを利用する場合には、ViewModelクラスがTaskに強参照されることでViewModelクラスが解放されず、メモリリークが起こる可能性もあるため、注意して実装する必要があります。
@ModelActor
actor SampleRepository {
nonisolated let dataChangedBroadcaster: AsyncStreamBroadcaster<Void> = .init(bufferingPolicy: .bufferingNewest(1))
nonisolated var dataChangedStream: AsyncStream<Void> {
dataChangedBroadcaster.makeStream()
}
func insert(entity: SampleEntity) async throws {
// insert処理を実行
try modelContext.save()
dataChangedBroadcaster.yield(())
}
}
/// ブロードキャストに対応したAsyncStreamを提供するクラス
public final class AsyncStreamBroadcaster<Element: Sendable>: Sendable {
private let bufferingPolicy: AsyncStream<Element>.Continuation.BufferingPolicy
private let continuations = OSAllocatedUnfairLock<[UUID: AsyncStream<Element>.Continuation]>(initialState: [:])
public init(
bufferingPolicy: AsyncStream<Element>.Continuation.BufferingPolicy = .unbounded
) {
self.bufferingPolicy = bufferingPolicy
}
/// 新しいStreamを作成
public func makeStream() -> AsyncStream<Element> {
let id = UUID()
let (stream, continuation) = AsyncStream<Element>.makeStream(
of: Element.self,
bufferingPolicy: bufferingPolicy
)
continuations.withLock { continuations in
continuations[id] = continuation
}
continuation.onTermination = { [weak self] _ in
Task {
self?.continuations.withLock { continuations in
continuations.removeValue(forKey: id)
}
}
}
return stream
}
/// 全てのStreamに値を送信
public func yield(_ value: Element) {
continuations.withLock { continuations in
for continuation in continuations.values {
continuation.yield(value)
}
}
}
/// 全てのStreimに終了を通知
public func finish() {
continuations.withLock { continuations in
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
}
}
方法2: Observableなクラスを中継する
この方法は、iOSDC Japan 2025でChatworkアプリにおけるSVVS実装戦略というセッションをヒントにした方法です。
このセッションで紹介されていたStoreというクラスは、Observableなクラスであり、外部リソースから受け取った情報をプロパティとして保持します。
そして、ViewState(この記事で言うViewModelと似た役割を持つObservableなクラス)から参照することで、Storeの変更が自動的にViewStateに反映されます。
これと同等な仕組みを利用することで、SwiftDataから取得した永続化データをObservableなクラス(以後Storeクラス)に保持し、ViewModelから参照することで、通知の購読をObservationフレームワークに任せることができ、自前で実装する必要がなくなります。
さらに、この方法では、データ量が多い場合にもロード方法を工夫できるというメリットがあります
例えば、先頭100件とそれ以外のデータを別のTaskでfetchすることで、100件目までは素早くロードするような工夫や、ページングを用いて段階的にfetchする方法などを用いて、ユーザーにロード時間を意識させない工夫をすることができます。
@MainActor
@Observable
final class SampleStore {
private(set) var samples: [SampleEntity] = []
init(…) {
……
Task { samples = await syncSamples() }
}
// SwiftDataから永続化データをfetchしてsamplesプロパティを更新する
// samplesプロパティへデータを格納するときのロジックを自由に実装可能(ページング処理など)
func syncSamples() async { … }
// SwiftDataの永続化データをinsertする
// 成功したら、samplesプロパティにもentityを追加する
func insertSample(entity: SampleEntity) async throws { … }
}
まとめ
この記事では、ViewModelからModelActorのデータ変更を検知する仕組みについて、新しい方法を2つ紹介しました。
方法1では、前記事の方法に比べてSwift Concurrencyライクに処理を記載できますし、グローバルなNotificationCenterという仕組みに頼らず実装が可能で、よりシンプルでテストしやすい実装が可能です。
今後、マルチキャスト可能なAsyncStreamが提供されれば、より便利に利用できるようになりそうです。
方法2では、ObservableなStoreクラスを用意することで通知の機構を時前で実装することなく、Observationフレームワークの力で更新を通知できます。
また、他の画面でロードしたデータがメモリ上に保持されるため、1度ロードや更新を行ったデータが画面間で共有され、いわゆるいいね問題も回避できそうです。
一方で、SwiftData上に永続化していてもStoreに読み出されていないデータはViewModel上に表示されませんし、常にSwiftDataとStoreで確実に整合性を取る必要があります。
私の個人開発では、今後利用するならページングの実装や「いいね問題」の回避が簡単な方法2を利用する場面が多そうです。