3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-12-28

はじめに

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:)でキーに対する初期値を登録することができます。

UserDefaults.standard.register(defaults: [{キー} : {}])

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

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プロパティラッパーでも初期値を用意することができます。

@AppStorage({キー}) var {任意の変数名} = {初期値}

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

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で表示してみます。

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

では、このアプリをアンインストールせずに初期値を「さようなら」に書き換えたらどうなるでしょうか?

UserDefaults.standard.register(defaults: ["greeting" : "さようなら"])

Simulator Screenshot - iPhone 15 Pro - 2024-12-28 at 17.35.16.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)

また、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)

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

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

代替処置

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

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を使って表すと以下のような感じです。(ジェネリクスが云々とかで実際はもっと複雑だと思いますが、あくまでイメージとして理解してください)

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

同じキーを複数回@AppStorageで呼び出すとおかしなことになる

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

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
ボタンを押してUserDefaultsに値が書き込まれる前は、FirstViewとSecondViewに置いた@AppStorageそれぞれの初期値が参照されています。

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

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

削除ボタンを押してUserDefaultsの値を削除すると、またFirstView内のhoge1、SecondView内のhoge334に戻ります。

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

register(defaults:)@AppStorageを併用するとどうなるか?

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

パターン1. register(defaults:)を先に発火させる

まず、SwiftUI.Appのイニシャライザ内でregister(defaults:)を使ってThe Registration Domainに初期値を登録します。

@main
struct AppStorageDefaultApp: App {
    init() {
        UserDefaults.standard.register(defaults: ["fugafugaInt" : 12345])
    }
    
    var body: some Scene {
        WindowGroup {
            FirstView()
        }
    }
}

そしてFirstViewで@AppStorageを使用してfugafugaIntを読み出し、初期値には先ほどとは違う値を入れておきます。

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

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

パターン2. register(defaults:)を後に発火させる

パターン1のサンプルアプリを以下のように改造します。
※全く実践的なコードではありません。あくまで実験のためのものです。

@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

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

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

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

@AppStorageの初期値は優先度が最も低い

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

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

おわりに

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

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

参考文献

3
4
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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?