はじめに
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()
}
}
この状態で起動すると当然画面に「こんにちは」が表示されます。
では、このアプリをアンインストールせずに初期値を「さようなら」に書き換えたらどうなるでしょうか?
UserDefaults.standard.register(defaults: ["greeting" : "さようなら"])
「さようなら」に変わりました。
「こんにちは」は無かったことになっています。
この挙動は、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")
}
}
ボタンを押して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に初期値を登録します。
@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
になります。
@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です。
最初に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
→CocoaフレームワークのNSUserDefaultsに関するドキュメントなので古い情報ですが、UserDefaultsの理解を深めるのに役立ちました。 -
swift - Understanding the UserDefaults register method - Stack Overflow
→この2つの投稿とコメントを読んで解決の糸口をつかめました。