[SwiftUI] @AppStorage を MVVM で使う時の注意点

SwiftUI

SwiftUI 2.0 で導入された @AppStorage ですが、MVVM と組み合わせようとすると注意が必要です

@AppStorage

SwiftUI 2.0 から導入された property wrapper です。動作としては、値を UserDefaults に自動で保存し、自動で読み出してくれます。

これまでは、自分で、アプリケーションの起動時に UserDefaults から読み出して、終了時に 保存していましたが、その部分を行ってくれるものです。

AppStorageのシンプルな使い方

シンプルなカウンターアプリ

「シンプルなカウンターアプリ」
よくあるカウンターアプリです。

通常の作り方では、カウンターの値は、起動ごとに初期化されてしまいますが、AppStorage を指定しておくことで、記憶してくれるようになります。

SimpleCounterApp code

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/22
//  © 2020  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    // (1)
    @AppStorage("countKey") var count:Int = 0
    var body: some View {
        Text(String(count))
            .font(.largeTitle)
        HStack {
            Button(action: {
                count = count - 1
            }, label: {
                Image(systemName: "minus.circle")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 100, height: 100)
            })
            Button(action: {
                count = count + 1
            }, label: {
                Image(systemName: "plus.circle")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 100, height: 100)
            })
        }
    }
}
コード解説
  1. @AppStorage 指定することで、count の値を UserDefaults に キー値 countKey で保存されます

上記コードは、通常(?)のカウンターアプリの変数宣言に @AppStorage をつけているだけです。

それだけで、値がアプリのライフサイクルを超えて保存されるようになるので、非常に使い勝手が良いです。

しかし、この @AppStorage を MVVM に組み入れて使おうとすると少し手間になり始めます。

MVVM の カウンターアプリ

先ほどのカウンターアプリを、MVVM 実装にしました。

SimpleCounterApp with MVVM

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/20
//  © 2020  SmallDeskSoftware
//

import SwiftUI

struct CountModel {
    var count:Int = 0
}

class CountViewModel:ObservableObject {
    @Published var countModel: CountModel
    
    init(model: CountModel) {
        self.countModel = model
    }
    
    var countAsString : String{
        return String(countModel.count)
    }
    
    func increment() {
        self.countModel.count = self.countModel.count + 1
    }
    
    func decrement() {
        self.countModel.count = self.countModel.count - 1
    }
}

struct ContentView: View {
    @ObservedObject var countViewModel: CountViewModel
    var body: some View {
        VStack {
            Text(countViewModel.countAsString)
                .font(.largeTitle)
            HStack {
                Button(action: {
                    countViewModel.decrement()
                }, label: {
                    Image(systemName: "minus.circle")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 100, height: 100)
                })
                Button(action: {
                    countViewModel.increment()
                }, label: {
                    Image(systemName: "plus.circle")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 100, height: 100)
                })
            }
        }
    }
}

MVVM の M(Model) に組み入れる

Model に組み入れようとすると、CountModel が以下のようなコードになります。

CountModel with AppStorage

// with AppStorage
struct CountModel {
    @AppStorage("countKey") var count:Int = 0
}

コンパイルは通りますが、動かしてみると、数値の変更で画面が更新されないことに気づきます。

理由としては、CountModel のさらに奥の count が変更されているために、CountViewModel がその変更を検知できないからです。

しかし、Model を ObservableObject に準拠させるのは、ViewModel の中に さらに ViewModel を作ることになってしまいます。

Model は、View や ViewModel からは独立しているべきですので、ViewModel で変更を検知して画面更新を依頼する必要があります。

ということで、ViewModel は、以下のようになります。

ViewModel with (AppStorage)Model

class CountViewModel:ObservableObject {
    // (1)
    @Published var countModel: CountModel
    
    init(model: CountModel) {
        self.countModel = model
    }
    
    var countAsString : String{
        return String(countModel.count)
    }
    
    func increment() {
        self.countModel.count = self.countModel.count + 1
        // (2)
        self.objectWillChange.send()
    }
    
    func decrement() {
        self.countModel.count = self.countModel.count - 1
        // (3)
        self.objectWillChange.send()
    }
}
コード解説
  1. CountModel に AppStorage 指定された変数が定義されています
  2. CoutModel 内の AppStorage 指定された変数を変更した場合に、ViewModel から変更を通知する必要があります。
  3. 同様に、ViewModel から変更を通知しています。

MVVM の責務の分担という意味では、ViewModel が変更を検知して通知、Model は、データの保存 と分かれてはいますが、
AppStorage 導入前と見比べると、複雑化してしまっているように見えます。

MVVM の VM に組み入れる

では、AppStorage 対象の変数を直接 ViewModel に入れてしまうのはどうでしょうか?

AppStorage を Model とみなすことになります。実は、このように実装しても、AppStorage 対象の変数変更を検知することはできません。ですので、ViewModel から 変更の通知 を行う必要があります。

ViewModel のコードは以下となります。

ViewModel (AppStorage direct)

class CountViewModel:ObservableObject {
    @AppStorage("countKey") var count:Int = 0
    
    var countAsString : String{
        return String(count)
    }
    
    func increment() {
        count = count + 1
        self.objectWillChange.send()
    }
    
    func decrement() {
        count = count - 1
        self.objectWillChange.send()
    }
}
@Publised?
AppStorage に @Publised をつけることはできないので、Observed 経由で変更を検知することもできません。

いずれにしても、@AppStorage 指定された変数の検知は、自前で行う必要があることがわかります。

まとめ:@AppStorage を MVVM で使う時の注意点

@AppStorage を MVVM で使う時の注意点
  • View が直接保持している @AppStorage は 変更検知できる
  • ViewModel 経由で保持している @AppStorage は、変更検知されないので、自前で通知する

現時点では、AppStorage の対応しているタイプは、Bool, Int, Double, String, URL, Data です。

配列等を保存する時には、Data に変換して保存する必要があります。

そう考えると、MVVM で構築しているアプリでは、@AppStorage を使わずに、直接 UserDefaults を使った方が良いように思えます。

Apple の このドキュメントよく読むと書いてあります。

A property wrapper type that reflects a value from UserDefaults and invalidates a view on a change in value in that user default.

説明は以上です。
不明な点やおかしな点ありましたら、Twitter でご連絡いただけるとありがたいです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です