Summary: - Docs: PlatformSync, README - Added symbols: class WatchConnectivityService, func session, func sessionWatchStateDidChange, func sessionReachabilityDidChange, func requestSyncIfNeeded Stats: - 2 files changed, 190 insertions(+)
6.0 KiB
Platform Sync (iOS + watchOS)
This document describes the end-to-end WatchConnectivity workflow used with LocalData. It covers the responsibilities of the LocalData package versus app-level wiring, and shows how to build a launch-order-safe sync handshake.
Goals
- Sync selected keys between iOS and watchOS using
WCSession.updateApplicationContext. - Avoid launch-order dependencies (either app can start first).
- Keep business data stored in LocalData; the watch app displays data in memory.
What LocalData Provides
LocalData does not activate WCSession for you. It provides the core sync utilities:
SyncHelper.syncIfNeeded(...)sendsapplicationContextfor eligible keys.StorageRouter.syncRegisteredKeysIfNeeded()re-sends stored values for all registered keys that are eligible for sync.StorageRouter.syncSnapshot()builds a payload snapshot (key → Data) to reply to watch requests.
These APIs are intentionally low-level so apps can decide when to sync (launch, reachability, user actions, etc.).
What the App Must Provide
Your app owns the WatchConnectivity lifecycle:
- Activate WCSession on iOS and watch.
- Bootstrap sync at app launch and on watch availability changes.
- Handle watch-initiated requests and reply with a snapshot.
- Decode payloads on watch and update UI state.
Recommended Architecture
iOS App
- Create a
WatchConnectivityService(or similar) that:- Activates
WCSession. - Calls
StorageRouter.syncRegisteredKeysIfNeeded()on:activationDidCompletesessionWatchStateDidChangesessionReachabilityDidChange
- Handles watch messages:
didReceiveMessage(_:replyHandler:)→ reply withStorageRouter.syncSnapshot()didReceiveUserInfo→ triggersyncRegisteredKeysIfNeeded()for queued requests
- Activates
watchOS App
-
Create a
WatchConnectivityServicethat:- Activates
WCSession. - Requests sync on launch or reachability changes:
- If
reachable:sendMessagewith arequest_synckey and use the reply payload. - If not reachable:
transferUserInfowith the same request key (queued until iOS launches).
- If
- Loads
applicationContexton activation and decodes known keys.
- Activates
-
Create lightweight handlers (e.g.,
UserProfileWatchHandler) to decode payloads into in-memory state.
Launch-Order-Safe Handshake
This prevents the "watch first" or "phone first" problem.
- Watch launches first
- Sends
request_syncviatransferUserInfoif iPhone unreachable. - When iPhone launches, it receives the queued request and replies with a snapshot.
- Sends
- iPhone launches first
- Bootstraps by calling
syncRegisteredKeysIfNeeded(). - When the watch launches, it requests a sync and receives a snapshot immediately.
- Bootstraps by calling
Either order works with this handshake.
Data Eligibility Rules
A key is eligible for sync when:
availabilityis.allor.phoneWithWatchSyncsyncPolicyis.manualor.automaticSmall- For
.automaticSmall, the payload must be <=SyncConfiguration.maxAutoSyncSize
Keys with .never will not sync unless your app explicitly overrides behavior.
Example: iOS Service
@MainActor
final class WatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityService()
private override init() {
super.init()
let session = WCSession.default
session.delegate = self
session.activate()
}
func session(_ session: WCSession, activationDidCompleteWith: WCSessionActivationState, error: Error?) {
Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() }
}
func sessionWatchStateDidChange(_ session: WCSession) {
Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() }
}
func sessionReachabilityDidChange(_ session: WCSession) {
Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() }
}
func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
Task {
let snapshot = await StorageRouter.shared.syncSnapshot()
replyHandler(snapshot.isEmpty ? ["ack": true] : snapshot)
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
Example: watch Service
@MainActor
final class WatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityService()
private override init() {
super.init()
let session = WCSession.default
session.delegate = self
session.activate()
}
func session(_ session: WCSession, activationDidCompleteWith: WCSessionActivationState, error: Error?) {
requestSyncIfNeeded()
}
func sessionReachabilityDidChange(_ session: WCSession) {
requestSyncIfNeeded()
}
private func requestSyncIfNeeded() {
let session = WCSession.default
if session.isReachable {
session.sendMessage(["request_sync": true]) { reply in
// Apply payload
}
} else {
session.transferUserInfo(["request_sync": true])
}
}
}
Common Pitfalls
- Simulator unreliability: WatchConnectivity is inconsistent in simulators. Test on real devices.
- Missing entitlements: Watch app must be embedded and signed correctly.
- Launch order assumptions: Always use the handshake; do not assume which app starts first.
- Large payloads: Automatic sync will fail if the payload exceeds the configured max size.
SecureStorageSample Reference
For a working implementation, see:
- iOS:
SecureStorageSample/SecureStorageSample/Services/WatchConnectivityService.swift - Watch:
SecureStorageSample/SecureStorageSample Watch App/Services/WatchConnectivityService.swift - Watch handlers:
SecureStorageSample/SecureStorageSample Watch App/Services/Handlers/ - Bootstrap:
SecureStorageSample/SecureStorageSample/SecureStorageSampleApp.swift