Open7

SwiftUI: @AppStorageをもう少し理解する

kabeyakabeya

データの保存をするのに、iOS 14から導入された@AppStorageプロパティラッパーが便利と聞きました。

これを使って多少複雑なデータを保存してもいいのかな?というのを試す前に、そもそもどういう動きをしているのかWebで調査しました。

参考にしたのは以下の記事です。

https://thwork.net/2021/11/16/swiftui_appstorage_behavior/

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箇所で行え」という感じかなと思います。

割と普通の話ですね。

kabeyakabeya

よくよく元記事のコードの動きを見ると

View以外にAppStorageを使用した場合、そのインスタンスで一度書き込みを行うまでは即座に反映されます。
一度書き込みを行った後はキャッシュされるようで、他で書き込まれても反映されません。

この動きになっています。単純にイニシャライザで読み込んでいるだけ、というわけでもなさそうです。

  • setされるまでは、getのたびに毎回ストレージまで取りにいく
  • いったんsetされるとその時点で変数とストレージとを切り離して、それ以降のgetは自分の値を返す

というような感じでしょうか。
これ知らないとヤバい動きですね。

tana00tana00

元記事のコードをmacOS Appプロジェクトを作って動かしてみた。ボタンを押していると次のような画面が出現する。今は理由が良く解らん。
Preview Canvasで動かすと期待した動きを見せる。

$xcodebuild -version
Xcode 16.0
Build version 16A242d
$sw_vers
ProductName:		macOS
ProductVersion:		15.0
BuildVersion:		24A335
kabeyakabeya

自分のところでは、以下で再現します。

  1. 3番目のボタンを押す
  2. 4番目のボタンを押す
  3. 5番目のボタンを押す

classにしてしまうと(おそらくObservableObjectかどうかに依らず)以下のように動いているように見えます。

  • setされるまでは、getのたびに毎回ストレージまで取りにいく
  • いったんsetされるとその時点で変数とストレージとを切り離して、それ以降のgetは自分の値を返す

3番目のボタン、4番目のボタンは、それぞれ「いったんsetされるとその時点で変数とストレージとを切り離して、それ以降のgetは自分の値を返す」というような動きを引き起こすボタンなので、「Not Attribute」と「Not ObservableObject」が独自に動き始めてしまいます。

つまり5番目のボタンでリセットしているのにそれが反映されませんし、3番目のボタンと4番目のボタンでカウントアップされるのは押したボタンに対応するテキストだけになってしまいます。

これ、改めてSILを追っかけてみましょうか。

kabeyakabeya

また別の現象に見舞われました。

@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にしたらいかん、ということなのかしら。
ちょっと試してみましょうか。

kabeyakabeya
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変数を割り当てて、onChangeonSubmit@AppStorage変数に同期するようにしました。

  • テキストフィールドに手入力したときは、テキストフィールドのタイマー値も変わるようになった
  • プルダウンで変更したときは、テキストフィールドのタイマー値は変わらない
  • どちらも、表示自体は更新される(プルダウンとテキストフィールドの値が一致する)

なんか面倒くさいですね、@AppStorage

kabeyakabeya

結局のところ、@AppStorage変数を更新するのはどこか1つのビュー、ということにしなければならないような気がしますね。
その値を複数のビューで更新するような設計は避けた方が無難そうです。

避けた方が無難そうな設計というのは以下の2つの設計です。

  • 複数のビューで同じ@AppStorage変数を更新する。更新が1箇所ならOK(たぶん)。
  • @AppStorage変数を@Bindingに渡す。渡せるし、またぱっと見、動作もしているように見えるが、@Binding変数と@AppStorage変数との同期が切れるときがある(というか何かの拍子にしか同期しない)。

この例で言うと、TextFieldはやめて単にTextで表示だけし、更新はさせないようにする、というようなことですね。