Appearance
CloudKit サーバレスでプッシュを確実配信する制約と設計
サーバを持たず CloudKit のサブスクリプションだけで「即時イベント(poke 等)は背景でも確実に届けたい」「別種のイベント(位置更新等)は通知を出したくない」を両立する際にぶつかる iOS の制約と、その回避設計。hi-yu(ペア位置共有+ふりふり)で実証した内容。
結論(TL;DR)
機微でないイベントだけを Public DB に分離し、CKQuerySubscription(種別/predicate で絞る)でアラート push する。機微データは共有ゾーンに残し silent 購読にする。 これで「片方は確実配信・もう片方は通知ゼロ」をサーバレス・規約クリーンに両立できる。time-sensitive 化には極小 NSE が必須。
確定した iOS / CloudKit の制約(重要)
共有DB(shared DB / CKShare 共有ゾーン)の購読は
CKDatabaseSubscription(DB 全体)のみ。CKQuerySubscription/CKRecordZoneSubscriptionは shared スコープ非対応。 → 同一 DB に同居する複数イベント(例: shake と位置)を購読レベルで区別できない。位置更新でも push が飛ぶ。CKSubscription.NotificationInfoにinterruptionLevelプロパティは存在しない。 (alertBody/title/soundName/shouldSendMutableContent/shouldSendContentAvailable/desiredKeys等のみ) → CloudKit 単体では time-sensitive 通知を出せない。 time-sensitive 化には Notification Service Extension(NSE) でcontent.interruptionLevel = .timeSensitiveを設定するしかない。com.apple.developer.usernotifications.time-sensitiveentitlement は App 拡張(NSE)の App ID に付与できない。 自動プロビジョニングが"... not a valid entitlement"でビルド失敗する。 → entitlement はホストアプリにのみ付与する。通知はホスト bundle に属するため、NSE が設定した interruptionLevel は ホスト側 entitlement で有効になる(実機で Focus 突破を確認済み)。UNAuthorizationOptions.timeSensitive(許可オプション)は iOS 15 で非推奨。 許可要求には付けず、entitlement+通知側のinterruptionLevel = .timeSensitiveで有効化する。サイレント push(content-available のみ)は配信保証なし。 低電力モード/throttling で背景配信が事実上止まる。 確実に届けたいイベントは**アラート型(alertBody +
shouldSendMutableContent)**にする必要がある。- アラート型push かつ
mutable-content=1のときのみ NSE が起動する。サイレントでは NSE は起動しない。
- アラート型push かつ
アラート型 push の通知は NSE では「改変」できるが「削除」できない。 位置更新の通知を完全に消すには restricted entitlement
com.apple.developer.usernotifications.filteringが必要だが、 Apple の承認制で通常配布アプリには下りにくい(実質却下前提)。passive 降格(無音)止まりだと通知センターには残る。CLLocationPushServiceExtension(Location Push)は専用サーバ必須。apns-push-type: location/apns-topic = <bundleID>.location-query/ token 認証(.p8) で、 トークンへ push を送るプロバイダサーバが必要。CloudKit からは起動できない=サーバレスでは使えない。Public DB は
CKQuerySubscriptionが使え、recordType / predicate で発火を絞れる。(公開DBはクエリ購読が標準) ただし全認証ユーザーが読めるので、置くデータは非機微に限る(or 暗号化)。独自フィールドが1つも無い「空のレコードタイプ」は Production へ Deploy できない。
"Cannot promote schema with empty type 'X'"で失敗する。CKShare のルートレコード型(中立 root)は フィールドが無くなりがち → ダミーフィールド(例createdAtTIMESTAMP)を1つ足すと昇格できる。cktool import-schemaは development 環境のみ(production への直接 import/validate は不可)。 本番反映は CloudKit Console の「Deploy Schema Changes to Production」フロー必須。
推奨設計パターン
「確実配信したい非機微イベント」を Public DB に分離する:
| データ | 置き場所 | 購読 | 通知 |
|---|---|---|---|
| 機微データ(位置など) | CKShare 共有ゾーン(ペア限定) | CKDatabaseSubscription(silent / content-available のみ) | 出さない(背景 refresh のみ) |
| 非機微イベント(poke/合図) | Public DB の専用レコード | CKQuerySubscription(pairID 等で predicate) | アラート+mutable-content → NSE で time-sensitive 昇格 |
ポイント:
- 自己シグナル除外は predicate の equality 2 本(
pairID == 自分 AND senderRole == 相手)で構造的に。!=を避けるとインデックスに優しい。 - pairID は CKShare の
recordID.recordName(システム生成 UUID・owner/participant 同値)を流用すると再ペア不要・推測不可。 - Public DB に出すのは ランダム pairID+role+時刻のみにすれば個人情報ゼロ=規約クリーン(後述)。
- 受信は 背景=NSE 表示/前面=
UNUserNotificationCenterDelegate.willPresentでバナー抑止+アプリ内演出。 前面はクエリ購読 push を取りこぼし得るのでポーリング保険を併用。 - NSE は極小でよい:Public DB クエリ購読の push は「来た=必ず該当イベント」が保証されるので、 NSE 内で fetch も判定も dedupe も不要。
interruptionLevel = .timeSensitiveを上げるだけ。 - 前面の
willPresent/didReceiveはローカル通知(近接等)でも呼ばれるので、request.trigger is UNPushNotificationTriggerでリモート push に限定しないと誤発火する。
ペア解除の伝播(ソフト解除パターン)
CKShare ペアの「解除」をサーバレスで相手へ伝える実装パターン(hi-yu で実証):
- 完全削除(CKShare/ゾーン/レコード破棄)は避ける。クロス DB で失敗時の整合が崩れやすく破壊的。
- ソフト解除が安全側: 自分の共有レコードに
sharing=0(解除フラグ)を書く → 相手の既存の共有ゾーン silent 購読(CKDatabaseSubscription)が発火 → 相手は背景 refresh で検知。 あわせて自分の購読を全削除+ローカル状態 clear。レコード実体は残すが機能的に解除済み。 - 相手側の自動解除: 相手は
sharing=0検知でローカルも自動 clear し未ペア画面へ戻すと UX が自然 (「片方解除=両方解除」の期待に一致)。検知は通常の partner fetch(前面ポーリング/push/起動時)に相乗り。 - 反映タイミングは「次回コールド起動時」が確実ライン。位置 silent push はスロットリングされるため、 背景での即時反映は保証されない。解除は緊急性が低くこの遅延は許容できる (即時にしたいなら shake と同様 Public DB+alert push に載せる必要がある=コスト高)。
- 注意: 解除フラグに
sharingを流用すると「一時的に共有オフ」機能と衝突する。 共有オフ/解除を区別したいなら専用フラグ(例unpaired)を分ける。
落とし穴: CKShare 再利用で再ペアしても pairID が変わらない → 解除検知が誤爆
CKShare 作成の定石(root を fetch-first で再利用し、root に紐づく既存 share があれば使い回す)だと、 解除→再ペアでも同じ CKShare/同じ recordID.recordName(pairID)になる。解除で書いた sharing=0 は同一ゾーン/レコードに残るため、再ペア直後に相手の古い 0 を読んだ瞬間に解除検知が 発火し、双方が sharing=0 を書き合う自動解除のカスケードが起きうる(star が消える/再起動で双方に 解除通知)。
- pairID で「解除検知の武装状態」を区別してはいけない(再ペアで不変なため区別にならない)。
- 対策: 武装状態を ペア確立時にリセットする局所フラグにし、「確立後に一度
sharing=1(共有中)を 観測してからの0」だけを解除とみなす。フラグは永続化すれば「アプリ再起動時に解除を反映」も両立。 - あわせて 共有中の書き込みは常に
sharing=1にして(savePolicy=.changedKeys、既存 0 を上書き)、 ペア確立直後に在席報告で1を即書きすると、残留0による誤検知窓を最小化できる。 - 根治するなら sharing 流用をやめ、解除専用表現(レコード削除/専用フラグ/再ペア時に share 作り直し)にする。
Apple 規約(公開DBに個人データを置く是非)
- 公開DB に個人データを置くこと自体は禁止ではない(作成者限定のセキュリティロール+暗号化で実装するアプリは実在)。
- ただし Apple は「機微データは private/shared DB に」と明確に推奨(GDPR & CloudKit)。位置等の機微データを公開DBに置くと 審査リスク+暗号化の輸出コンプライアンス申告(
ITSAppUsesNonExemptEncryption)が増える。 - → 公開DBには非機微データ(時刻・ランダムIDのみ)だけを置き、機微データは共有ゾーンに残すのが規約上クリーン。
出典
- CKDatabaseSubscription / shared DB 制約: https://developer.apple.com/documentation/cloudkit/ckdatabasesubscription
- CKSubscription.NotificationInfo: https://developer.apple.com/documentation/cloudkit/cknotificationinfo
- UNNotificationServiceExtension: https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension
- UNNotificationInterruptionLevel.timeSensitive: https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/timesensitive
- CLLocationPushServiceExtension: https://developer.apple.com/documentation/corelocation/creating-a-location-push-service-extension
- GDPR & CloudKit: https://developer.apple.com/videos/play/tech-talks/703/
- 輸出コンプライアンス: https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations