本文へスキップ

iOS の plist を書き戻しても反映されない原因と cfprefsd を再起動する解決策

はじめに

iOS の Data Container を Mac から書き戻したのにアプリを再起動しても plist の編集だけ反映されない、という現象に遭遇しました。plist 以外のファイル(例えば SQLite)は同じ Container 操作で即反映されるので、書き込み自体は届いています。

原因は cfprefsd のインメモリキャッシュです。シミュレータは xcrun simctl、実機は xcrun devicectlcfprefsd を 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 simctlxcrun devicectl
SIGTERM の送り手シミュレータ内の launchctlhost Mac の devicectl
ターゲット指定サービス名(system/com.apple.cfprefsd.xpc.daemonPID(毎回 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 差し替え → cfprefsd terminate → アプリ再起動」が安全です。
  • cfprefsd はデバイス上のすべてのアプリの preferences を扱うので、terminate は対象アプリ以外も巻き添えにします。開発端末でのみ実行する前提の手順です。

まとめ

  • plist 書き戻しが反映されないのは cfprefsd のインメモリキャッシュが原因
  • シミュレータは xcrun simctl spawn booted launchctl kickstart -k system/com.apple.cfprefsd.xpc.daemon で SIGTERM できる
  • 実機は xcrun devicectl device process terminatecfprefsd を SIGTERM すれば、次回アプリ起動からファイルが読み直される
  • plist 以外のファイル(例えば SQLite)は cfprefsd を経由しないので、同じ Container 操作でも plist だけが詰まる

同じ症状に遭遇したら、シミュレータや実機を再起動する前に cfprefsd を疑ってみてください。

参考リンク