Posts Swift ModelActorでの永続化データの更新をViewModelで検知する戦略

ModelActorでの永続化データの更新をViewModelで検知する戦略

はじめに

以前、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を利用する場面が多そうです。