概要
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 の仕組みを考えると、この書き方では変更が検知されないケースがあります。
結論
@Model の Dictionary や Array の中身を変更する場合は、直接 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 内で Dictionary や Array を更新するときは、次のように考えると安全です。
避けたい書き方
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 の @Model で Dictionary を使う場合、次のような in-place 変更は変更検知されないことがあります。
themeStatusesRaw[key] = value
これは、人間から見るとプロパティを変更しているように見えますが、Observation の仕組みから見ると、プロパティ全体の setter が呼ばれない可能性があります。
そのため、変更を確実に反映させたい場合は、次のように一度ローカル変数で変更してから再代入します。
var updated = themeStatusesRaw
updated[key] = value
themeStatusesRaw = updated
ポイントは、最後にプロパティへ再代入することです。
themeStatusesRaw = updated
この一行によって、SwiftData / Observation に対して「このプロパティは変更されました」と明示的に伝えられます。
最後に
Dictionary や Array のようなコレクション型は、コード上は自然に中身を変更できてしまいます。
しかし、SwiftData や SwiftUI の変更検知と組み合わせる場合は、「中身を直接変更したか」ではなく、「プロパティの setter が呼ばれたか」が重要になる場面があります。
そのため、SwiftData の @Model でコレクションを扱うときは、
変更した値を最後にプロパティへ再代入する
という書き方を意識しておくと、原因不明の更新漏れを避けやすくなります。