axiom-cloud-sync について
iCloud 同期を設計する Apple プラットフォーム開発者向けのスキルです。構造化データとファイルに応じた CloudKit または iCloud Drive の選択、オフライン優先設計、競合処理を扱います。
主な機能
- 01CloudKit と iCloud Drive の選択
- 02オフライン優先の同期パターン
- 03競合解決の方針
- 04SwiftData と CKSyncEngine の構成
- 05大規模データのバッチ同期指針
活用シーン
- 01レコードや文書に適した同期方式の選定
- 02オフラインでも使える同期フローの設計
- 03複数デバイスでの編集競合への対応
- 04大量レコードの初回同期の計画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-cloud-syncインストール後、Claude Code で/axiom-cloud-syncを入力して読み込まれたか確認します。
詳細
クラウド同期
概要
基本原則:データの形状に適した同期技術を選び、ネットワーク障害に適切に対処するオフラインファーストのパターンを実装します。
同期方法には、大きく異なる次の 2 つがあります。
- CloudKit — 構造化データ(フィールドやリレーションシップを持つレコード)
- iCloud Drive — ファイルベースのデータ(書類、画像、あらゆるファイル形式)
クイック判定ツリー
What needs syncing?
├─ Structured data (records, relationships)?
│ ├─ Using SwiftData? → SwiftData + CloudKit (easiest, iOS 17+)
│ ├─ Need shared/public database? → CKSyncEngine or raw CloudKit
│ └─ Custom persistence (GRDB, SQLite)? → CKSyncEngine (iOS 17+)
│
├─ Documents/files users expect in Files app?
│ └─ iCloud Drive (UIDocument or FileManager)
│
├─ Large binary blobs (images, videos)?
│ ├─ Associated with structured data? → CKAsset in CloudKit
│ └─ Standalone files? → iCloud Drive
│
└─ App settings/preferences?
└─ NSUbiquitousKeyValueStore (simple key-value, 1MB limit)
CloudKit と iCloud Drive の比較
| 項目 | CloudKit | iCloud Drive |
|---|---|---|
| データの形状 | 構造化レコード | ファイル/書類 |
| クエリのサポート | 完全なクエリ言語 | ファイル名のみ |
| リレーションシップ | ネイティブでサポート | なし(手動) |
| 競合解決 | レコード単位 | ファイル単位 |
| ユーザーからの見え方 | ユーザーには非表示 | ファイルアプリに表示 |
| 共有 | レコード/データベースの共有 | ファイル共有 |
| オフライン | ローカルキャッシュが必要 | 自動ダウンロード |
注意すべき兆候
以下のいずれかが当てはまる場合は、いったん停止して再検討してください。
- ❌ 「JSON ファイルを CloudKit に保存する」 — 適切なツールではありません。ファイルには iCloud Drive を使ってください
- ❌ 「iCloud Drive でリレーションシップを手動で構築する」 — 適切なツールではありません。CloudKit を使ってください
- ❌ 「同期は瞬時に完了すると想定する」 — ネットワークは切断されます。オフラインファーストで設計してください
- ❌ 「競合処理を省略する」 — 複数のデバイスを使うと、競合は必ず発生します
- ❌ 「ユーザーの書類に CloudKit を使う」 — ユーザーには表示されません。iCloud Drive を使ってください
- ❌ 「アプリの起動時だけ同期する」 — ユーザーは継続的な同期を期待しています
オフラインファーストのパターン
必須:すべての同期コードは、まずオフラインで動作する必要があります。
// ✅ CORRECT: Offline-first architecture
class OfflineFirstSync {
private let localStore: LocalDatabase // GRDB, SwiftData, Core Data
private let syncEngine: CKSyncEngine
// Write to LOCAL first, sync to cloud in background
func save(_ item: Item) async throws {
// 1. Save locally (instant)
try await localStore.save(item)
// 2. Queue for sync (non-blocking)
syncEngine.state.add(pendingRecordZoneChanges: [
.saveRecord(item.recordID)
])
}
// Read from LOCAL (instant)
func fetch() async throws -> [Item] {
return try await localStore.fetchAll()
}
}
// ❌ WRONG: Cloud-first (blocks on network)
func save(_ item: Item) async throws {
// Fails when offline, slow on bad network
try await cloudKit.save(item)
try await localStore.save(item)
}
競合解決の戦略
同期前に 2 台のデバイスで同じデータを編集すると、競合が発生します。
戦略 1:最終書き込み優先(最もシンプル)
// Server always has latest, client accepts it
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
return server // Accept server version
}
使用する場面:データが重要ではなく、上書きされてもユーザーが気付かない場合
戦略 2:マージ(最も一般的)
// Combine changes from both versions
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
// For each field, apply custom merge logic
merged["notes"] = mergeText(
local["notes"] as? String,
server["notes"] as? String
)
merged["tags"] = mergeSets(
local["tags"] as? [String] ?? [],
server["tags"] as? [String] ?? []
)
return merged
}
使用する場面:両方のバージョンに価値のある変更が含まれている場合
戦略 3:ユーザーが選択
// Present conflict to user
func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
let choice = await presentConflictUI(local: local, server: server)
return choice == .keepLocal ? local : server
}
使用する場面:データが重要で、ユーザー自身の判断が必要な場合
一般的なパターン
パターン 1:SwiftData + CloudKit(新規アプリに推奨)
import SwiftData
// Automatic CloudKit sync with zero configuration
@Model
class Note {
var title: String
var content: String
var createdAt: Date
init(title: String, content: String) {
self.title = title
self.content = content
self.createdAt = Date()
}
}
// Container automatically syncs if CloudKit entitlement present
let container = try ModelContainer(for: Note.self)
制限事項:
- プライベートデータベースのみ(パブリック/共有データベースは不可)
- 自動同期(タイミングの制御は限定的)
- カスタムの競合解決は不可
- CloudKit 同期では
@Attribute(.unique)がサポートされません。CloudKit を使用する場合は削除してください
パターン 2:CKSyncEngine(カスタム永続化)
// For GRDB, SQLite, or custom databases
class MySyncManager: CKSyncEngineDelegate {
private let engine: CKSyncEngine
private let database: GRDBDatabase
func handleEvent(_ event: CKSyncEngine.Event) async {
switch event {
case .stateUpdate(let update):
// Persist sync state
await saveSyncState(update.stateSerialization)
case .fetchedDatabaseChanges(let changes):
// Apply changes to local DB
for zone in changes.modifications {
await handleZoneChanges(zone)
}
case .sentRecordZoneChanges(let sent):
// Mark records as synced
for saved in sent.savedRecords {
await markSynced(saved.recordID)
}
}
}
}
CKSyncEngine のセットアップ全体については、axiom-cloudkit-ref を参照してください。
パターン 3:iCloud Drive の書類
import UIKit
class MyDocument: UIDocument {
var content: Data?
override func contents(forType typeName: String) throws -> Any {
return content ?? Data()
}
override func load(fromContents contents: Any, ofType typeName: String?) throws {
content = contents as? Data
}
}
// Save to iCloud Drive (visible in Files app)
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)?
.appendingPathComponent("Documents")
.appendingPathComponent("MyFile.txt")
let doc = MyDocument(fileURL: url!)
doc.content = "Hello".data(using: .utf8)
doc.save(to: url!, for: .forCreating)
NSFileCoordinator と競合処理については、axiom-icloud-drive-ref を参照してください。
アンチパターン
1. 同期状態を無視する
// ❌ WRONG: No awareness of pending changes
var items: [Item] = [] // Are these synced? Pending? Conflicted?
// ✅ CORRECT: Track sync state
struct SyncableItem {
let item: Item
let syncState: SyncState // .synced, .pending, .conflict
}
2. 同期中に UI をブロックする
// ❌ WRONG: UI blocks until sync completes
func viewDidLoad() async {
items = try await cloudKit.fetchAll() // Spinner forever on airplane
tableView.reloadData()
}
// ✅ CORRECT: Show local data immediately
func viewDidLoad() {
items = localStore.fetchAll() // Instant
tableView.reloadData()
Task {
await syncEngine.fetchChanges() // Background update
}
}
3. CloudKit のスキーマを本番環境にデプロイしていない
CloudKit には、開発環境と本番環境で別々のスキーマがあります。App Store 上のアプリからアクセスできるのは、本番環境のみです。開発環境でレコードタイプ、フィールド、インデックスを追加しても、デプロイしなければ、本番環境でのクエリはエラーなしで空の結果を返します。
❌ Works in Xcode/TestFlight (Development) → empty results in App Store (Production)
Queries silently return zero results — no CKError, no crash, no clue.
✅ Before every App Store submission:
1. CloudKit Console → Select container
2. "Deploy Schema Changes" → Review changes → Deploy
3. Test with Production environment in Xcode scheme settings
省略した場合の時間コスト:3~7 日(審査のやり直し+「TestFlight では動くのに、本番環境では動かないのはなぜか?」のデバッグ)。これは初めて提出する人が遭遇する CloudKit の落とし穴の中で、最もよくあるものです。
4. 再試行ロジックがない
// ❌ WRONG: Single attempt
try await cloudKit.save(record)
// ✅ CORRECT: Exponential backoff
func saveWithRetry(_ record: CKRecord, attempts: Int = 3) async throws {
for attempt in 0..<attempts {
do {
try await cloudKit.save(record)
return
} catch let error as CKError where error.isRetryable {
let delay = pow(2.0, Double(attempt))
try await Task.sleep(for: .seconds(delay))
}
}
throw SyncError.maxRetriesExceeded
}
同期状態の表示
同期状態は、常にユーザーに表示してください。
enum SyncState {
case synced // ✓ (checkmark)
case pending // ↻ (arrows)
case conflict // ⚠ (warning)
case offline // ☁ with X
}
// In SwiftUI
HStack {
Text(item.title)
Spacer()
SyncIndicator(state: item.syncState)
}
エンタイトルメントのチェックリスト
同期を機能させるには、次の項目を確認してください。
-
Xcode → Signing & Capabilities
- ✓ iCloud 機能を追加済み
- ✓ CloudKit にチェック(CloudKit の場合)
- ✓ iCloud Documents にチェック(iCloud Drive の場合)
- ✓ コンテナを選択/作成済み
-
Apple Developer Portal
- ✓ App ID に iCloud 機能を設定済み
- ✓ CloudKit コンテナが存在する(CloudKit の場合)
-
CloudKit Console(App Store 提出前)
- ✓ スキーマを本番環境にデプロイ済み(レコードタイプ、フィールド、インデックス)
- ✓ Xcode のスキームで本番環境を指定してテストし、クエリが動作することを確認済み
-
デバイス
- ✓ iCloud にサインイン済み
- ✓ iCloud Drive を有効化済み(設定 → [名前]→ iCloud)
大規模データセットの同期
10,000 件以上のレコードを同期する場合、単純な方法ではタイムアウトや起動の遅延が発生します。
初回同期の戦略
// ❌ WRONG: Fetch everything at once
let allRecords = try await database.fetchAll()
syncEngine.state.add(pendingRecordZoneChanges: allRecords.map { .saveRecord($0.recordID) })
// ✅ CORRECT: Batch initial sync
func performInitialSync(batchSize: Int = 200) async throws {
var cursor: CKQueryOperation.Cursor? = nil
repeat {
let (results, nextCursor) = try await database.records(
matching: query,
resultsLimit: batchSize,
desiredKeys: nil,
continuationCursor: cursor
)
// Process batch
try await localStore.saveBatch(results.compactMap { try? $0.1.get() })
cursor = nextCursor
} while cursor != nil
}
差分同期(初回以降)
CKSyncEngine は差分同期を自動的に処理します。最後の同期トークン以降の変更のみを取得します。次回起動時にエンジンがすべてを再取得しないよう、stateSerialization を永続化してください。
パフォーマンスのガイドライン
| データセットのサイズ | 戦略 | 備考 |
|---|---|---|
| 1,000 件未満 | デフォルトの CKSyncEngine | そのまま使えます |
| 1,000~10,000 件 | 初回同期をバッチ処理 | 200 件単位のバッチで処理し、進行状況を表示する UI を表示します |
| 10,000 件以上 | ページネーション+バックグラウンド処理 | 初回同期には BGProcessingTask を使用します |
| 100,000 件以上 | サーバー側でフィルタリング | ユーザーに必要なデータのみ同期し、残りは必要に応じて読み込みます |
重要なポイント:ボトルネックとなるのは初回同期です。初回同期後は、差分のみを取得する CKSyncEngine の差分同期により、大規模なデータセットも効率的に処理できます。
想定されるプレッシャーへの対応
シナリオ 1:「v1 では競合処理を省略しよう」
状況:競合解決なしでリリースしようという、締め切りへのプレッシャー。
リスク:ユーザーは複数のデバイスで必ず編集します。データは気づかれないまま失われます。
対応:「最低限の競合処理にかかる時間は 2 時間です。気づかないデータ損失はユーザーを失い、星 1 つのレビューにつながります。」
シナリオ 2:「アプリ起動時に同期すれば十分」
状況:継続的な同期の複雑さを避けようとしています。
リスク:ユーザーは、次回の起動時ではなく、数秒以内に変更が反映されることを期待します。
対応:継続的な同期を自動的に処理する CKSyncEngine または SwiftData を使用してください。
関連スキル
axiom-cloudkit-ref— CloudKit API の完全なリファレンスaxiom-icloud-drive-ref— NSFileCoordinator を使ったファイルベースの同期axiom-cloud-sync-diag— 同期失敗のデバッグaxiom-storage— データをローカルのどこに保存するかの選択