1. 記事一覧 >
  2. ブログ記事
category logo

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 の違いは、ざっくりと以下の違いがあります。

データ領域特徴/用途
UserDefaultskey-value 形式 DB
通常は、1 アプリ 1DB の永続データだが、アプリ間でデータ共有もできる
軽量データ向け
FileManagerデータではなく実体ファイル保存
ファイルを保存したい時向け
Core Dataリレーショナル DB
データ同士を紐づけて抽出とかできる
大容量データ向け
※今回、説明しない


Widget と App 側でデータ共有 図


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 側(アプリ本体側)のエントリーポイント


App/ContentView.swift
各 Example Widget に関係する View で区切られています。
今回関係するのは、appGroupWidgetSection 部分です。
appGroupWidgetSection の実装は、
extension ContentView {
private var appGroupWidgetSection: some View {
の部分です。

App/ContentView.swift
    var body: some View {
        List {
            appGroupWidgetSection//今回関係する部分
            coreDataWidgetSection
            deepLinkWidgetSection
            dynamicIntentWidgetSection
            previewWidgetSection
        }
        .listStyle(InsetGroupedListStyle())
        // InsetGroupedListStyle()は、
        // はめ込み+グループ化リストの表示スタイル。
        // リストの周りにマージンが適用される。
        // iOS14以降で使用可能。
        // .listStyle(.insetGrouped)でも同じ意味。
    }

appGroupWidgetSectionの実装
extension ContentView {
    private var appGroupWidgetSection: some View {
・・・
}

今回の記事では、
extension ContentView {
private var coreDataWidgetSection: some View {
等の他の View は関係有りません。
すなわち、以下のようにしてもOKです。


App/ContentView.swift
    var body: some View {
        List {
            appGroupWidgetSection//今回関係する部分
            //coreDataWidgetSection
            //deepLinkWidgetSection
            //dynamicIntentWidgetSection
            //previewWidgetSection
        }
        .listStyle(InsetGroupedListStyle())
    }

ContentView.swift


● 複数のウィジェット
ウィジェットは、1アプリに対して、複数のウィジェットが提供されています。


こういうときは、WidgetBundle を使います。

WidgetExtension/WidgetBundle.swift
@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 を拡張して、固定値を設定しています。

Shared/FileManager+Ext.swift
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 を拡張して、固定値を設定しています。

Shared/UserDefaults+Ext.swift
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 はアプリケーションごとに異なります)。 このため、異なるアプリ/拡張機能間で共有できるストアを作成できます。

参考:https://stackoverflow.com/questions/47328023/what-is-real-meaning-of-suitename-in-userdefaults-initializer

func setArrayfunc getArray も定義されていますが、今回、関係無いため、触れません。


Shared/Widget+Ext.swift
Shared/Widget+Ext.swift は、enum WidgetKind でウィジェットの名前を定義しています。

Shared/Widget+Ext.swift
import Foundation

enum WidgetKind {
    static var appGroup: String { "AppGroupWidget" }
    static var clock: String { "ClockWidget" }
・・・

ここに enum WidgetKind 以外の実装はありません。


UserDefaults で共有

まずは、UserDefaults での共有部分を紐解きます。


App Groups

先ほど、Shared/UserDefaults+Ext.swiftShared/FileManager+Ext.swift とで、"group.com.pawelwiszenko.WidgetExamples" という部分が出てきました。
これは、UserDefaults のアプリ固有の場所を明示するもので、あらかじめ、設定する必要があります。

App Groups設定 アプリ側


App Groups設定 ウィジェット側


今回の場合、当然、追加済みですが、自分で開発するときは、Signing & Capabilities ボタンを押して、App Groups 追加から実施する必要があります。
アプリ側、ウィジェット側で同じ名前で登録が必要です。

App Groups設定手順1


App Groups設定手順2


App 側

まず、アプリ側 App/ContentView.swiftstruct ContentView: View { の上の方に

App/ContentView.swift
@AppStorage(Key.luckyNumber.rawValue, store: .appGroup) private var luckyNumber = 0

という部分が出てきます。


ContentView.swift @AppStorage


@AppStorage とは、UserDefaults を @State のように扱えるカスタム属性です。
UserDefaults の値の変化に連動して、View に値の内容を反映できるプロパティラッパー(カスタム属性)です。

【 プロパティラッパー(カスタム属性) 】

プログラムの要素に何らかの特殊な性質を付与する機能です。

【 @State 】

データバインディングの仕組みの一つです。

データバインディングとは、データ(変数)の値と何かを紐づけるということです。

@State private var luckyNumber = 0 の場合、"何か" が luckyNumber を表示している View になり、luckyNumber が更新されると、View が再描画されます。


Key.luckyNumber.rawValue は、Shared/UserDefaults+Ext.swift に書かれています。

Shared/UserDefaults+Ext.swift
extension UserDefaults {
    enum Keys: String {
        case luckyNumber
        case contacts
    }
}

結果、

App/ContentView.swift
    @AppStorage("luckyNumber", store: .appGroup) private var luckyNumber = 0

と同じ意味です。"luckyNumber" は、UserDefaults の key です。


store: .appGroup は、

App/ContentView.swift
@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 に反映されるよ。一粒で二度おいしいよ。」
と言っています。

UserDefaults 共有 まとめ図


その後、ボタンのタップのところで、
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 を取り出して、ウィジェットに表示する部分は、

WidgetExtension/AppGroupWidget/AppGroupWidget.swift
    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)
    }

AppGroupWidget.swift UserDefaults関係部分

だけが関係します。


UserDefaults.appGroup の integer メソッド
func integer(forKey defaultName: String) -> Int
で値を取り出して、表示しています。
UserDefaults.appGroup は、Shared/UserDefaults+Ext.swiftclass UserDefaults : NSObject が拡張されていて、static let appGroup が追加されています。

Shared/UserDefaults+Ext.swift
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 リソース)への書き込みを行っています。

App/ContentView.swift
        //.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: により、共通で使う場所を指定しています。

FileManager+Ext.swift
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 に定義されています。

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 を取り出して、ウィジェットに表示する部分は、

WidgetExtension/AppGroupWidget/AppGroupWidget.swift
    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
    }

AppGroupWidget.swift FileManager関係部分

だけが関係します。


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 の値を取り出して表示


となっています。


loading...