Open2

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は自分の値を返す

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