Skip to content

CloudKit サーバレスでプッシュを確実配信する制約と設計

サーバを持たず CloudKit のサブスクリプションだけで「即時イベント(poke 等)は背景でも確実に届けたい」「別種のイベント(位置更新等)は通知を出したくない」を両立する際にぶつかる iOS の制約と、その回避設計。hi-yu(ペア位置共有+ふりふり)で実証した内容。

結論(TL;DR)

機微でないイベントだけを Public DB に分離し、CKQuerySubscription(種別/predicate で絞る)でアラート push する。機微データは共有ゾーンに残し silent 購読にする。 これで「片方は確実配信・もう片方は通知ゼロ」をサーバレス・規約クリーンに両立できる。time-sensitive 化には極小 NSE が必須。

確定した iOS / CloudKit の制約(重要)

  1. 共有DB(shared DB / CKShare 共有ゾーン)の購読は CKDatabaseSubscription(DB 全体)のみ。CKQuerySubscription / CKRecordZoneSubscription は shared スコープ非対応。 → 同一 DB に同居する複数イベント(例: shake と位置)を購読レベルで区別できない。位置更新でも push が飛ぶ。

  2. CKSubscription.NotificationInfointerruptionLevel プロパティは存在しない。alertBody/title/soundName/shouldSendMutableContent/shouldSendContentAvailable/desiredKeys 等のみ) → CloudKit 単体では time-sensitive 通知を出せない。 time-sensitive 化には Notification Service Extension(NSE) で content.interruptionLevel = .timeSensitive を設定するしかない。

  3. com.apple.developer.usernotifications.time-sensitive entitlement は App 拡張(NSE)の App ID に付与できない。 自動プロビジョニングが "... not a valid entitlement"ビルド失敗する。 → entitlement はホストアプリにのみ付与する。通知はホスト bundle に属するため、NSE が設定した interruptionLevel は ホスト側 entitlement で有効になる(実機で Focus 突破を確認済み)。

  4. UNAuthorizationOptions.timeSensitive(許可オプション)は iOS 15 で非推奨。 許可要求には付けず、entitlement+通知側の interruptionLevel = .timeSensitive で有効化する。

  5. サイレント push(content-available のみ)は配信保証なし。 低電力モード/throttling で背景配信が事実上止まる。 確実に届けたいイベントは**アラート型(alertBody + shouldSendMutableContent)**にする必要がある。

    • アラート型push かつ mutable-content=1 のときのみ NSE が起動する。サイレントでは NSE は起動しない。
  6. アラート型 push の通知は NSE では「改変」できるが「削除」できない。 位置更新の通知を完全に消すには restricted entitlement com.apple.developer.usernotifications.filtering が必要だが、 Apple の承認制で通常配布アプリには下りにくい(実質却下前提)。passive 降格(無音)止まりだと通知センターには残る。

  7. CLLocationPushServiceExtension(Location Push)は専用サーバ必須。apns-push-type: location / apns-topic = <bundleID>.location-query / token 認証(.p8) で、 トークンへ push を送るプロバイダサーバが必要。CloudKit からは起動できない=サーバレスでは使えない。

  8. Public DB は CKQuerySubscription が使え、recordType / predicate で発火を絞れる。(公開DBはクエリ購読が標準) ただし全認証ユーザーが読めるので、置くデータは非機微に限る(or 暗号化)。

  9. 独自フィールドが1つも無い「空のレコードタイプ」は Production へ Deploy できない。"Cannot promote schema with empty type 'X'" で失敗する。CKShare のルートレコード型(中立 root)は フィールドが無くなりがち → ダミーフィールド(例 createdAt TIMESTAMP)を1つ足すと昇格できる。 cktool import-schemadevelopment 環境のみ(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のみ)だけを置き、機微データは共有ゾーンに残すのが規約上クリーン。

出典