- 記事一覧 >
- ブログ記事
SwiftUI WidgetとApp側でデータ共有 UserDefaultsとFileManager2パターン
はじめに
SwiftUI について、Widget と App 側でデータ共有するときに参考になるソースコードを見つけました。
(https://github.com/pawello2222/WidgetExamples
)
アプリ間データ共有で、代表的なのは、UserDefaults or FileManager or Core Data と思います。
今回、UserDefaults と FileManager による ウィジェット - アプリ間データ共有について、
pawello2222/WidgetExamples
を紐解いた結果で解説していこうと思います。
UserDefaults or FileManager or Core Data の違いは、ざっくりと以下の違いがあります。
データ領域 | 特徴/用途 |
---|---|
UserDefaults | key-value 形式 DB 通常は、1 アプリ 1DB の永続データだが、アプリ間でデータ共有もできる 軽量データ向け |
FileManager | データではなく実体ファイル保存 ファイルを保存したい時向け |
Core Data | リレーショナル DB データ同士を紐づけて抽出とかできる 大容量データ向け ※今回、説明しない |
pawello2222/WidgetExamples は、SwiftUI のソースコードで、ウィジェット実装のサンプル集です。
2022/09 時点で、12 個の Example Widget が含まれています。
この記事で扱うのは、AppGroup Widget 1個だけです。
(AppGroup Widget に UserDefaults と FileManager 2パターン両方実装されています。)
Core Data で共有するパターンも含まれていますが、
今回は、UserDefaults と FileManager 2パターンに絞った説明になります。
この記事は、2022/09 時点の pawello2222/WidgetExamples を元に書いています。
本記事は、勝手に解説しているだけです。ソースコードの作者とは何ら関係有りません。
記事中に出てくるソースコードは、省略したり、コメントを追加しています。
【検証環境】
macOS Monterey 12.5.1
Xcode 13.4.1 (SwiftUI)
動作内容
動作内容はいたってシンプルです。Generate new lucky number
をタップすると、Lucky number:
の数字が、1 ~ 99 までランダムに変化して、その数字がウィジェットに反映されているというものです。
ウィジェットのFrom UserDefaults:
部分が UserDefaults から取得した値で、From File:
部分が FileManager から取得した値です。
以下の動画は、ソースコードを書き換えて、Lucky number の機能だけに絞り込んだ状態です。
概要
ソースコードはこのような構成になっています。
App/* : アプリ側の実装
WidgetExtention/* : 各 Widget の実装
IntentHandler/* : 構成可能な Widget(ユーザーがコンテンツを構成できる Widget。例:郵便番号を設定してコンテンツが変えられる天気ウィジェット。)で必要な実装
Shared/CoreData/* : Core Data 用の実装
Shared/Models/Contact.swift : Contact データ(友人の名前、誕生日データ)に関係する実装
Shared/FileManager+Ext.swift : 既存 class FileManaer : NSObject
を拡張している
Shared/UserDefaults+Ext.swift : 既存 class UserDefaults : NSObject
を拡張している
Shared/Widget+Kind.swift : WidgetKind という enum が定義されている
Frameworks/* : 外部フレームワーク
これらの内、今回関係するのは、
App/App.swift
App/ContentView.swift
WidgetExtension/WidgetBundle.swift
WidgetExtension/AppGroupWidget.swift
Shared/FileManager+Ext.swift
Shared/UserDefaults+Ext.swift
Shared/Widget+Ext.swift
だけです。
全体像
データ共有の話の前に、全体的にもう少し掘り下げておきます。
●Widget について
UserDefaults or FileManager で App 側とデータ共有しているのは、
AppGroup Widget 1個だけです。
Widget の実装は、WidgetExtension/AppGroupWidget.swift
にあります。
●App/App.swift
App 側(アプリ本体側)のエントリーポイントです。
当たり前ですが、これが無いと、アプリが起動できません。
●App/ContentView.swift
各 Example Widget に関係する View で区切られています。
今回関係するのは、appGroupWidgetSection 部分です。
appGroupWidgetSection の実装は、extension ContentView {
private var appGroupWidgetSection: some View {
の部分です。
var body: some View {
List {
appGroupWidgetSection//今回関係する部分
coreDataWidgetSection
deepLinkWidgetSection
dynamicIntentWidgetSection
previewWidgetSection
}
.listStyle(InsetGroupedListStyle())
// InsetGroupedListStyle()は、
// はめ込み+グループ化リストの表示スタイル。
// リストの周りにマージンが適用される。
// iOS14以降で使用可能。
// .listStyle(.insetGrouped)でも同じ意味。
}
extension ContentView {
private var appGroupWidgetSection: some View {
・・・
}
今回の記事では、extension ContentView {
private var coreDataWidgetSection: some View {
等の他の View は関係有りません。
すなわち、以下のようにしてもOKです。
var body: some View {
List {
appGroupWidgetSection//今回関係する部分
//coreDataWidgetSection
//deepLinkWidgetSection
//dynamicIntentWidgetSection
//previewWidgetSection
}
.listStyle(InsetGroupedListStyle())
}
● 複数のウィジェット
ウィジェットは、1アプリに対して、複数のウィジェットが提供されています。
こういうときは、WidgetBundle を使います。
@main
struct WidgetExamplesWidgetBundle: WidgetBundle {
//WidgetBundleを使うと、異なる種類のWidgetを追加できます。
//@mainをつける
//WidgetBundleを継承する
//bodyの中で複数のWidgetを並べる
var body: some Widget {
WidgetBundle1().body//1,2,3になぜ別れている?
WidgetBundle2().body
WidgetBundle3().body
}
}
struct WidgetBundle1: WidgetBundle {
var body: some Widget {
AppGroupWidget()
ClockWidget()
CoreDataWidget()
CountdownWidget()
DeepLinkWidget()
}
}
struct WidgetBundle2: WidgetBundle {
・・・
●Shared/FileManager+Ext.swift
Shared/FileManager+Ext.swift は、class FileManager : NSObject
を拡張して、固定値を設定しています。
extension FileManager {
// FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:...
// アプリ間で共有するディレクトリのようなもの
static let appGroupContainerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.pawelwiszenko.WidgetExamples")!
}
extension FileManager {
static let luckyNumberFilename = "LuckyNumber.txt"
}
●Shared/UserDefaults+Ext.swift
Shared/UserDefaults+Ext.swift は、class UserDefaults : NSObject
を拡張して、固定値を設定しています。
typealias Key = UserDefaults.Keys
extension UserDefaults {
static let appGroup = UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")!
//App Groupsの名前が書かれている。
//suiteName: は、UserDefaultsの中の住所のようなもの。
//appGroupと出てきたら、これのこと。これが共通だと、
//アプリが違っていても共通の場所のデータを読み書きできる。
}
extension UserDefaults {
enum Keys: String {
case luckyNumber
case contacts
}
}
・・・
【 suiteName: の意味 】
suiteName: は、設定 (UserDefaults) ストアの作成に役立つ一種の識別子です。 特定の suiteName を使用することで、特定のアプリケーションに限定されない環境設定ストアを作成します (標準の UserDefaults はアプリケーションごとに異なります)。 このため、異なるアプリ/拡張機能間で共有できるストアを作成できます。
func setArray
とfunc getArray
も定義されていますが、今回、関係無いため、触れません。
●Shared/Widget+Ext.swift
Shared/Widget+Ext.swift は、enum WidgetKind
でウィジェットの名前を定義しています。
import Foundation
enum WidgetKind {
static var appGroup: String { "AppGroupWidget" }
static var clock: String { "ClockWidget" }
・・・
ここに enum WidgetKind
以外の実装はありません。
UserDefaults で共有
まずは、UserDefaults での共有部分を紐解きます。
App Groups
先ほど、Shared/UserDefaults+Ext.swift と Shared/FileManager+Ext.swift とで、"group.com.pawelwiszenko.WidgetExamples"
という部分が出てきました。
これは、UserDefaults のアプリ固有の場所を明示するもので、あらかじめ、設定する必要があります。
今回の場合、当然、追加済みですが、自分で開発するときは、Signing & Capabilities
で +
ボタンを押して、App Groups
追加から実施する必要があります。
アプリ側、ウィジェット側で同じ名前で登録が必要です。
App 側
まず、アプリ側 App/ContentView.swift の struct ContentView: View {
の上の方に
@AppStorage(Key.luckyNumber.rawValue, store: .appGroup) private var luckyNumber = 0
という部分が出てきます。
@AppStorage とは、UserDefaults を @State のように扱えるカスタム属性です。
UserDefaults の値の変化に連動して、View に値の内容を反映できるプロパティラッパー(カスタム属性)です。
【 プロパティラッパー(カスタム属性) 】
プログラムの要素に何らかの特殊な性質を付与する機能です。
【 @State 】
データバインディングの仕組みの一つです。
データバインディングとは、データ(変数)の値と何かを紐づけるということです。
@State private var luckyNumber = 0
の場合、"何か" が luckyNumber を表示している View になり、luckyNumber が更新されると、View が再描画されます。
Key.luckyNumber.rawValue
は、Shared/UserDefaults+Ext.swift に書かれています。
extension UserDefaults {
enum Keys: String {
case luckyNumber
case contacts
}
}
結果、
@AppStorage("luckyNumber", store: .appGroup) private var luckyNumber = 0
と同じ意味です。"luckyNumber" は、UserDefaults の key です。
store: .appGroup
は、
@AppStorage("luckyNumber", store: UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")!) private var luckyNumber = 0
と同じ意味です。
store:
を省略すると、デフォルトの位置です。
private var luckyNumber = 0
は、UserDefaults の初期値です。
まとめると、
「UserDefaults の "group.com.pawelwiszenko.WidgetExamples" にある "luckyNumber" キーの値を変数 luckyNumber に連動させるよ。かつ、変数 luckyNumber に変更があったら、すぐに View に反映されるよ。一粒で二度おいしいよ。」
と言っています。
その後、ボタンのタップのところで、luckyNumber = Int.random(in: 1 ... 99)
と値を更新しています。
Button("Generate new lucky number") {
luckyNumber = Int.random(in: 1 ... 99)
WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.appGroup)
}
Int.random(in: 1 ... 99)
は、1 ~ 99 までのランダムな Int 型の値を生成しています。
これだけで、UserDefaults の更新、View の更新が行われます。
ただ、View は更新されてもウィジェットは更新されませんので、WidgetCenter.shared.reloadTimelines(ofKind: WidgetKind.appGroup)
でウィジェットを更新しています。
Widget 側
Widget の実装の詳細は省略しますが、
UserDefaults から luckyNumber を取り出して、ウィジェットに表示する部分は、
var body: some View {
VStack {
Text("Lucky number")
.font(.callout)// フォントの大きさを変更。吹き出しフォント。デフォルトは、.body
Text("From UserDefaults: \(luckyNumberFromUserDefaults)")// UserDefaultsから取り出したluckyNumber
Text("From File: \(luckyNumberFromFile)") // FileManagerから取り出したluckyNumber
}
.font(.footnote)// フォントの大きさを変更。脚注用サイズ。
}
var luckyNumberFromUserDefaults: Int {
UserDefaults.appGroup.integer(forKey: UserDefaults.Keys.luckyNumber.rawValue)
}
だけが関係します。
UserDefaults.appGroup の integer メソッドfunc integer(forKey defaultName: String) -> Int
で値を取り出して、表示しています。UserDefaults.appGroup
は、Shared/UserDefaults+Ext.swift で class UserDefaults : NSObject
が拡張されていて、static let appGroup
が追加されています。
extension UserDefaults {
static let appGroup = UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")!
}
結果、
UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")!.integer(forKey: UserDefaults.Keys.luckyNumber.rawValue)
と同じ意味になります。
forKey:
は、UserDefaults のキーを指定するので、UserDefaults.Keys.luckyNumber.rawValue
すなわち文字列の "luckyNumber"
を指定しています。
結局、UserDefaults.appGroup.integer(forKey: UserDefaults.Keys.luckyNumber.rawValue)
は、
UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")!.integer(forKey: "luckyNumber")
と同じ意味になります。
ちなみに、View と紐づける必要が無い場合、@AppStorage は使わずに、
let userDefault = UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")
userDefault?.set( luckyNumber, forKey: "luckyNumber" )
のようにuserDefault?.set
で直接更新して問題有りません。
まとめ
まとめると、以下のような関係です。
App Groups に App 側、Widget 側、両方に、"group.com.pawelwiszenko.WidgetExamples" を登録する。
↓
App 側で、@AppStorage("luckyNumber", store: UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")!) private var luckyNumber = 0
と UserDefaults、View に連動する luckyNumber 変数を作成する。
↓
App 側で、luckyNumber = Int.random(in: 1 ... 99)
により、UserDefaults、View に値を反映。
↓
Widget 側で、
From UserDefaults: UserDefaults(suiteName: "group.com.pawelwiszenko.WidgetExamples")!.integer(forKey: "luckyNumber")
として、UserDefaults の luckyNumber の値を取り出して表示
となっています。
FileManager で共有
FileManager での共有部分を紐解きます。
App Groups
App Groups の登録は必要ありません。
App 側
App/ContentView.swift の Section の onChange モディファイアで、luckyNumber に変化があったら、Widget と共通の場所にあるファイル(URL リソース)への書き込みを行っています。
//.onChange(of: 値, perform: クロージャー)
// of: 監視対象の値。この値が変更されると、performで指定されたクロージャーが実行されます。
// perform: 監視対象の値が変更された時に実行されるクロージャー。
// クロージャーはメインスレッドで実行されるので、長時間実行されるタスクは、バックグラウンドのキューに送る必要があります。
// クロージャーでは引数として監視対象の変更後の値を受け取ります。
// onChangeモディファイアを記述する場所は、値のスコープ範囲内であればどこでも良いです。
// .onChange(of: 値) { [変更前プロパティ名] 変更後プロパティ名 in
// 監視対象が同一のonChangeを複数記述した場合は、どちらも実行される。
.onChange(of: luckyNumber) { _ in
let url = FileManager.appGroupContainerURL.appendingPathComponent(FileManager.luckyNumberFilename)
// FileManager.appGroupContainerURLは、FileManager+Ext.swiftで定義されているアプリ間で共有するディレクトリのようなもの
// urlは、ファイルへのフルパスようなもの。luckyNumberFilenameは、FileManager+Ext.swiftで定義されている。
//【FileManager+Ext.swift】
// extension FileManager {
// static let luckyNumberFilename = "LuckyNumber.txt"
// }
try? String(luckyNumber).write(to: url, atomically: false, encoding: .utf8)
// try? はエラーを無視。
// String(...)イニシャライザで、String型にキャスト
// .write String構造体のwriteメソッド
// to: 保存先のパス。URL構造体のまま渡す。
// automatically: trueを渡すと補助ファイルを使用して書き込み。補助ファイルに書き込み→書き込み完了→toのファイルにリネーム
// encoding: 文字エンコーディング
}
let url = FileManager.appGroupContainerURL.appendingPathComponent(FileManager.luckyNumberFilename)
を紐解きます。
まず、url
は、struct URL
です。struct URL
は、リモートサーバー上のアイテムやローカルファイルへのパスなど、リソースの場所を識別する値です。
FileManager.appGroupContainerURL
は、FileManager+Ext.swift で FileManager を拡張してプロパティが定義されていて、FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:
により、共通で使う場所を指定しています。
extension FileManager {
// FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:...
// アプリ間で共有するディレクトリのようなもの
static let appGroupContainerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.pawelwiszenko.WidgetExamples")!
}
let url = ...
のところは、
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.pawelwiszenko.WidgetExamples")!.appendingPathComponent(FileManager.luckyNumberFilename)
と同じ意味になります。(1行が横に長くなりますが。)
.appendingPathComponent(FileManager.luckyNumberFilename)
部分は、
アプリ間で共通で使う場所(URL)に "LuckyNumber.txt" を繋げています。
"LuckyNumber.txt" は、FileManager+Ext.swift に定義されています。
extension FileManager {
static let luckyNumberFilename = "LuckyNumber.txt"
}
結果、
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.pawelwiszenko.WidgetExamples")!.appendingPathComponent("LuckyNumber.txt")
と同じ意味になります。
ここで、出来上がる url
は、/アプリ間で共通で使うディレクトリ/ファイル名
のようなフルパスのイメージです。
次に、try? String(luckyNumber).write(to: url, atomically: false, encoding: .utf8)
を紐解きます。
try?
は、右側の処理のエラーを無視するという意味です。
String(luckyNumber)
String(...)イニシャライザで、String 型にキャストしています。.write
は、String 構造体の write メソッドで、オプションに指定した通り、ファイルを書き込みます。to:
保存先の場所で、URL 構造体のまま渡します。automatically:
true を渡すと補助ファイルを使用して書き込みます。補助ファイルに書き込み → 書き込み完了 →to のファイルにリネーム という具合になります。false の場合は、直接書き込みます。encoding:
書き込む内容の文字コードを指定します。
.onChange(of: luckyNumber)
の部分ですが、.onChange(of:
には監視対象を指定します。
luckyNumber に関して、今回の記事では、@AppStorage
により、監視対象になっていますが、UserDefaults が関係無い場合、@State
を使って監視対象にしても問題ありません。
FileManager で共有だけの場合、
@State private var luckyNumber = 0
extension ContentView {
private var appGroupWidgetSection: some View {
Section(header: Text("AppGroup Widget")) {
Text("Lucky number: \(luckyNumber)")
Button("Generate new lucky number") {
luckyNumber = Int.random(in: 1 ... 99)
・・・
.onChange(of: luckyNumber) { _ in
で良いということです。
Widget 側
Widget の実装の詳細は省略しますが、
FileManager で luckyNumber を取り出して、ウィジェットに表示する部分は、
var body: some View {
VStack {
Text("Lucky number")
.font(.callout)
Text("From UserDefaults: \(luckyNumberFromUserDefaults)")
Text("From File: \(luckyNumberFromFile)")
}
.font(.footnote)
}
・・・
var luckyNumberFromFile: Int {
let url = FileManager.appGroupContainerURL.appendingPathComponent(FileManager.luckyNumberFilename)
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return 0 }
return Int(text) ?? 0
}
だけが関係します。
let url = FileManager.appGroupContainerURL.appendingPathComponent(FileManager.luckyNumberFilename)
は、App 側の説明と同じで、url
は、/アプリ間で共通で使うディレクトリ/ファイル名
のようなイメージです。
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return 0 }
を紐解きます。
guard let ... else { return 0 }
は、読み込みに失敗した場合、0 を返しています。(ウィジェットに 0 が表示されます。)
text = try? String(contentsOf: url, encoding: .utf8)
は、String 構造体の機能を使って、contentsOf:
リソースの場所(URL)、encoding:
読み込むときの文字エンコード を指定し、ファイルを読み込んで、text
に格納しています。
return Int(text) ?? 0
は、text の値を Int にキャストして、nil(キャスト失敗)の場合、0 を返しています。
【
??
】Nil結合演算子(Nil-Coalescing Operator)です。
Int(text)
に値が有る場合、アンラップします。
Int(text)
がnil
の場合、??
の右側のデフォルト値 0 を返します。
まとめ
まとめると、以下のような関係です。
(App Groups への登録は無し。)
↓
App 側で、@State private var luckyNumber = 0
と View に連動する luckyNumber 変数を作成する。
↓
App 側で、
luckyNumber = Int.random(in: 1 ... 99)
により、View に値を反映。
↓
App 側で、let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.pawelwiszenko.WidgetExamples")!.appendingPathComponent("LuckyNumber.txt")
try? String(luckyNumber).write(to: url, atomically: false, encoding: .utf8)
により、ファイルに値を反映。
↓
Widget 側で、let url = FileManager.appGroupContainerURL.appendingPathComponent(FileManager.luckyNumberFilename)
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return 0 }
として、ファイルの luckyNumber の値を取り出して表示
となっています。
その他、宣伝、誹謗中傷等、当方が不適切と判断した書き込みは、理由の如何を問わず、投稿者に断りなく削除します。
書き込み内容について、一切の責任を負いません。
このコメント機能は、予告無く廃止する可能性があります。ご了承ください。
コメントの削除をご依頼の場合はTwitterのDM等でご連絡ください。