2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【SwiftUI】@AppStorage触ってみる

Posted at

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に入力した文字を代入しているだけです。
これだけで、永続化できました!便利!

シミュレーターで動かしてみると以下のような感じになります。

Simulator Screen Recording - iPhone 16 Pro - 2025-08-30 at 11.10.11.gif

@AppStorageの保存領域

冒頭でも少し触れましたが@AppStorageUserDefaultsのラッパーなので、保存領域は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 through State or SceneStorage,
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
    of nil 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のUserDefaultsUserDefaults.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の値が変わらないという事象が起きます。

想像ですが、@AppStorageDynamicPropertyに準拠しているので、Viewの変更を検知してリアルタイムで変更できているのだと思います。
ただViewではないclassなどではViewの変更を検知できないので、そういう場合はリアルタイムでは変更できないのかなと、

いろんな記事でも言われていることですが、MVVMといったプレゼンテーションロジックをViewではおこなわないアーキテクチャには@AppStorageは向かないですね。
逆にいうと、MVといったViewでプレゼンテーションロジックを持つアーキテクチャでは、使い倒せるのでシンプルなコードを書くための非常に便利なツールになると思いました。

おわり

@AppStorage初めましてだったので、良いAPIをしれて嬉しいです!
SwiftDataにも似たような使い方ができるプロパティラッパーがあって最初感動していましたが、結構前からこういうスタイルのプロパティラッパーがあったんですね。
もっと早く知りたかった!
本記事もどなたかの参考になれば幸いです!
一部推測もあり間違いがあるかもなので、ご指摘のコメントも大歓迎です🙏

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?