0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

以前、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クラスが解放されず、メモリリークが起こる可能性もあるため、注意して実装する必要があります。

SampleRepsitory.swift
@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(())
    }
}
AsyncStreamBroadcaster.swift
/// ブロードキャストに対応した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する方法などを用いて、ユーザーにロード時間を意識させない工夫をすることができます。

SampleStore.swift
@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を利用する場面が多そうです。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?