axiom-cloud-sync by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-cloud-sync核心原则:根据数据形态选择合适的同步技术,然后实现能够优雅处理网络故障的离线优先模式。
两种根本不同的同步方法:
需要同步什么?
├─ 结构化数据(记录、关系)?
│ ├─ 使用 SwiftData? → SwiftData + CloudKit(最简单,iOS 17+)
│ ├─ 需要共享/公共数据库? → CKSyncEngine 或原生 CloudKit
│ └─ 自定义持久化(GRDB、SQLite)? → CKSyncEngine(iOS 17+)
│
├─ 用户期望在“文件”应用中看到的文档/文件?
│ └─ iCloud Drive(UIDocument 或 FileManager)
│
├─ 大型二进制数据块(图像、视频)?
│ ├─ 与结构化数据关联? → CloudKit 中的 CKAsset
│ └─ 独立文件? → iCloud Drive
│
└─ 应用设置/偏好?
└─ NSUbiquitousKeyValueStore(简单的键值对,1MB 限制)
| 方面 | CloudKit | iCloud Drive |
|---|---|---|
| 数据形态 | 结构化记录 | 文件/文档 |
| 查询支持 |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 完整的查询语言 |
| 仅限文件名 |
| 关系 | 原生支持 | 无(需手动处理) |
| 冲突解决 | 记录级别 | 文件级别 |
| 用户可见性 | 对用户隐藏 | 在“文件”应用中可见 |
| 共享 | 记录/数据库共享 | 文件共享 |
| 离线 | 需要本地缓存 | 自动下载 |
如果出现以下任何情况,请停止并重新考虑:
强制要求:所有同步代码必须首先能在离线状态下工作。
// ✅ 正确:离线优先架构
class OfflineFirstSync {
private let localStore: LocalDatabase // GRDB, SwiftData, Core Data
private let syncEngine: CKSyncEngine
// 先写入本地,后台同步到云端
func save(_ item: Item) async throws {
// 1. 本地保存(即时)
try await localStore.save(item)
// 2. 加入同步队列(非阻塞)
syncEngine.state.add(pendingRecordZoneChanges: [
.saveRecord(item.recordID)
])
}
// 从本地读取(即时)
func fetch() async throws -> [Item] {
return try await localStore.fetchAll()
}
}
// ❌ 错误:云端优先(因网络而阻塞)
func save(_ item: Item) async throws {
// 离线时失败,网络差时缓慢
try await cloudKit.save(item)
try await localStore.save(item)
}
当两台设备在同步前编辑了相同的数据时,就会发生冲突。
// 服务器始终拥有最新版本,客户端接受它
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
return server // 接受服务器版本
}
适用场景:数据不重要,用户不会注意到覆盖
// 合并两个版本的更改
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
// 对每个字段应用自定义合并逻辑
merged["notes"] = mergeText(
local["notes"] as? String,
server["notes"] as? String
)
merged["tags"] = mergeSets(
local["tags"] as? [String] ?? [],
server["tags"] as? [String] ?? []
)
return merged
}
适用场景:两个版本都包含有价值的更改
// 向用户展示冲突
func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
let choice = await presentConflictUI(local: local, server: server)
return choice == .keepLocal ? local : server
}
适用场景:数据至关重要,必须由用户决定
import SwiftData
// 零配置自动 CloudKit 同步
@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()
}
}
// 如果存在 CloudKit 权限,容器会自动同步
let container = try ModelContainer(for: Note.self)
限制:
// 适用于 GRDB、SQLite 或自定义数据库
class MySyncManager: CKSyncEngineDelegate {
private let engine: CKSyncEngine
private let database: GRDBDatabase
func handleEvent(_ event: CKSyncEngine.Event) async {
switch event {
case .stateUpdate(let update):
// 持久化同步状态
await saveSyncState(update.stateSerialization)
case .fetchedDatabaseChanges(let changes):
// 将更改应用到本地数据库
for zone in changes.modifications {
await handleZoneChanges(zone)
}
case .sentRecordZoneChanges(let sent):
// 将记录标记为已同步
for saved in sent.savedRecords {
await markSynced(saved.recordID)
}
}
}
}
完整的 CKSyncEngine 设置请参阅 axiom-cloudkit-ref。
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
}
}
// 保存到 iCloud Drive(在“文件”应用中可见)
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。
// ❌ 错误:不知道待处理的更改
var items: [Item] = [] // 这些同步了吗?待处理?冲突了?
// ✅ 正确:跟踪同步状态
struct SyncableItem {
let item: Item
let syncState: SyncState // .synced, .pending, .conflict
}
// ❌ 错误:UI 阻塞直到同步完成
func viewDidLoad() async {
items = try await cloudKit.fetchAll() // 飞行模式下永远显示加载指示器
tableView.reloadData()
}
// ✅ 正确:立即显示本地数据
func viewDidLoad() {
items = localStore.fetchAll() // 即时
tableView.reloadData()
Task {
await syncEngine.fetchChanges() // 后台更新
}
}
// ❌ 错误:单次尝试
try await cloudKit.save(record)
// ✅ 正确:指数退避
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 // ✓ (对勾)
case pending // ↻ (箭头)
case conflict // ⚠ (警告)
case offline // ☁ 带 X
}
// 在 SwiftUI 中
HStack {
Text(item.title)
Spacer()
SyncIndicator(state: item.syncState)
}
同步工作前需要:
同步 10,000 条以上记录时,简单的方法会导致超时和启动变慢。
// ❌ 错误:一次性获取所有内容
let allRecords = try await database.fetchAll()
syncEngine.state.add(pendingRecordZoneChanges: allRecords.map { .saveRecord($0.recordID) })
// ✅ 正确:分批初始同步
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
)
// 处理批次
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 的增量方法能高效处理大型数据集,因为它只获取增量数据。
情况:为了按时发布,面临不实现冲突解决的压力。
风险:用户一定会在多台设备上编辑。数据一定会被静默丢失。
回应:“最小可行的冲突处理只需 2 小时。静默数据丢失会使用户流失并产生一星差评。”
情况:避免持续同步的复杂性。
风险:用户期望更改在几秒钟内出现,而不是在下一次启动时。
回应:使用 CKSyncEngine 或 SwiftData,它们会自动处理持续同步。
axiom-cloudkit-ref — 完整的 CloudKit API 参考axiom-icloud-drive-ref — 使用 NSFileCoordinator 进行基于文件的同步axiom-cloud-sync-diag — 调试同步故障axiom-storage — 选择本地数据存储位置每周安装量
104
仓库
GitHub 星标数
606
首次出现
2026年1月21日
安全审计
安装于
opencode90
codex84
gemini-cli83
claude-code83
cursor80
github-copilot78
Core principle : Choose the right sync technology for the data shape, then implement offline-first patterns that handle network failures gracefully.
Two fundamentally different sync approaches:
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)
| Aspect | CloudKit | iCloud Drive |
|---|---|---|
| Data shape | Structured records | Files/documents |
| Query support | Full query language | Filename only |
| Relationships | Native support | None (manual) |
| Conflict resolution | Record-level | File-level |
| User visibility | Hidden from user | Visible in Files app |
| Sharing | Record/database sharing | File sharing |
| Offline | Local cache required | Automatic download |
If ANY of these appear, STOP and reconsider:
MANDATORY : All sync code must work offline first.
// ✅ 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)
}
Conflicts occur when two devices edit the same data before syncing.
// Server always has latest, client accepts it
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
return server // Accept server version
}
Use when : Data is non-critical, user won't notice overwrites
// 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
}
Use when : Both versions contain valuable changes
// 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
}
Use when : Data is critical, user must decide
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)
Limitations :
// 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)
}
}
}
}
See axiom-cloudkit-ref for complete CKSyncEngine setup.
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)
See axiom-icloud-drive-ref for NSFileCoordinator and conflict handling.
// ❌ 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
}
// ❌ 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
}
}
// ❌ 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
}
Always show users the sync state:
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)
}
Before sync will work:
Xcode → Signing & Capabilities
Apple Developer Portal
Device
When syncing 10,000+ records, naive approaches cause timeouts and launch slowdowns.
// ❌ 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 handles incremental sync automatically — it fetches only changes since the last sync token. Ensure you persist stateSerialization so the engine doesn't re-fetch everything on next launch.
| Dataset Size | Strategy | Notes |
|---|---|---|
| < 1,000 records | Default CKSyncEngine | Works out of the box |
| 1,000–10,000 | Batch initial sync | 200-record batches, show progress UI |
| 10,000+ | Pagination + background | Use BGProcessingTask for initial sync |
| 100,000+ | Server-side filtering | Only sync what user needs, lazy-load rest |
Key insight : Initial sync is the bottleneck. After initial sync, CKSyncEngine's incremental approach handles large datasets efficiently because it only fetches deltas.
Situation : Deadline pressure to ship without conflict resolution.
Risk : Users WILL edit on multiple devices. Data WILL be lost silently.
Response : "Minimum viable conflict handling takes 2 hours. Silent data loss costs users and generates 1-star reviews."
Situation : Avoiding continuous sync complexity.
Risk : Users expect changes to appear within seconds, not on next launch.
Response : Use CKSyncEngine or SwiftData which handle continuous sync automatically.
axiom-cloudkit-ref — Complete CloudKit API referenceaxiom-icloud-drive-ref — File-based sync with NSFileCoordinatoraxiom-cloud-sync-diag — Debugging sync failuresaxiom-storage — Choosing where to store data locallyWeekly Installs
104
Repository
GitHub Stars
606
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode90
codex84
gemini-cli83
claude-code83
cursor80
github-copilot78
Supabase Postgres 最佳实践指南 - 8大类别性能优化规则与SQL示例
76,000 周安装