🕊️

SwiftDataをiCloudとApp Groupsに対応して利用する

2023/12/20に公開

SwiftDataはiOS 17以降で使えるフレームワークです。SwiftUIと相性よく作られている、CoreData的な存在です。

最近個人アプリで利用しましたが、

  • iCloudで複数デバイス間でデータを同期
  • App Groupsでウィジェット側でもデータを取得

という構成にしました。このあたりを設定するのに良いドキュメントが見つからなかったので、このZennで手順を紹介します。

(PR)作った個人アプリはこちらです!
https://apps.apple.com/jp/app/ドット絵日記-1日1枚ドット絵で記録するダイアリー/id6471673369

速習 SwiftData

SwiftDataについて簡単に紹介します。

クラスに @Model をつけます。

import Foundation
import SwiftData

@Model
final class Diary {
    var title: String
    var content: String
    var createdAt: Date
    
    init(title: String, content: String, createdAt: Date) {
        self.title = title
        self.content = content
        self.createdAt = createdAt
    }
}

コンテナを初期化し、 modelContainer に渡します。

import SwiftUI
import SwiftData

@main
struct SampleApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Diary.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

Viewから以下のように利用します。

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    
    // クエリ
    @Query private var diaries: [Diary]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(diaries) { diary in
                    VStack(alignment: .leading) {
                        Text(diary.title)
                        Text(diary.content)
                        Text(diary.createdAt, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
            }
            .toolbar {
                ToolbarItem {
                    Button(action: addDiary) {
                        Label("Add", systemImage: "plus")
                    }
                }
            }
        }
    }
    
    // データの追加と永続化
    private func addDiary() {
        let newDiary = Diary(title: "Title",
                             content: "Content",
                             createdAt: .now)
        modelContext.insert(newDiary)
    }
}

これだけでデータの読み書きが実現できます。
@Modelをつけたクラスのプロパティは@Publishedをつける必要はなく、値が変更されるとViewが更新されます。

iCloudに対応する

プロジェクトの作成

iCloudに対応させる方法です。

新規プロジェクト作成時、StorageでSwiftDataを選び、「Host in CloudKit」 にチェックを入れます。

CloudKitをONにする

「XcodeのTargets > Signing & Capabilities > iCloud」からメニューを開き、「+」ボタンを押します。
containerのidentifierを問われるので、値を入力して設定します(慣習的に iCloud. から始めることが多いようです)。

追加した直後は赤字になりますが、少し待つと通常の色になります。

「CloudKit Console」をクリックしてブラウザで開いてみましょう。

CloudKit Databaseを選択。

このような画面が表示されます。
CloudKit Databaseについては後述しますので、いったんXcodeに戻ります。

Entityの定義を修正する

CloudKitを使う際の注意として、プロパティをoptionalにするかデフォルト値をセットする必要があります。また、@Attribute(.unique)なども利用できません。

Xcodeのコンソールエラーをを見ながら修正していきます。
今回は以下のようにデフォルト値をセットしました。

@Model
final class Diary {
    var title: String = ""
    var content: String = ""
    var createdAt: Date = Date.now
    
    // ...
}

iCloudにサインインして挙動を確認する

ビルドして挙動を確認してみましょう。

iPhoneのシミュレータを2台立ち上げ、同じApple IDでサインインします。一方でデータを追加すると、もう一方でもデータが同期されます。
(シミュレータだと一度画面を切り替える必要がありますが、実機ではリアルタイムに同期されます)

App Groupsに対応する

Targetの作成

続いてApp Groupsの対応です。
まずはアプリにWidgetを追加しましょう。

New > Target > Widget Extension からターゲットを追加します。

App GroupsをONにする

「Targets > Signing & Capabilities」から「App Groups」を追加します。

「+」ボタンからidentifierを追加します(慣習的にgroup.から始まる値が多いようです)。

Widget側のTargetsでもApp GroupsをONにして、同じグループにチェックを入れます。

共通で使うファイルのTarget Membershipを更新する

ウィジェット側から利用するクラスのTarget Membershipを編集していきます。
(ここは他にも色々やり方があると思うので、お好みで)

まずはEntityのファイルを編集。

ModelContainerもウィジェットから使いたいので、別ファイルに切り出してTarget Membershipを編集します。

// SharedModelContainer.swift
import Foundation
import SwiftData

var sharedModelContainer: ModelContainer = {
    let schema = Schema([
        Diary.self
    ])
    let modelConfiguration = ModelConfiguration(schema: schema)

    do {
        return try ModelContainer(for: schema, configurations: [modelConfiguration])
    } catch {
        fatalError("Could not create ModelContainer: \(error)")
    }
}()

これでウィジェットから呼び出す準備はOKです。

ウィジェット側でSwiftDataからデータを取得する

ウィジェットからデータを呼び出します。

struct Provider: TimelineProvider {
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        Task { @MainActor in
            var entries: [SimpleEntry] = []
            
	    // SwiftDataからデータ取得
            let context = sharedModelContainer.mainContext
            let diaries = (try? context.fetch(FetchDescriptor<Diary>())) ?? []
            
            // 取得したデータを使ってentriesに追加
        }
    }
}

これで本体とウィジェットから共通のデータにアクセスできました。

CloudKit Consoleを確認する

CloudKit Consoleの使い方にも触れておきます。

リリース時は忘れずにDeploy Scheme Changesする

開発時はsandbox環境につながっています。リリースの前に本番環境へ反映する必要があります。
操作はCloudKit Consoleの「Deploy Scheme Changes...」メニューから行います

接続先はentitlementsに設定が書かれているので、常に本番に接続されるように変更することも可能です。

RecordNameを追加してクエリする

iCloudに保存されたデータを確認したい場合、ひと手間必要です。

Indexes > Entity と選択し、「Add Basic Index」します。
"recordName"を選び、Index Typeを"Querable"として追加します。

追加できたらクエリしてみます。デフォルトでプライベート領域にデータが書かれる設定になっているので、以下のように指定します。

  • DBは「Private Database」
  • Zoneは「com.apple.coredata.cloudkit.zone」
  • RECORD_TYPEは対象のType

画像のようにデータ表示が取得できます。

iCloudにデータを送ったものしか変更に検知されない

モデルクラスに加えた変更は自動的にiCloud側に反映されます。
ただし、iCloudにサインインした状態で一度モデルを更新する必要があります。
コードに追加しただけではiCloudに反映されませんので気をつけましょう。

その他

以下の制限があったので書いておきます。

  • enumをPredicateのなかの条件式で使えない。永続化するのはenumではなくRawValueにして対応
  • booleanのプロパティがiCloudに保存できなかった。原因不明だが、文字列にして対応

おわりに

SwiftDataをiCloudとApp Groupsに対応する手順を書きました。
どなたかの参考になれば幸いです。

Discussion