Open17

TCAとSwiftData(もしくはRealm)の共存はどうやるのが良いのか

kabeyakabeya

TCAとSwiftDataあるいはRealmを一緒に使う場合、TCAのStateと、SwiftDataの@ModelあるいはRealmのObjectって、どのように扱うのが良いのかと悩んでいます。

永続化する必要のないStateもありますが、永続化すべきデータレコードのようなStateもあります。
後者はデータベースに保存したいところです。

ちなみに、現時点ではまだ何も試していません。

永続化するデータレコードをStateで扱い、Action操作の結果としてSwiftDataやRealmのオブジェクトに反映する、という方法もあるのかと思いますが、MVVMの話題でよくある「ModelとViewModelの同期を取るのが大変」という悩みがそっくりそのまま起こりそうです。
(State=ViewModel的なもの、SwiftData/Realmのデータ構造=Model的なもの)

Stateがそのまま@Modelで書けるといいのかなと思いますが、仕組み的にどうなんでしょうか。
マクロで生成されると思うので、なんとなくうまく行かない気がしますが。

ネットで調べてもそれっぽいコードがないんですよね。

kabeyakabeya

ChatGPTに聞きました。


推奨される設計ポイント

SwiftDataは永続化のみに専念させる:

ビジネスロジックやUI状態管理はTCAで行い、SwiftDataは必要に応じてデータを提供する役割に徹します。

StateとSwiftDataを同期させる:

SwiftDataでデータを操作した後、TCAのActionでStateを更新します。
例: 新しいデータの作成時に、SwiftDataへ保存し、同時にStateを更新。

パフォーマンスに配慮:

必要以上にSwiftDataへのクエリを発行せず、TCAのStateで表示に必要なデータを保持。


だそうです。
やっぱりそうなるのかな。

SwiftDataでデータを操作した後、TCAのActionでStateを更新します。

この文章の意味は「TCAのActionで、 SwiftDataでデータを操作した後、Stateを更新します」なのかしら。

kabeyakabeya

ということで引き続きChatGPTに聞きました。


SwiftDataでデータを操作した後、TCAのActionでStateを更新します。

というのは、TCAのAction内で、「SwiftDataのデータ操作」と「TCAのState更新」を同時に行うということでしょうか?その場合、順番はSwiftData→TCAの順が良いですか?


はい、TCAのAction内で「SwiftDataのデータ操作」と「TCAのStateの更新」を同時に行うことが一般的です。その際、処理の順番については 「SwiftData → TCA」 の順で行うのが良い理由があります。

順番: SwiftData → TCA の理由

永続化の失敗に対応できる:

  • データを永続化する処理(SwiftData)が失敗する可能性があります。その場合、TCAのStateを更新してしまうと状態が整合しなくなります。
  • 先にSwiftDataで処理を確定させてから、TCAのStateを更新することで、一貫性を保つことができます。

UIの状態が現実のデータと一致する:

  • SwiftDataでデータが保存されなかった場合にTCAのStateだけ更新すると、UIに表示されるデータと永続化データが異なる状態になります。
  • 永続化処理が成功した場合のみTCAの状態を更新することで、UIと実データの整合性が保たれます。

エフェクト処理の分離が簡単:

  • 永続化処理が非同期の場合も、永続化が完了したタイミングでActionを発行してStateを更新する設計が簡単に実現できます。

なるほど。更新のAction内でSwiftDataでの保存とStateの更新を同時に行う「のではなく」、SwiftData保存用アクション完了時のEffectで、State更新アクションを投げる、というような感じにしたほうが良いということなんですね。

kabeyakabeya

「TCA SwiftData」で検索すると以下が見つかります。後者は前者を参考に書かれた記事のように見受けられます。 →よく見ると結構違いますね…。

https://github.com/SouzaRodrigo61/SwiftDataTCA/

https://phychic-owl.medium.com/using-swift-data-and-the-composable-architecture-tca-6af11ebef240

これらのやり方は、ChatGPTのやり方とは違うようです。
自分は、ChatGPTの答えた内容はすんなり腹に落ちたような気がした一方で、上記のやり方は「うーん」という感じでしたね。

Dependencyについては、Live=ファイル、テスト=InMemoryでいいような気がしますしね。

kabeyakabeya

ChatGPTが出力するコードを見ると、言葉とは裏腹に先の2件の例のような書き方をしてます。

先の2件の例のような書き方:Stateが、@Modelで定義されたクラスのオブジェクトの配列をメンバで持つ
言葉:@Modelのデータと、Stateのデータが別々にあるイメージ

@Modelで定義されたオブジェクトの配列をメンバで持つ=その配列のデータをいじると、自動でDBにも反映される(自動保存が有効な場合)、ということですよね。明示的に.save()を呼ぶべきケースも多いとは思いますが。

一方、@ModelのデータとStateのデータが別々にあるイメージにすると、この同期処理をモデルごとに書かないといけなくなります。

ChatGPTの言葉に従って、一瞬だけ後者をやってみましたが、これはたぶんしんどいなという印象です。

素直にStateに@Modelで定義されたクラスのオブジェクト(あるいはその配列)を持たせてしまったほうが良さそうです。
新規レコードの追加画面なんかでは、@Modelのオブジェクトを直接作ってしまって、親画面側で.insert()するような作りにすべきなのかな(子画面でDBに.insert()すると、親画面のStateの配列とDBが一致しなくなりますからね)。

kabeyakabeya

@Modelで定義されたオブジェクトをStateに持たせました。
見た感じはうまく動きます。

一方、その上でテストを書いていますが、これ、以下のような奴が通りませんね。

        await store.send(.setName("test")) {
            $0.contact.name = "test"
        }

この例では、.contact@Modelで定義されたクラスのオブジェクトなんですが、structのときと違い、store.contact$0.contactが同じオブジェクトを指しています。クラスなので。

このため$0.contact.name = "test"とクロージャに書くと、Actual(=store.contact.name)とExpected(=$0.contact.name)が共に"test”に書き換わってしまいます(というか、どっちを書き換えても見える値が同じになる)。

このため、クロージャで$0.contact.nameに何を代入しても「Expected state to change, but no change occurred.」になってしまいます。

と言って、$0.contactに新たなオブジェクトを生成して入れると「A state change does not match expectation」と言われるんですね。

どうしたらいいのかな。

kabeyakabeya

ModelContextSendableでないのがあれですね。
どうしていいか分からない…

kabeyakabeya

「ModelContextがSendableではないのですが、Swift6でTCAのDependencyで使う方法がありますか?」という質問をChatGPTとGemini 2.0 Flash Experimentalに聞いてみました。

ChatGPT:

  • 方法1:クロージャをラップする型を作成
  • 方法 2: 型消去 (@escapingを利用)
  • 方法 3: グローバルの非同期タスクで処理

Gemini:

  • ModelActorの導入
  • DependencyとしてModelActorを提供
  • データの受け渡しはモデルオブジェクト自体ではなくPersistentIdentifierもしくはSendableな型を使用

だそうです。
Geminiの言ってることのほうがすっと理解できます。ChatGPTのほうは提示してくれたサンプルコードを見ても「これでいけるようには思えないんだけど」っていう感じでした。

一方で、Geminiの言ってることを見て思うのは「やっぱりそこ(=@Modelのデータ構造とStateのデータ構造を分けるような感じ)に戻ってくのね」ってことですね。

kabeyakabeya

Swift6 + TCA + SwiftDataのサンプルプロジェクトを作りました。

https://github.com/takenori-kabeya/ContactListTCAwithSwiftData

結局、ModelContextSendableでないのだけども、ModelContainerSendableなので、コンテキストではなくコンテナをliveとtestで分ける、というようなことにしました。

UIから実行されるときはコンテナの.mainContextを使用し、そうでない場合はその都度、コンテナからModelContextを生成する、ということにしました。

ただしまだこのサンプルではSwiftDataのRelationshipにノータッチなので、それが入るとどういうことになるのかちょっと不安です。

State用のstructから@Modelclassを自動生成するSwiftマクロもいちおう書いてみました。
上記のサンプル程度のシンプルなものしか考慮できてないのですが、そのうちGitHubにおいておきます。

kabeyakabeya

ただしまだこのサンプルではSwiftDataのRelationshipにノータッチなので、それが入るとどういうことになるのかちょっと不安です。

ContactPhoneNumberを複数持たせるというパターンを試そう、と思ってちょっとハマリ始めました。
何かというとPhoneNumberの種別ですね。
自宅、勤務先、携帯のほかに(iPhoneの「連絡先」にもあるように)カスタムという感じで自由に追加できるパターンも当然あるよね、と思ったのが運の尽き。
enum PhoneTypeをassociated typeとともに定義しようと思ったのですが、たぶんこれこういう用途には向いてないですよね。

以下の記事あたりを見てstructにすべきかと思いました。

https://zenn.dev/d_date/articles/964dd9c79e3d63

https://stackoverflow.com/questions/74965723/swift-use-enum-with-associated-properties-in-view

一方で、じゃあstructにするとして、こういうのをTCAでどう扱うのか。
たぶん「種別」のFeatureにするんでしょうね。
ちょっと調べたり試したりしてみます。

kabeyakabeya

たぶん「種別」のFeatureにするんでしょうね。
ちょっと調べたり試したりしてみます。

と書いたものの、いったんそこは置いておいて、種別にカスタムを含まない「自宅、勤務先、携帯」の3パターンだけで始めます。
まずはRelationshipの検証。

kabeyakabeya

案の定、Relationshipでハマっています。

なんていうか、Swift 6というかマルチスレッドというかコンカレンシーというか、その辺りの仕組みと、SwiftDataとかRealmとかのオブジェクトの操作結果がそのままDBのデータに反映される仕組みとは、相性悪すぎる気がしますね。

シームレスにマルチスレッドを扱いやすくするというのと、シームレスにデータとDBを同期させるというのが相容れないというか。いや、厳密に言えば、そこもシームレスにしようとすればできるんだけどそうすると性能がガタ落ちするのでシームレスにならないようにしているというか。

あと、単純にSwiftDataはファイルの中身が分かりにくいのでデバッグしづらい…

kabeyakabeya

エフェクト処理の分離が簡単:
永続化処理が非同期の場合も、永続化が完了したタイミングでActionを発行してStateを更新する設計が簡単に実現できます。

実際やってみると、この話、あんまりよくない気がします。

  1. Saveボタンを押す
  2. 編集画面のデータ(子State)をDBに永続化
  3. DBへの永続化が完了したら親Stateに反映
  4. 編集画面をdismiss
  5. 親画面の再描画

という流れになるべきですが、非同期に永続化すると(タイミング次第で)以下のようになってしまいます。

  1. Saveボタンを押す
  2. 編集画面のデータ(子State)をDBに永続化を開始(非同期)
  3. 編集画面をdismiss
  4. 親画面の再描画(子Stateはまだ反映されていない)
  5. DBへの永続化が完了。親Stateに反映

しかも、なんとなくですが5で親Stateが更新されても、親画面の再描画は走らないように見えます。
@State変数の場合、バックグラウンドで値が更新されるとXcodeが紫色の警告を出しますが、@Bindableだからなのか、そういうことも起きません。

というか、あれか。
編集画面のdismissを保存が完了する前にやっちゃっているのが良くない、というだけなのかな。
元々、TCAのチュートリアルがそんな感じだったので盲目的に従ってしまいましたけども。

dismissを親State反映まで待って行うようにしてみます。

kabeyakabeya

dismissを親State反映まで待って行うようにしてみます。

これ、言うのは簡単ですが、やろうとすると難しいですね。
DBの保存はasyncコンテキストで行う必要があるので、どこかでasyncコンテキストを起こしてやる必要があります。
子画面のdismissは、1) そのコンテキストで実行されている非同期処理の完了を待つか、2) 親(asyncコンテキスト)から処理完了時に子画面をdismissするように通知してもらう、かのいずれかで行う必要があります。

1は、もし非同期処理といいつつ@MainActorでの保存処理だとすると、待ったらデッドロックになってしまいます。
2は親画面から子画面のActionを呼び出すか、何か状態を更新する処理を呼び出すような感じになります。

https://zenn.dev/kalupas226/articles/87b1f7b245915c

この記事にあるように、子のActionを呼ぶのはやめて、子のStateにmutatingな関数を用意して、それを見てdismissする、ですかね。うまくいきますかね?
ちょっとやってみます。

kabeyakabeya

この記事にあるように、子のActionを呼ぶのはやめて、子のStateにmutatingな関数を用意して、それを見てdismissする、ですかね。うまくいきますかね?

なんとなく気持ちは悪いですけど、うまく行きますね。


やっぱり子に.dismissを送った方が良いような気がします。.dismissはあえて用意しなくても元から存在するようですし。
閉じるためにフラグとmutatingな関数を用意するというのもなんか気持ち悪いので。

kabeyakabeya

FetchDescriptor#Predicateマクロがうまくコンパイルできなくて困っています。
何が悪いのか。

kabeyakabeya

FetchDescriptor#Predicateマクロがうまくコンパイルできなくて困っています。
何が悪いのか。

諦めてpredicate:を使わずに素のFetchDescriptorで全件取ってきて、メモリ上でフィルタすることにしてしまいました。

とりあえずGitHubに最新版をコミットしました。データは旧版と互換性がないはずで、かつ、移行用のコードも書いていないため、もし旧版を実行していれば、default.storeをいったん削除してから実行してください。

https://github.com/takenori-kabeya/ContactListTCAwithSwiftData

中身は、TCAのチュートリアルにあったようなコンタクトリストです。

  • 連絡先(名前)を複数登録できる
  • 連絡先(名前)の削除ができる
  • 連絡先(名前)の修正ができる
  • 各連絡先に、電話番号を複数登録できる
  • 電話番号は、自宅(home)、勤務先(office)、携帯(mobile)の種別が付く(付けられる)
  • 各電話番号の削除ができる
  • 各電話番号・種別の修正ができる

ただ、何となくは動いていますが、データがきれいに削除されているのか、とかをもう少し確認する必要があります。

Schemaに電話番号の分を入れなくても動いているように見えたり、連絡先の初回生成時はinsertすると電話番号のinsertをしなくても保存できてたり、正直、何が必須で何が必須でないのかよく分かってない部分も多々あります。