iOS の plist を書き戻しても反映されない原因と cfprefsd を再起動する解決策
はじめに
iOS の Data Container を Mac から書き戻したのにアプリを再起動しても plist の編集だけ反映されない、という現象に遭遇しました。plist 以外のファイル(例えば SQLite)は同じ Container 操作で即反映されるので、書き込み自体は届いています。
原因は cfprefsd のインメモリキャッシュです。シミュレータは xcrun simctl、実機は xcrun devicectl で cfprefsd を terminate すれば、デバイス再起動なしで反映されます。
原因: cfprefsd のインメモリキャッシュ
UserDefaults はアプリが直接 plist を読み書きせず、cfprefsd というシステムデーモンを介します。
- アプリ起動時、
cfprefsdが<bundleId>.plistの内容をメモリにキャッシュする - アプリ実行中の
setはいったんcfprefsdのメモリに溜まる cfprefsd自身のタイミングで、メモリの内容がファイルに flush される- 次回アプリ起動時、
cfprefsdは手元のメモリキャッシュを優先するので、ファイルが外部から書き換えられていても古い値を返す
Xcode の Replace Container などで外から plist ファイルを書き換えても、cfprefsd のメモリは触れません。plist 以外のファイル(例えば SQLite)はこの daemon を経由せずアプリが直接読み書きするため、書き換えがそのまま反映されます。
シミュレータの場合は、シミュレータの runtime 内に cfprefsd が host Mac の cfprefsd とは別プロセスとして走っています。
解決策: cfprefsd を SIGTERM する
手っ取り早く確実なのはシミュレータや実機を再起動することです。launchd 配下のプロセスがまるごと再 spawn されるので、cfprefsd のメモリキャッシュも消えます。ただし開いているアプリの状態やログがリセットされ、起動にも時間がかかります。
ここでは cfprefsd だけを SIGTERM する軽い方法を取り上げます。cfprefsd は launchd 管理のシステムデーモンなので、SIGTERM を送るだけで launchd が新しいインスタンスを spawn します。新しい cfprefsd はメモリキャッシュを持たないので、次回アプリ起動でファイルから plist を読み直します。
シミュレータか実機かで、外部から terminate を送るためのコマンドが変わります。やりたいこと(SIGTERM → launchd で再 spawn)は同じで、SIGTERM を届ける経路だけが違います。
| 項目 | シミュレータ | 実機 |
|---|---|---|
| ツール | xcrun simctl | xcrun devicectl |
| SIGTERM の送り手 | シミュレータ内の launchctl | host Mac の devicectl |
| ターゲット指定 | サービス名(system/com.apple.cfprefsd.xpc.daemon) | PID(毎回 lookup が必要) |
| PID lookup の前処理 | 不要 | 必要 |
| 再 spawn の主体 | シミュレータ内の launchd | 実機内の launchd |
実機側の手段が少し冗長なのは、devicectl に「実機内で任意の binary を spawn する」機能が無く、launchctl 経由のサービス名指定ができないためです。devicectl process terminate で PID 直接指定して terminate する形になります。
シミュレータ (simctl)
booted 状態のシミュレータに対しては、xcrun simctl spawn でシミュレータ内の launchctl を叩き、cfprefsd のサービスを kickstart します。
xcrun simctl spawn booted launchctl kickstart -k system/com.apple.cfprefsd.xpc.daemon
booted のところは個別の UDID でも指定できます。シミュレータが複数 boot している環境では UDID を明示するほうが安全です。
xcrun simctl spawn <UDID> launchctl kickstart -k system/com.apple.cfprefsd.xpc.daemon
このルートはシミュレータ内の launchd 名前空間で動くので、host Mac の cfprefsd には触れません。
実機 (devicectl)
iOS 17 以降の xcrun devicectl には、実機側のプロセスを直接 terminate するサブコマンドがあります。
まず PID を調べます。
xcrun devicectl device info processes \
--device <UDID> \
--search cfprefsd \
--json-output -
返ってくる JSON は次の形です。
{
"result": {
"runningProcesses": [
{ "executable": "file:///usr/sbin/cfprefsd", "processIdentifier": 108 },
{ "executable": "file:///usr/sbin/cfprefsd", "processIdentifier": 109 }
]
}
}
cfprefsd は user / system の 2 系統で動いているので、PID は通常 2 つ並びます。両方に terminate を送ります。
xcrun devicectl device process terminate --device <UDID> --pid 108
xcrun devicectl device process terminate --device <UDID> --pid 109
直後にもう一度 info processes を引くと、別 PID で再 spawn されているはずです。
ワンライナー
書き戻しスクリプトの末尾に呼ぶ前提で、シミュレータ・実機それぞれを 1 ファイルにしておくと楽です。
シミュレータ向け
#!/bin/bash
set -euo pipefail
TARGET="${1:-booted}"
xcrun simctl spawn "$TARGET" launchctl kickstart -k system/com.apple.cfprefsd.xpc.daemon
simctl-cfprefsd-restart.sh の引数を省略すると booted なシミュレータを対象にし、UDID を渡せば特定のシミュレータだけを狙えます。
実機向け
PID を手で拾うのは手間なので、jq で抜き出してまとめて terminate するスクリプトにしておきます。
#!/bin/bash
set -euo pipefail
UDID=$1
xcrun devicectl device info processes \
--device "$UDID" \
--search cfprefsd \
--json-output - \
| jq -r '.result.runningProcesses[].processIdentifier' \
| while read -r pid; do
xcrun devicectl device process terminate --device "$UDID" --pid "$pid"
done
devicectl-cfprefsd-restart.sh <UDID> の形で呼べば、plist 書き戻し直後の反映に困らなくなります。Container 書き戻しの後始末として、書き戻しスクリプトの末尾にそのまま続けて呼ぶ形にしておくと、毎回手動で打たずに済みます。
注意点
- 対象アプリは事前に kill してから plist を差し替える。
cfprefsdの SIGTERM は graceful shutdown を起こすので、対象アプリが起動中で未 flush の書き込みを抱えたまま terminate すると、そのインメモリ値が外部から書き戻した plist を上書きする恐れがあります。順序は「アプリ kill → plist 差し替え →cfprefsdterminate → アプリ再起動」が安全です。 cfprefsdはデバイス上のすべてのアプリの preferences を扱うので、terminate は対象アプリ以外も巻き添えにします。開発端末でのみ実行する前提の手順です。
まとめ
- plist 書き戻しが反映されないのは
cfprefsdのインメモリキャッシュが原因 - シミュレータは
xcrun simctl spawn booted launchctl kickstart -k system/com.apple.cfprefsd.xpc.daemonで SIGTERM できる - 実機は
xcrun devicectl device process terminateでcfprefsdを SIGTERM すれば、次回アプリ起動からファイルが読み直される - plist 以外のファイル(例えば SQLite)は
cfprefsdを経由しないので、同じ Container 操作でも plist だけが詰まる
同じ症状に遭遇したら、シミュレータや実機を再起動する前に cfprefsd を疑ってみてください。