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 throughStateorSceneStorage,
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
ofnilwill 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にも似たような使い方ができるプロパティラッパーがあって最初感動していましたが、結構前からこういうスタイルのプロパティラッパーがあったんですね。
もっと早く知りたかった!
本記事もどなたかの参考になれば幸いです!
一部推測もあり間違いがあるかもなので、ご指摘のコメントも大歓迎です🙏
