SwiftでiOSといったAppleプラットフォームの開発をするとき、手軽にデータの永続化を実現しようと思ったら、まずUserDefaults
あたりを思い浮かべるかと思います。
SwiftUIでは@AppStorage
というプロパティラッパーにより、よりシンプルにUserDefaults
によるデータ永続化を行うインターフェースが提供されているようだったので、試してみます。
前提
iOS 14.0以降
簡単な使い方
触ってみると実際にシンプルと感じました。
試しに以下のようなコードを実装してみました。
struct ContentView: View {
@AppStorage("test") var test = ""
@State var textFieldValue = ""
var body: some View {
VStack {
TextField("Text", text: $textFieldValue)
.textFieldStyle(.roundedBorder)
.padding()
.onSubmit { // enterキータップで入力文字を保存
test = textFieldValue
textFieldValue = ""
}
Text("Test value: \(test)")
}
.padding()
}
}
TextFieldで入力した文字をSubmitしたら、入力した文字を永続化する例です。
onSubmit
ブロックでやっていることは、@AppStorage
なプロパティであるtest
に入力した文字を代入しているだけです。
これだけで、永続化できました!便利!
シミュレーターで動かしてみると以下のような感じになります。
@AppStorage
の保存領域
冒頭でも少し触れましたが@AppStorage
はUserDefaults
のラッパーなので、保存領域はUserDefaults
と同じです。
もちろん保存に使用するUserDefaults
のインスタンスを指定するインターフェースも用意されています。
自分が調べた限り、二つ指定方法があります。
@AppStorage
のイニシャライザで指定
@AppStorage
のイニシャライザの第二引数にstore
というのがあるので、そこに使用するUserDefaults
のインスタンスを指定できます。
@AppStorage("test", store: UserDefaults(suiteName: "test")!) var test = ""
ただプロパティラッパーは基本動的に指定できないと思うので、正直この方法の使い所は思い当たりません。
environmentのUserDefaults
を指定する
@AppStorage
は先ほど紹介してようにイニシャライザでUserDefaults
を指定しない場合は、environmentのUserDefaults
インスタンスを使用するようになっています。
この仕様は、@AppStorage
のイニシャライザのドキュメントコメントに記載されていました。
Creates a property that can save and restore table column state.
Table column state is typically not bound from a table directly to
AppStorage
, but instead indirecting throughState
orSceneStorage
,
and using the app storage value as its initial value kept up to date
on changes to the direct backing.
- Parameters:
- wrappedValue: The default value if table column state is not
available for the given key.- key: The key to read and write the value to in the user defaults
store.- store: The user defaults store to read and write to. A value
ofnil
will use the user default store from the environment.
public init(wrappedValue: Value = TableColumnCustomization(), _ key: String, store: UserDefaults? = nil) where Value == TableColumnCustomization, RowValue : Identifiable
store
引数の説明の日本語訳が以下の通りです。
値を読み書きするUserDefaultsストア。
nil
を指定した場合、environmentから取得したUserDefaultsストアが使用されます。
以下のようにUserDefaults
のenvironmentを指定できます。
ContentView()
.defaultAppStorage(UserDefaults(suiteName: "test")!)
ちなみにdefaultAppStorage
でカスタムUserDefaults
を指定しない場合はデフォルトではenvironmentのUserDefaults
はUserDefaults.standard
になります。
`defaultAppStorage`のドキュメントコメントより
/// The default store used by `AppStorage` contained within the view.
///
/// If unspecified, the default store for a view hierarchy is
/// `UserDefaults.standard`, but can be set a to a custom one. For example,
/// sharing defaults between an app and an extension can override the
/// default store to one created with `UserDefaults.init(suiteName:_)`.
///
/// - Parameter store: The user defaults to use as the default
/// store for `AppStorage`.
nonisolated public func defaultAppStorage(_ store: UserDefaults) -> some View
拡張する
@AppStorage
をそのまま使うと、個人的に以下のような問題が起きると思っています。
-
key
を文字リテラルを使って指定すると、タイポの耐性がない
// 画面Aで"test"キーのデータを参照
@AppStorage("test") var test = ""
// 画面Bでも"test"キーのデータを参照したいけどタイポしてたら、期待するデータが取れずバグる
@AppStorage("tezt") var test = ""
-
key
がOutputの型に紐づいていないという意味でタイプセーフではない
// 画面Aで"test"キーのデータを文字列として参照
@AppStorage("test") var test = ""
// 画面Bで"test"キーのデータを数字として参照しているが、期待するデータが取れずバグる
@AppStorage("test") var test = 0
これら問題は、@AppStorage
を以下のように拡張すれば解決しそうと思いました。
enum AppStorageStringValueKey: String {
case test
}
extension AppStorage where Value == String {
init(wrappedValue: String, key: AppStorageStringValueKey) {
self.init(wrappedValue: wrappedValue, key.rawValue)
}
}
上記のように型に紐づいたKeyを指定できるイニシャライザを作ってあげたら、Keyを文字列で指定する必要もないし、型を確定させることができるので、コンパイル時に懸念していた問題が起きていないことを保証することができます。
// コンパイルが通る
@AppStorage(key: .test) var test = ""
// コンパイルエラー(Cannot convert value of type 'Int' to expected argument type 'String')
@AppStorage(key: .test) var test = 0
Int型保存用の拡張を追加したいときは同じ要領で、以下のような実装を追加すればOKですね。
enum AppStorageIntValueKey: String {
case int_test
}
extension AppStorage where Value == Int {
init(wrappedValue: Int, key: AppStorageIntValueKey) {
self.init(wrappedValue: wrappedValue, key.rawValue)
}
}
@AppStorage(key: .int_test) var intTest = 0
@AppStorage
の制約
こちらの記事にも紹介されている通り、View以外で使用すると、リアルタイムで@AppStorage
の値が変わらないという事象が起きます。
想像ですが、@AppStorage
はDynamicProperty
に準拠しているので、Viewの変更を検知してリアルタイムで変更できているのだと思います。
ただViewではないclassなどではViewの変更を検知できないので、そういう場合はリアルタイムでは変更できないのかなと、
いろんな記事でも言われていることですが、MVVMといったプレゼンテーションロジックをViewではおこなわないアーキテクチャには@AppStorage
は向かないですね。
逆にいうと、MVといったViewでプレゼンテーションロジックを持つアーキテクチャでは、使い倒せるのでシンプルなコードを書くための非常に便利なツールになると思いました。
おわり
@AppStorage
初めましてだったので、良いAPIをしれて嬉しいです!
SwiftDataにも似たような使い方ができるプロパティラッパーがあって最初感動していましたが、結構前からこういうスタイルのプロパティラッパーがあったんですね。
もっと早く知りたかった!
本記事もどなたかの参考になれば幸いです!
一部推測もあり間違いがあるかもなので、ご指摘のコメントも大歓迎です🙏