機種変更直後に「お気に入りした壁紙が全部消えていた」というレビューを 2 件続けて受けた週があって、それまで AsyncStorage に保存していたお気に入りリストを 6 本ある壁紙アプリ全部で iCloud に寄せ直すことに決めました。アーティスト・クリエイターの廣川政樹です。2014 年から個人で iOS/Android アプリを作っており、累計 5,000 万 DL を超えた壁紙系アプリの保守を一人で続けています。
「iCloud で同期」と聞くと反射的に CloudKit を選びがちですが、お気に入り壁紙のような小さな配列データに対しては、CloudKit はオーバースペックでした。実際に選んだのは NSUbiquitousKeyValueStore(以下 KV ストア)です。本稿は、Rork が出した Expo / React Native プロジェクトに KV ストアを後付けし、6 本のアプリで 3 週間運用した実装ノートです。よくある「サンプルコードはあるが本番で破綻する」パターンを避けるための判断軸を、実数値と一緒に残しておきます。
なぜ CloudKit ではなく NSUbiquitousKeyValueStore か
お気に入り壁紙のデータ構造は、突き詰めれば「壁紙 ID の配列」と「カテゴリ別の数件のメモ」だけです。これを CloudKit に乗せるとスキーマ管理・コンテナ初期化・サブスクリプション設定が必要になり、6 本のアプリで重複作業が走ります。私の場合、もう一つ大きな理由がありました。CloudKit はコンテナ単位での課金や運用上のクォータ管理が将来発生する可能性があり、月収が AdMob 中心の個人開発では、メンテナンスコストを上げてまで採用する根拠が弱かったのです。
NSUbiquitousKeyValueStore は Apple ID に紐づく単純な KV ストレージで、容量は 1 MB、キー数は 1024 個、値あたり 1 MB という制約があります。お気に入り壁紙の ID を文字列で持つなら、平均 180 件のリストでも 5 KB 前後に収まります。6 本のアプリで個別の Apple ID 配下に独立した KV ストアを持つ設計で、容量は十分でした。
判断軸を整理すると、以下のようになります。
ユーザーごとの構造化データ(コメント・編集履歴・大容量メディア)を扱う → CloudKit
端末間で「最新の選好を一致させたい小さな状態」を扱う → KV ストア
ユーザー間で共有が必要な状態を扱う → CloudKit のパブリックデータベース or 自前サーバー
お気に入り壁紙は 2 番目に該当します。ここを CloudKit にすると、課金・運用・更新通知の三方向で考えることが増えて、6 アプリ並行運用が破綻します。私が壁紙アプリで KV ストアに寄せた理由は「小さく寄せきれる確信」ともう一つ、「Apple 側にバックエンドを完全に預けて自分の運用責任を縮める」という指針です。
Expo / React Native プロジェクトに KV ストアを生やす最短ルート
Rork が出力する Expo プロジェクトでは、KV ストアを扱うネイティブモジュールが既定で入っていません。expo-modules-core を使って Swift 側の薄いブリッジを自作するのが、保守しやすくて 6 アプリで使い回せる形でした。
modules/iCloudKVSync/ios/ICloudKVSync.swift に以下のような実装を置きます。
import ExpoModulesCore
import Foundation
public class ICloudKVSyncModule : Module {
private var observer: NSObjectProtocol ?
public func definition () -> ModuleDefinition {
Name ( "ICloudKVSync" )
Events ( "onRemoteChange" )
OnCreate {
let store = NSUbiquitousKeyValueStore.default
// 最初の synchronize は失敗してもログだけ残す
_ = store. synchronize ()
self .observer = NotificationCenter.default. addObserver (
forName : NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object : store,
queue : .main
) { [ weak self ] notification in
guard let self = self else { return }
let reason = notification.userInfo ? [NSUbiquitousKeyValueStoreChangeReasonKey] as? Int ?? -1
let keys = notification.userInfo ? [NSUbiquitousKeyValueStoreChangedKeysKey] as? [ String ] ?? []
self . sendEvent ( "onRemoteChange" , [
"reason" : reason,
"keys" : keys
])
}
}
OnDestroy {
if let observer = self .observer {
NotificationCenter.default. removeObserver (observer)
}
}
AsyncFunction ( "setString" ) { ( key : String , value : String ) -> Bool in
let store = NSUbiquitousKeyValueStore.default
store. set (value, forKey : key)
return store. synchronize ()
}
AsyncFunction ( "getString" ) { ( key : String ) -> String ? in
return NSUbiquitousKeyValueStore.default. string ( forKey : key)
}
AsyncFunction ( "removeKey" ) { ( key : String ) -> Bool in
let store = NSUbiquitousKeyValueStore.default
store. removeObject ( forKey : key)
return store. synchronize ()
}
AsyncFunction ( "synchronize" ) { () -> Bool in
return NSUbiquitousKeyValueStore.default. synchronize ()
}
}
}
ポイントは 3 つあります。1 つ目は、synchronize() の戻り値はあくまで「ローカルキャッシュへの書き込みが成功したか」であって、クラウドへの伝搬完了ではないという点です。2 つ目は、didChangeExternallyNotification を購読してリモート更新の reason / changed keys を JS 側へイベントとして流す設計にしておくと、後から競合解消ロジックを足しやすくなる点です。3 つ目は、OnDestroy で observer を解除しないと、Expo の開発ビルドでホットリロード時に多重発火するので忘れずに書きます。
JS 側は型を最小に保ち、ストアを直接いじる箇所を 1 ファイルに閉じ込めます。
import { NativeModule, requireNativeModule } from 'expo-modules-core' ;
type ChangeEvent = { reason : number ; keys : string [] };
declare class ICloudKVSyncModule extends NativeModule <{
onRemoteChange : ( event : ChangeEvent ) => void ;
}> {
setString ( key : string , value : string ) : Promise < boolean >;
getString ( key : string ) : Promise < string | null >;
removeKey ( key : string ) : Promise < boolean >;
synchronize () : Promise < boolean >;
}
export default requireNativeModule < ICloudKVSyncModule >( 'ICloudKVSync' ) ;
Rork が初期生成するプロジェクトに app.json の ios.entitlements で com.apple.developer.ubiquity-kvstore-identifier を追加するのも忘れずに行います。これを忘れると Xcode のビルドは通るのに、本番の TestFlight ビルドで読み書きが完全に no-op になるという、地味で見つけづらいバグになります。
「お気に入り壁紙」のドメインモデルを KV ストアに寄せる
ここからは設計の話です。お気に入りリストを単一の JSON 文字列としてキー 1 つに突っ込む手抜きパターンを最初試したのですが、3 日でやめました。理由は 2 つあります。1 つは、複数デバイスで同時に編集すると「最後に書いた方が全勝」となり、片方の追加が無音で消える事故が起きること。もう 1 つは、JSON のサイズが 1 MB に近づくと、KV ストアの伝搬が遅くなる体感があるためです(Apple 公式は明記していませんが、複数案件で見た傾向です)。
採用した設計はキーを意味のある粒度で分割する方式です。
キー名 役割 値の例
fav:vお気に入りのスキーマバージョン "3"
fav:idx全 ID のソート済み配列(JSON) ["wp_8a", "wp_19", ...]
fav:ts:各お気に入りの最終更新 UNIX 時刻 "1716879100"
fav:meta:カテゴリ・タグ等の付随メタ(任意) JSON 文字列
fav:tomb:削除済みマーク(墓標)の TTL "1716879500"
この粒度に分けると、(a) 単一の追加・削除がキー 2 個の更新で済む、(b) 墓標を別キーで持つことで「あるデバイスで削除した直後に別デバイスで復活してしまう」事故が防げる、(c) インデックス配列だけ取れば即座に表示できる、という 3 つの利点があります。Apple は KV ストアの個別キー単位で差分同期しているため、1 MB JSON を毎回まるごと送るより、明らかに伝搬が速くなりました。
削除イベントの扱いを少しだけ補足します。お気に入りから外す操作は、fav:idx から ID を消すのと同時に、fav:tomb:<id> に「14 日後の UNIX 時刻」を書きます。リモートから受け取ったインデックスに、ローカルの墓標で生きている ID が含まれていた場合は、追加を無視して逆に相手側へ削除を伝播し直します。14 日経ったら墓標自体を捨てる GC を起動時に走らせます。これで「最後に削除した意思」が確実に勝つ設計になります。
競合解消をどう設計したか — last-writer-wins と削除優先
KV ストアの強みは、Apple が「最後に書いたデバイスの値が勝つ」ことを保証してくれる点ですが、これは個別キー単位の話です。fav:idx のような配列をマージするロジックは自分で書く必要があります。
採用したマージ規則は次の通りです。
リモート側 fav:idx を受信した瞬間、ローカル fav:idx との差集合を取る
リモートにあってローカルにない ID は、fav:tomb:<id> が生きていれば「削除済みとして無視」、そうでなければ追加とみなす
ローカルにあってリモートにない ID は、ローカルの fav:ts:<id> がリモート受信時刻より新しければ追加扱い、古ければ削除扱い
衝突が解消したら新しい fav:idx を即座に書き戻して伝播
これを React 側のフックとして閉じ込めると、UI コードからは「お気に入りの追加・削除」だけ呼べば自動的に同期される形になります。
import { useEffect, useReducer } from 'react' ;
import ICloudKVSync from '@/modules/iCloudKVSync' ;
const INDEX_KEY = 'fav:idx' ;
const TS_PREFIX = 'fav:ts:' ;
const TOMB_PREFIX = 'fav:tomb:' ;
const TOMB_TTL_SEC = 60 * 60 * 24 * 14 ; // 14日
type State = { ids : string []; loaded : boolean };
type Action =
| { type : 'load' ; ids : string [] }
| { type : 'add' ; id : string }
| { type : 'remove' ; id : string };
function reducer ( state : State , action : Action ) : State {
switch (action.type) {
case 'load' :
return { ids: action.ids, loaded: true };
case 'add' :
if (state.ids. includes (action.id)) return state;
return { ... state, ids: [ ... state.ids, action.id]. sort () };
case 'remove' :
return { ... state, ids: state.ids. filter ( x => x !== action.id) };
}
}
export function useSyncedFavorites () {
const [ state , dispatch ] = useReducer (reducer, { ids: [], loaded: false });
useEffect (() => {
( async () => {
const idx = await ICloudKVSync. getString ( INDEX_KEY );
const ids = idx ? ( JSON . parse (idx) as string []) : [];
dispatch ({ type: 'load' , ids });
})();
const sub = ICloudKVSync. addListener ( 'onRemoteChange' , async ( e ) => {
// 競合解消は別関数に切り出す(後述)
const merged = await mergeFavorites ();
dispatch ({ type: 'load' , ids: merged });
});
return () => sub. remove ();
}, []);
const add = async ( id : string ) => {
dispatch ({ type: 'add' , id });
const next = [ ... state.ids, id]. sort ();
await ICloudKVSync. setString ( INDEX_KEY , JSON . stringify (next));
await ICloudKVSync. setString ( TS_PREFIX + id, String (Math. floor (Date. now () / 1000 )));
};
const remove = async ( id : string ) => {
dispatch ({ type: 'remove' , id });
const next = state.ids. filter ( x => x !== id);
await ICloudKVSync. setString ( INDEX_KEY , JSON . stringify (next));
const tombAt = Math. floor (Date. now () / 1000 ) + TOMB_TTL_SEC ;
await ICloudKVSync. setString ( TOMB_PREFIX + id, String (tombAt));
};
return { ... state, add, remove };
}
このフックは、起動時に 1 回だけ KV ストアからロードし、以後はリモート変更通知に応じて再マージするだけです。UI は state.ids をそのまま購読すればよく、KV ストアの存在を意識する必要がありません。
起動時の戻し順序 — AsyncStorage との二重化と移行
すでに AsyncStorage で運用してきたお気に入りデータをいきなり捨てる移行は危険です。1 ヶ月かけて二重化フェーズを置きました。具体的には、起動時に以下の順序で復元します。
AsyncStorage から「直近のお気に入りスナップショット」を読み、UI に即時表示
KV ストアから fav:idx を読み、空ならスナップショットを KV ストアへアップロード
KV ストアと AsyncStorage の両方が存在する場合、fav:ts:<id> を見て新しい方を採用
マージ結果を AsyncStorage に書き戻して整合性を取る
ここで「KV ストアを正、AsyncStorage を従」に振り切らない理由は、iCloud の同期が一時的に止まっている端末(機内モード長期・iCloud 容量不足・サインアウト中など)でも UI が空にならないようにするためです。AsyncStorage を「常時更新されるオフラインキャッシュ」として位置付けると、ネットワーク状況に依存しない UI 復元が保証されます。
実運用では、初回マージで「KV ストアが空 / AsyncStorage に大量データ」のケースが当然多発します。このとき、fav:idx を一気に書き込むより、500 ms 間隔で 50 件ずつ細切れに書き込んだ方が、初期同期の伝搬が安定しました。Apple が KV ストア更新をバッチ化する閾値があるようで、ここは公式ドキュメントに記述がない領域です。私の場合は 6 アプリすべてで同じ間隔を採用しています。
失敗ケースの観測 — Crashlytics に映らない無音劣化を捕まえる
「お気に入りが消える」系の不具合は、クラッシュにならないため Crashlytics に映りません。観測体制を別に組まないと、ユーザーから低評価レビューが来て初めて気づきます。
私が組んだ観測ポイントは 4 つです。
KV ストアへの書き込み回数と平均サイズ(端末ローカルに 1 週間集計しテレメトリ送信)
didChangeExternallyNotification 受信時の NSUbiquitousKeyValueStoreChangeReasonKey の分布
ログイン Apple ID が無いまま起動した割合(FileManager.default.ubiquityIdentityToken が nil)
AsyncStorage と KV ストアの差分件数の四分位数
特に 3 つ目の指標は重要で、iCloud にサインインしていないユーザーが約 8% 存在しました。この層には UI 上で「iCloud にサインインすると別端末と同期できます」という案内をオンボーディングで 1 度だけ出すようにしています。押し付けがましくならない頻度で、しかし無音にしないことが、ユーザーに「同期されていないリスク」を察知してもらう最小限の責任だと考えています。
Crashlytics に映らない劣化を本気で見るなら、Sentry や別の軽量テレメトリを併用するのが現実的です。私は壁紙アプリ 6 本で「お気に入り操作の成功率」を 日次で 99.94% から 99.98% の範囲に収めることをアラート閾値にしています。3 日連続で 99.9% を下回ったら、その日のうちにリリースを巻き戻すか調査スレッドを立てる運用です。
バックエンドが不要になる利点と、それでも捨てない選択肢
KV ストアに寄せた最大の利点は、サーバー運用が完全に消えたことです。以前はお気に入り同期に Cloudflare D1 と Workers を組み合わせて運用していて、月額数百円 + 緊急対応の精神コストがありました。これを Apple 側に預けると、コストは Apple Developer Program 年会費 11,800 円に丸めて吸収できます。
ただし、KV ストアでカバーできないユースケースは明確に分けています。
お気に入り壁紙の検索インデックス(カテゴリ・タグ横断検索)→ ローカル SQLite
アプリ間の課金エンタイトルメント共有 → App Groups の UserDefaults + 自前 API
アプリ運営側からのユーザー単位アナウンス → Firebase Remote Config
CloudKit を諦めたわけではなく、「お気に入り壁紙の同期」という限定されたドメインで、最小コストで最大の効果を出すために KV ストアを選んだ、というのが正確な表現です。1997 年に独学でプログラミングを始めた頃、「動くもののうち、最も小さく作れる選択肢を選ぶ」感覚を覚えました。設計判断のたびに、その感覚を呼び戻すようにしています。
1 ヶ月運用してわかった数値と所感
3 月末から 4 月にかけて、壁紙アプリ 6 本に KV ストア同期を順次投入しました。1 ヶ月の運用で見えた数値をいくつか共有します。
「機種変更後にお気に入りが復元されない」というレビューが、月 5 件から月 0–1 件に減少
KV ストアへの書き込み 1 回あたりの平均サイズは 320 バイト前後
端末間の同期遅延は、Wi-Fi 環境で中央値 4 秒、モバイル回線で中央値 11 秒
iCloud サインアウト中ユーザー比率は 8% で安定
6 アプリ合計のコード追加量は約 380 行(うちネイティブモジュール 110 行)
「劇的に何かが変わった」という感覚ではなく、「ユーザーが気づかないところで静かに体験が安定した」という変化です。お気に入り壁紙のような小さな選好データは、UI から見えないところで失われると怒りに変わるので、無音で確実に同期され続けるインフラを Apple に預けられる安心感は、6 アプリ並行運用の精神的負荷を着実に減らしてくれました。
CloudKit を本格採用する局面では、構造化データやユーザー間共有が必要になったときに改めて検討するつもりです。それまでは、「KV ストアでカバーできる範囲は KV ストアで、それ以外はローカル中心で」という線引きで運用を続けます。同じように複数アプリを並行運用している方は、まず「同期したい状態は本当に CloudKit のスキーマ管理コストに見合うか」を一度書き出してみてください。書き出すと、想像以上に KV ストアで足りるケースが多いことに気づくはずです。
お読みいただきありがとうございました。