SwiftUI: @AppStorageをもう少し理解する
データの保存をするのに、iOS 14から導入された@AppStorage
プロパティラッパーが便利と聞きました。
これを使って多少複雑なデータを保存してもいいのかな?というのを試す前に、そもそもどういう動きをしているのかWebで調査しました。
参考にしたのは以下の記事です。
Viewとそれ以外で動作が異なる、というので、実際何が起きてそうなのか、もう少し確認してみました。
サンプルコード
struct AppStorageView: View {
@AppStorage("Count") var count = 0
var body: some View {
let text = "AppSorageView \(count)"
Button(text){
count += 1
}.buttonStyle(.bordered)
}
}
struct ObservedObjectView: View {
@ObservedObject var countModel: CountModel
var body: some View {
let text = "ObservedObjectView \(countModel.count)"
Button(text){
countModel.count += 1
}.buttonStyle(.bordered)
}
}
struct ObservableObjectView: View {
var countModel: CountModel = CountModel(timeInterval: 2)
var body: some View {
let text = "ObservableObjectView \(countModel.count)"
Button(text){
countModel.count += 1
}.buttonStyle(.bordered)
}
}
struct NonObservableObjectView: View {
var countModel: CountModelNonObservable = CountModelNonObservable(timeInterval: 5)
var body: some View {
let text = "NonObservableObjectView \(countModel.count)"
Button(text){
countModel.count += 1
}.buttonStyle(.bordered)
}
}
struct CountView: View {
var observedCountModel: CountModel
var body: some View {
VStack {
AppStorageView()
ObservedObjectView(countModel: observedCountModel)
ObservableObjectView()
NonObservableObjectView()
Button("UserDefaults"){
UserDefaults.standard.set(0, forKey: "Count")
}.buttonStyle(.bordered)
}
}
}
class CountModel: ObservableObject{
@AppStorage("Count") var count: Int = 0
var timer: Timer
init(timeInterval: TimeInterval) {
print("CountModel init: \(Date.now)")
self.timer = Timer()
self.timer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: true) { _ in
self.count += 1
print("CountModel(\(timeInterval)) count up -> \(self.count)")
}
}
}
class CountModelNonObservable {
@AppStorage("Count") var count = 0
var timer: Timer
init(timeInterval: TimeInterval) {
print("CountModelNonObservable init: \(Date.now)")
self.timer = Timer()
self.timer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: true) { _ in
self.count += 1
print("CountModelNonObservable count up -> \(self.count)")
}
}
}
struct ContentView: View {
@StateObject var countModel = CountModel(timeInterval: 3)
init() {
print("init: \(Date.now)")
}
var body: some View {
VStack {
let _ = print("ContentView.body \(Date.now)")
CountView(observedCountModel: countModel)
}
}
}
もと記事と同様、同じ@AppStorage
変数を読み書きします。
タイマーで値を自動更新するようにしました。
結論的なもの
確認した感じでは、以下のように推測しました。
言えること | 推測理由 |
---|---|
@Publish と同様、@AppStorage 変数が更新されるとそれを監視しているView に通知が行って再描画される |
observedCountModel の値が更新されるとObservedObjectView に更新がかかるため |
更新された@AppStorage 変数と同じ@AppStorage 変数を見ているView には通知が行って再描画される |
どのオブジェクトのタイマーも、更新された値がAppStorageView に反映されるため |
@AppStorage 変数があるオブジェクトでも、View 側に@ObservedObject としてマークされてなければView には通知が来ない |
ObservableObjectView には更新がかからないため |
UserDefaults からの読み込みはイニシャライザでのみ行われ、その後の変数の更新は最初に読み込んだ変数値をもとにして行われる(途中で再読込されない) |
タイマーで更新されている変数の値が、それぞれのオブジェクトで異なっているため |
View の@AppStorage 変数が常に最新に見えるのは、単に通知を受け取ったView が毎回イニシャライズされているせい。@AppStorage 変数もイニシャライザで再読込されている |
init のログから判断 |
UserDefaults への書き込み自体は、@AppStorage 変数に更新がかかる都度、毎回行われている |
AppStorageView の値がいったりきたりすることから、最後に更新された値で毎回書き込まれていることがわかる |
簡単に言うと
確かに「View
以外で@AppStorage
を使う場合は注意」なんですけども、どちらかというと「@AppStorage
を読むのはどこで読んでもいいけども、更新は1箇所で行え」という感じかなと思います。
割と普通の話ですね。
よくよく元記事のコードの動きを見ると
View以外にAppStorageを使用した場合、そのインスタンスで一度書き込みを行うまでは即座に反映されます。
一度書き込みを行った後はキャッシュされるようで、他で書き込まれても反映されません。
この動きになっています。単純にイニシャライザで読み込んでいるだけ、というわけでもなさそうです。
-
set
されるまでは、get
のたびに毎回ストレージまで取りにいく - いったん
set
されるとその時点で変数とストレージとを切り離して、それ以降のget
は自分の値を返す
というような感じでしょうか。
これ知らないとヤバい動きですね。
元記事のコードをmacOS Appプロジェクトを作って動かしてみた。ボタンを押していると次のような画面が出現する。今は理由が良く解らん。
Preview Canvasで動かすと期待した動きを見せる。
$xcodebuild -version
Xcode 16.0
Build version 16A242d
$sw_vers
ProductName: macOS
ProductVersion: 15.0
BuildVersion: 24A335
自分のところでは、以下で再現します。
- 3番目のボタンを押す
- 4番目のボタンを押す
- 5番目のボタンを押す
class
にしてしまうと(おそらくObservableObject
かどうかに依らず)以下のように動いているように見えます。
- setされるまでは、getのたびに毎回ストレージまで取りにいく
- いったんsetされるとその時点で変数とストレージとを切り離して、それ以降のgetは自分の値を返す
3番目のボタン、4番目のボタンは、それぞれ「いったんsetされるとその時点で変数とストレージとを切り離して、それ以降のgetは自分の値を返す」というような動きを引き起こすボタンなので、「Not Attribute」と「Not ObservableObject」が独自に動き始めてしまいます。
つまり5番目のボタンでリセットしているのにそれが反映されませんし、3番目のボタンと4番目のボタンでカウントアップされるのは押したボタンに対応するテキストだけになってしまいます。
これ、改めてSILを追っかけてみましょうか。
また別の現象に見舞われました。
@AppStorage
の変数を更新しても、なんか戻る、です。
struct TextFieldView: View {
@State var timer: Timer?
@AppStorage("TextValue") var textValue: String = "1"
var body: some View {
TextField("テキストをここに入力", text: $textValue)
.textFieldStyle(.roundedBorder)
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
print("TextFieldView.TextValue: \(self.textValue)")
}
}
.onChange(of: self.textValue) { (oldValue, newValue) in
print("onChange: \(oldValue) -> \(newValue), \(self.textValue)")
}
}
}
struct ContentView: View {
@State var timer: Timer?
@AppStorage("TextValue") var textValue: String = "1"
var body: some View {
VStack {
Picker("値の更新", selection: $textValue) {
ForEach(1 ... 10, id: \.self) { idx in
let text = idx.formatted()
Text(text).tag(text)
}
}
TextFieldView()
}
.padding()
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
print("ContentView.TextValue: \(self.textValue)")
}
}
}
}
#Preview {
ContentView()
}
プルダウンメニュー(?)とテキストフィールドで同じ@AppStorage
変数を参照・更新します。
どっちのビューもタイマーを使って1秒間隔でtextValue
の値をデバッグプリントします。
このとき以下のような現象が発生します。
- プルダウンで4に変更すると、テキストフィールドの表示も4になる
- タイマーで出力される値は、プルダウン=4、テキストフィールド=1
- テキストフィールドに数値、例えば5をタイプすると、プルダウンもちゃんと5に同期する
- この場合のタイマーで出力される値は、プルダウン=5だが、テキストフィールド=1のまま
- まれに?テキストフィールドのタイマー値も変わることがある。条件は不明
TextField
とかのバインディング値を@AppStorage
にしたらいかん、ということなのかしら。
ちょっと試してみましょうか。
struct TextFieldView: View {
@State var timer: Timer?
@State var text: String = "1"
@AppStorage("TextValue") var textValue: String = "1"
var body: some View {
TextField("テキストをここに入力", text: $text)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.textValue = self.text
}
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
print("TextFieldView.TextValue: \(self.textValue)")
}
self.text = self.textValue
}
.onChange(of: self.textValue) { (oldValue, newValue) in
print("onChange: \(oldValue) -> \(newValue), \(self.textValue)")
self.text = newValue
}
}
}
TextField
には@State
変数を割り当てて、onChange
とonSubmit
で@AppStorage
変数に同期するようにしました。
- テキストフィールドに手入力したときは、テキストフィールドのタイマー値も変わるようになった
- プルダウンで変更したときは、テキストフィールドのタイマー値は変わらない
- どちらも、表示自体は更新される(プルダウンとテキストフィールドの値が一致する)
なんか面倒くさいですね、@AppStorage
。
結局のところ、@AppStorage
変数を更新するのはどこか1つのビュー、ということにしなければならないような気がしますね。
その値を複数のビューで更新するような設計は避けた方が無難そうです。
避けた方が無難そうな設計というのは以下の2つの設計です。
- 複数のビューで同じ
@AppStorage
変数を更新する。更新が1箇所ならOK(たぶん)。 -
@AppStorage
変数を@Binding
に渡す。渡せるし、またぱっと見、動作もしているように見えるが、@Binding
変数と@AppStorage
変数との同期が切れるときがある(というか何かの拍子にしか同期しない)。
この例で言うと、TextField
はやめて単にText
で表示だけし、更新はさせないようにする、というようなことですね。