UserDefaultsやAppStorageの初期値を正しく理解する

## はじめに
iOSにはUserDefaultsという、キーと値をペアにして永続的なデータとして保存できる便利なインタフェースがあります。
このUserDefaultsには初期値を登録しておける`UserDefaults.standard.register(defaults:)`という便利なメソッドがあるのですが、正しい仕様を理解せずに使っていたら意図しないタイミングで値が変わってしまうという現象に遭遇しました。

### 結論: `register(defaults:)`と`@Appstorage`の初期値は「書き込み」を行わない
はじめに結論を書いてしまうと、この点を私がきちんと理解していませんでした。
"register"だから「登録」と解釈してしまったのがよくありませんでした。

`register(defaults:)`を通したから、もう値はそれぞれのデバイスで持っているはずと思ってコードを書き換えると、一度もそのキーを`set(_:forKey)`していないデバイスでは予期せず新しい初期値が呼ばれるようになってしまいます。

`register(defaults:)`と`@Appstorage`は初期値を「**提供**」するものと覚えておくと間違いが起きないと思います。

ここからは、UserDefaultsの初期値に関する仕様について詳しく見ていきます。

## UserDefaultsのキーに対応する値がないときの動作
まだ登録されていない値を取り出そうとすると、
- 非数値型 → nil
- 数値型 → 0
- Bool型 → false（NSNumberの0）

が返ってきます。

## 初期値を登録する
### `register(defaults:)`の場合
nilや0だと困るという場合、`register(defaults:)`でキーに対する初期値を登録することができます。
```Swift
UserDefaults.standard.register(defaults: [{キー} : {値}])
```

キーに対する値が存在しない場合は初期値、`set(_:forkey:)`で値を書き込んだ後はその値が参照されるようになります。
また、値を削除すると再びデフォルト値が返ってくるようになります。
```Swift
let userDefaults = UserDefaults.standard

userDefaults.register(defaults: ["UserName" : "NoName"])
print(userDefaults.string(forKey: "UserName"))! // NoName

userDefaults.set("山田太郎", forKey: "UserName")
print(userDefaults.string(forKey: "UserName"))! // 山田太郎

userDefaults.removeObject(forKey: "UserName")
print(userDefaults.string(forKey: "UserName"))! // NoName
```

### `@AppStorage`の場合
SwiftUIで使える`@AppStorage`プロパティラッパーでも初期値を用意することができます。
```Swift
@AppStorage({キー}) var {任意の変数名} = {初期値}
```

`@AppStorage`で登録した初期値も先ほどの`register(defaults:)`と同じ動作になります。
以下の例にある「削除」ボタンを押すと、 `fugafugaInt`はnilや0になるのではなく、初期値の1に戻ります。
```Swift
import SwiftUI

struct ContentView: View {
    @AppStorage("isHogehoge") var isHogehoge = true
    @AppStorage("fugafugaInt") var fugafugaInt = 1
    
    var body: some View {
        VStack {
            Toggle("isHogehoge", isOn: $isHogehoge)
                .padding()
                
            HStack {
                Text("fugafuga: \(fugafugaInt)")
                
                Button("+1") {
                    fugafugaInt += 1
                }
                .buttonStyle(BorderedButtonStyle())
                
                Button("削除") {
                    UserDefaults.standard.removeObject(forKey: "fugafugaInt")
                }
                .buttonStyle(BorderedButtonStyle())
            }
        }
        .padding()
    }
}
```
## `set(_:forKey:)`する前に初期値を変更するとどうなるか？
SwiftUI.Appのイニシャライザに`register(defaults:)`を置いて、キー`greeting`に対する初期値を登録します。
そして登録した`greeting`を画面にTextで表示してみます。

```Swift
import SwiftUI

@main
struct UserDefaultsRegisterDemoApp: App {
    init() {
        UserDefaults.standard.register(defaults: ["greeting" : "こんにちは"])
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    private var greeting = UserDefaults.standard.string(forKey: "greeting")!
    
    var body: some View {
        VStack {
            Text(greeting)
                .font(.title)
        }
        .padding()
    }
}
```

この状態で起動すると当然画面に「こんにちは」が表示されます。

![Simulator Screenshot - iPhone 15 Pro - 2024-12-28 at 17.29.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768381/ff304661-20ea-bf0a-0c6f-346cc70d114e.png)

では、このアプリをアンインストールせずに初期値を「さようなら」に書き換えたらどうなるでしょうか？
```Swift
UserDefaults.standard.register(defaults: ["greeting" : "さようなら"])
```

![Simulator Screenshot - iPhone 15 Pro - 2024-12-28 at 17.35.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768381/c68ba8f5-e5f2-c6b9-c96e-e43d15dd510c.png)

「さようなら」に変わりました。
「こんにちは」は無かったことになっています。

この挙動は、`register(defaults:)`が値を登録している場所と、UserDefaultsから値を取得するときの検索方法が関わっています。

## `register(defaults:)`の役割は、The Registration Domainへ初期値を登録すること
何気なく使っているUserDefaultsですが、その中には5つのドメインが存在します。

- The Argument Domain
- The Application Domain (`UserDefaults.standard`)
- The Global Domain
- The Lauguages Domains
- The Registration Domain

値を`string(forKey:)`や`object(forKey:)`などで取得するとき、これらのドメインを上から順番に検索して、上位のドメインに存在する値を優先して返します。

`register(forKey:)`はThe Registration Domainに指定した辞書の内容を登録するメソッドです。`UserDefaults.standard`にあたるThe Application Domainに書き込みは行っていません。

### The Registration Domainは揮発性
公式ドキュメントの`register(defaults:)`のページには以下のように書かれています。
> The contents of the registration domain are not written to disk; you need to call this method each time your application starts.
> （訳：The Registration Domainの内容はディスクに書き込まれないので、アプリケーションが起動するたびにこのメソッドを呼び出す必要がある。）
> ([register(defaults:) \| Apple Developer Documentation](https://developer.apple.com/documentation/foundation/userdefaults/1417065-register))

また、`registrationDomain`にも、
> The domain consisting of a set of temporary defaults whose values can be set by the application to ensure that searches will always be successful.
> （訳：ドメインは、検索が常に成功するように、アプリケーションが設定できる一時的なデフォルト値のセットで構成される。）
> ([registrationDomain \| Apple Developer Documentation](https://developer.apple.com/documentation/foundation/userdefaults/1415953-registrationdomain))

とあるように、初期値はあくまでnilじゃ困るときの一時的な値として扱うべきであり、その値を参照することが通例となることは避けるべきなんでしょう。

たとえばBool値でユーザーの設定を管理するとき、ユーザーが設定画面でその値をいじらない限り`set(_:value:)`が長期間呼ばれないという状況だと、コードをいじったときに意図せずtrueとfalseを反転させてしまうかもしれません。

### 代替処置

`set(_:forKey:)`で書き込むタイミングが長期間ないかもしれない、または初期値をデバイス固有の値として持たせておきたいという場合は、以下のようにするといいかもしれません。

```Swift
if UserDefaults.standard.object(forKey: "hogehoge") == nil {
	UserDefaults.standard.set(true, forKey: "hogehoge")
}
```

`register(defaults:)`と違って辞書で持たせて一気に登録ができませんし、`removeObject(forKey:)`で削除したあとでも初期値が必要な場合は再び`set(_:forKey:)`しなくてはいけないので結構面倒です。
しかし、確実にThe Application Domainに永続的な値として書き込むことができます。


## `@AppStorage`の初期値はまた別の働きをしている
`@AppStorage`の初期値は`register(default:)`と違い、The Resistration Domainへの登録も行っていません。

`@AppStorage`の初期値は、指定したキーでUserDefaultsのドメインをすべて検索して、いずれもnilだったときにnil結合演算子(`??`)を使用してdefaultValueを返しているような挙動になります。

UserDefaultsを使って表すと以下のような感じです。（ジェネリクスが云々とかで実際はもっと複雑だと思いますが、あくまでイメージとして理解してください）

```Swift
let hoge: Int = UserDefaults.standard.object(forKey: "hogehoge") as? Int ?? 1
```

### 同じキーを複数回`@AppStorage`で呼び出すとおかしなことになる
一度もsetされていないキーを以下のように複数の`@AppStorage`を使って実装し、それぞれ別の初期値を与えると、setされるまではそれぞれ変数に指定した初期値を参照します。

```Swift
struct FirstView: View {
    @AppStorage("hoge") private var hoge = 1
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    Text("hoge: \(hoge)")
                    
                    Spacer()
                    
                    Button("+1") {
                        hoge += 1
                    }
                    .buttonStyle(BorderedButtonStyle())
                    
                    Button("削除") {
                        UserDefaults.standard.removeObject(forKey: "hoge")
                    }
                    .buttonStyle(BorderedButtonStyle())
                }
                
                NavigationLink("SecondViewへ移動", destination: SecondView())
                    .buttonStyle(BorderedProminentButtonStyle())
                    .padding()
            }
            .padding()
            .navigationTitle("FirstView")
        }
    }
}

struct SecondView: View {
    @AppStorage("hoge") private var hoge = 334
    
    var body: some View {
        VStack {
            Text("hoge: \(hoge)")
        }
        .navigationTitle("SecondView")
    }
}
```

![画面収録 2024-12-28 15.38.43 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768381/fb2a4271-b50d-01b4-ae6c-bce9dfba5737.gif)
ボタンを押してUserDefaultsに値が書き込まれる前は、FirstViewとSecondViewに置いた`@AppStorage`それぞれの初期値が参照されています。

FirstView内の「+1」ボタンをタップすると、FirstView内の`hoge`の初期値である1に1を加算した`2`がUserDefaultsに書き込まれます。

そうするとUserDefaultsの`hoge`がnilでなくなったことにより、SecondViewの`hoge`もUserDefaultsの値を参照するようになります。

削除ボタンを押してUserDefaultsの値を削除すると、またFirstView内の`hoge`は`1`、SecondView内の`hoge`は`334`に戻ります。

なんなら、変数名を変えれば同じView内で複数個同じキーを持つ`@AppStorage`を作っても同じ動作を確認できます。
（実践でそんなアホなことをする人はいないと思いますが……）


## `register(defaults:)`と`@AppStorage`を併用するとどうなるか？
本題とは少しズレますが、SwiftUIのプロジェクトで`register(defaults:)`と`@AppStorage`を併用するとどうなるのでしょうか。

### パターン1. `register(defaults:)`を先に発火させる
まず、SwiftUI.Appのイニシャライザ内で`register(defaults:)`を使ってThe Registration Domainに初期値を登録します。
```Swift
@main
struct AppStorageDefaultApp: App {
    init() {
        UserDefaults.standard.register(defaults: ["fugafugaInt" : 12345])
    }
    
    var body: some Scene {
        WindowGroup {
            FirstView()
        }
    }
}
```

そしてFirstViewで`@AppStorage`を使用して`fugafugaInt`を読み出し、初期値には先ほどとは違う値を入れておきます。
```Swift
struct FirstView: View {
    @AppStorage("fugafugaInt") var fugafugaInt = 1
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    Text("fugafugaInt: \(fugafugaInt)")
                    
                    Spacer()
                    
                    Button("+1") {
                        fugafugaInt += 1
                    }
                    .buttonStyle(BorderedButtonStyle())
                    
                    Button("削除") {
                        UserDefaults.standard.removeObject(forKey: "fugafugaInt")
                    }
                    .buttonStyle(BorderedButtonStyle())
                }
            }
            .padding()
            .navigationTitle("FirstView")
        }
    }
}
```

この場合は、先の説明の通りThe Registration Domainの値が優先されるため`12345`になります。

![Simulator Screenshot - iPhone 15 Pro - 2024-12-28 at 15.56.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768381/8f09b8d1-8a57-2ecc-8b26-1022b9cf5d1a.png)

`@AppStorage`に書いた初期値の`1`は完全に無視され、「+1」ボタンで値をUserDefaultsへ書き込んだあとに「削除」ボタンで`removeObject(forKey:)`をすると、再びThe Registration Domainに登録されている`12345`が返ってくるようになります。

### パターン2. `register(defaults:)`を後に発火させる
パターン1のサンプルアプリを以下のように改造します。
※全く実践的なコードではありません。あくまで実験のためのものです。
```Swift
@main
struct AppStorageDefaultApp: App {
    // イニシャライザに書いたregister(defaults:)は削除
    
    var body: some Scene {
        WindowGroup {
            FirstView()
        }
    }
}

struct FirstView: View {
    @AppStorage("fugafugaInt") private var fugafugaInt = 1
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    Text("fugafugaInt: \(fugafugaInt)")
                    
                    Spacer()
                    
                    Button("+1") {
                        fugafugaInt += 1
                    }
                    .buttonStyle(BorderedButtonStyle())
                    
                    Button("削除") {
                        UserDefaults.standard.removeObject(forKey: "fugafugaInt")
                    }
                    .buttonStyle(BorderedButtonStyle())
                }
                
                NavigationLink("SecondViewへ移動", destination: SecondView())
                    .buttonStyle(BorderedProminentButtonStyle())
                    .padding()
            }
            .padding()
            .navigationTitle("FirstView")
        }
    }
}

struct SecondView: View {
    @AppStorage("fugafugaInt") private var fugafugaInt = 334
    
    var body: some View {
        VStack {
            Text("fugafugaInt: \(fugafugaInt)")

            NavigationLink("ThirdViewへ移動", destination: ThirdView())
                .buttonStyle(BorderedProminentButtonStyle())
                .padding()
        }
        .navigationTitle("SecondView")
    }
}

struct ThirdView: View {
    private var fugafugaInt = UserDefaults.standard.integer(forKey: "fugafugaInt")
    
    var body: some View {
        Text("fugafugaInt: \(fugafugaInt)")
            .onAppear {
                UserDefaults.standard.register(defaults: ["fugafugaInt" : 56789])
            }
            .navigationTitle("ThirdView")
    }
}
```
SecondViewには初期値を`334`にした`@AppStorage`を置きます。

ThirdViewには`@AppStorage`ではなく`integer(forkey:)`で`fugafugaInt`を取得してViewにText表示させます。
そして、このTextの`.onAppear(perform:)`で`register(defaults:)`を発火し、The Registration Domainに`56789`が登録されるようにします。

これらの結果が以下のGIFです。

![画面収録 2024-12-28 16.11.10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768381/e997f414-cda7-3f4d-ecd5-8c45856d3bd7.gif)

最初にFirstViewとSecondViewが表示されたときは、`@AppStorage`の初期値である`1`と`334`がそれぞれの表示されています。

しかし、ThirdViewに移動すると`onAppear`の`register(defaults:)`によってThe Registration Domainに`56789`が登録されたことにより、FirstViewとSecondViewの表示も`56789`に変わりました。
GIFをよく見ると、「ThirdViewを表示する」ボタンをタップして画面が遷移する最中に、SecondViewの表示が`334`から`56789`に変化しているのがわかります。

このあと削除ボタンを押していますが、`removeObject(forKey:)`ではThe Registration Domainに登録されている`56789`は消えないので、アプリを終了するまで`1`と`334`は使われなくなります。

### `@AppStorage`の初期値は優先度が最も低い
先にも書いた通り、`@AppStorage`に持たせた初期値はUserDefaultsの5つのドメインを検索して、すべてnilだったときにようやく呼ばれます。

そのため、The Application Domainは当然のこと、The Registration Domainに後から値が入った場合でも検索の結果はnilでなくなるため、`@AppStorage`の初期値は呼ばれなくなります。

## おわりに
何百回も同じことを思って後悔するのですが、コードを書いてガンガン進めるだけでなく、ちゃんと公式ドキュメントを読んで理解を深めながら進めていかなくてはいけませんね。

だいぶ極端な例も出しましたが、少しでも皆さんの理解の一助になれば幸いです。

## 参考文献
- [About the User Defaults System | Apple Developer Documentation Archive](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/UserDefaults/AboutPreferenceDomains/AboutPreferenceDomains.html#//apple_ref/doc/uid/10000059i-CH2-SW4)
→CocoaフレームワークのNSUserDefaultsに関するドキュメントなので古い情報ですが、UserDefaultsの理解を深めるのに役立ちました。

- [UserDefaults register(defaults:) の勘違い #iOS - Qiita](https://qiita.com/fuzzball/items/11e7df162a5a172620f0)
- [swift - Understanding the UserDefaults register method - Stack Overflow](https://stackoverflow.com/questions/53683730/understanding-the-userdefaults-register-method)
→この2つの投稿とコメントを読んで解決の糸口をつかめました。

- [register(defaults:) \| Apple Developer Documentation](https://developer.apple.com/documentation/foundation/userdefaults/1417065-register)
- [init(\_:store:) \| Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/appstorage/init(_:store:))