3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftData の @Model で Dictionary の変更が保存・反映されない

3
Posted at

概要

SwiftData の @Model で、Dictionary 型のプロパティを使っていたところ、値を書き換えたはずなのに View が更新されなかったり、保存がうまく反映されなかったりすることがありました。

例えば、次のようなコードです。

@Model
final class UserSettings {
    var themeStatusesRaw: [String: String] = [:]
}

そして、テーマごとの状態を更新するためにこう書いていました。

func setThemeStatus(_ status: ThemeStatus, for theme: Theme) {
    themeStatusesRaw[theme.rawValue] = status.rawValue
}

一見何も問題なさそうに見えますが・・・

しかし SwiftData / Observation の仕組みを考えると、この書き方では変更が検知されないケースがあります。


結論

@ModelDictionaryArray の中身を変更する場合は、直接 in-place に変更するのではなく、一度ローカル変数にコピーしてから再代入するのが安全です。

func setThemeStatus(_ status: ThemeStatus, for theme: Theme) {
    var updated = themeStatusesRaw
    updated[theme.rawValue] = status.rawValue
    themeStatusesRaw = updated
}

重要なのは最後のこの行です。

themeStatusesRaw = updated

この再代入によって、SwiftData / Observation が「このプロパティは変更された」と検知できます。


なぜ themeStatusesRaw[key] = value ではダメなのか

SwiftData の @Model は、内部的に Observation の仕組みと連携しています。

ざっくり言うと、@Observable はプロパティへのアクセスや代入を追跡するために、コンパイル時にプロパティをラップします。

イメージとしては、次のような変換が行われます。

// 自分が書いたコード
var themeStatusesRaw: [String: String]

これが内部的には、概念的に次のような形になります。

var themeStatusesRaw: [String: String] {
    get {
        _$observationRegistrar.access(self, keyPath: \.themeStatusesRaw)
        return _themeStatusesRaw
    }
    set {
        _$observationRegistrar.withMutation(self, keyPath: \.themeStatusesRaw) {
            _themeStatusesRaw = newValue
        }
    }
}

ポイントは、変更検知が主に set のタイミングで行われるということです。

つまり、次のようにプロパティ全体へ代入した場合は、setter が呼ばれます。

themeStatusesRaw = newDictionary

この場合、Observation は「themeStatusesRaw が変更された」と判断できます。


問題のコード

問題になりやすいのは、次のようなコードです。

themeStatusesRaw[theme.rawValue] = status.rawValue

人間が見ると、これは明らかに themeStatusesRaw を変更しているように見えます。

しかし、Observation から見ると少し話が違います。

このコードは、概念的には次のような操作です。

// 1. themeStatusesRaw の値を取り出す
// 2. 取り出した Dictionary の subscript を変更する
// 3. themeStatusesRaw 自体に新しい値を代入しているとは限らない

つまり、themeStatusesRaw というプロパティそのものに対して、

themeStatusesRaw = ...

という代入が明示的に行われていません。

そのため、Observation の setter が呼ばれず、変更が検知されないケースがあります。


たとえ話:箱と中身

themeStatusesRaw を「箱」だと考えると分かりやすいです。

var themeStatusesRaw: [String: String]

これは辞書が入った箱です。

SwiftData / Observation が確実に気づきやすいのは、箱そのものを交換したときです。

themeStatusesRaw = [
    "dark": "enabled",
    "light": "disabled"
]

これは「箱を新しいものに交換した」状態です。

一方で、次のコードは箱の中身だけを直接いじっています。

themeStatusesRaw["dark"] = "enabled"

人間から見ると変更ですが、監視の仕組みから見ると「箱そのものが交換された」とは判断できない場合があります。

そのため、SwiftData / SwiftUI が変更を見逃すことがあります。


修正方法

修正後のコードは次のようになります。

func setThemeStatus(_ status: ThemeStatus, for theme: Theme) {
    var updated = themeStatusesRaw
    updated[theme.rawValue] = status.rawValue
    themeStatusesRaw = updated
}

処理を分解するとこうです。

var updated = themeStatusesRaw

まず現在の辞書をローカル変数にコピーします。

updated[theme.rawValue] = status.rawValue

ローカル変数の辞書を変更します。

themeStatusesRaw = updated

最後に、変更済みの辞書を themeStatusesRaw に再代入します。

この最後の再代入によって setter が呼ばれ、Observation が変更を検知できます。


Array の append も同じ?

考え方としては同じです。

例えば次のようなコードです。

photoData.append(data)

これは photoData という配列の中身を直接変更しています。

Observation に確実に変更を伝えたい場合は、次のように書く方が安全です。

var updated = photoData
updated.append(data)
photoData = updated

ただし、SwiftData は配列やリレーションについては内部的にうまく追跡できるケースもあります。

そのため、配列では問題が出にくい一方で、Dictionary では変更が反映されない、ということが起こりえます。


Before / After

Before

func setThemeStatus(_ status: ThemeStatus, for theme: Theme) {
    themeStatusesRaw[theme.rawValue] = status.rawValue
}

一見自然な書き方ですが、themeStatusesRaw の setter が呼ばれず、変更検知されない可能性があります。

After

func setThemeStatus(_ status: ThemeStatus, for theme: Theme) {
    var updated = themeStatusesRaw
    updated[theme.rawValue] = status.rawValue
    themeStatusesRaw = updated
}

themeStatusesRaw に変更済みの辞書を再代入することで、SwiftData / Observation に変更を伝えられます。


実用ルール

SwiftData の @Model 内で DictionaryArray を更新するときは、次のように考えると安全です。

避けたい書き方

model.dictionary[key] = value
model.array.append(value)

安全寄りの書き方

var updatedDictionary = model.dictionary
updatedDictionary[key] = value
model.dictionary = updatedDictionary
var updatedArray = model.array
updatedArray.append(value)
model.array = updatedArray

まとめ

SwiftData の @ModelDictionary を使う場合、次のような in-place 変更は変更検知されないことがあります。

themeStatusesRaw[key] = value

これは、人間から見るとプロパティを変更しているように見えますが、Observation の仕組みから見ると、プロパティ全体の setter が呼ばれない可能性があります。

そのため、変更を確実に反映させたい場合は、次のように一度ローカル変数で変更してから再代入します。

var updated = themeStatusesRaw
updated[key] = value
themeStatusesRaw = updated

ポイントは、最後にプロパティへ再代入することです。

themeStatusesRaw = updated

この一行によって、SwiftData / Observation に対して「このプロパティは変更されました」と明示的に伝えられます。


最後に

DictionaryArray のようなコレクション型は、コード上は自然に中身を変更できてしまいます。

しかし、SwiftData や SwiftUI の変更検知と組み合わせる場合は、「中身を直接変更したか」ではなく、「プロパティの setter が呼ばれたか」が重要になる場面があります。

そのため、SwiftData の @Model でコレクションを扱うときは、

変更した値を最後にプロパティへ再代入する

という書き方を意識しておくと、原因不明の更新漏れを避けやすくなります。

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?