WidgetKit .description()の文字列補間でFatal Error
PlayTimeのWatch Smart Stack1対応で、WidgetKit2の.description()に文字列補間を使ったらWidget Extension3が即死した。Xcodeのコンソールにはエラーが一切表示されず、原因特定に時間がかかった。
この記事では、何を間違えていたのか、なぜ気づけなかったのか、どうやって検知したのかを記録する。
何を間違えていたか
.description()で文字列補間を使った
WidgetKitのIntentConfiguration4(またはStaticConfiguration)には.configurationDisplayName()と.description()という2つのメソッドがある。Widget候補の表示名と説明文を設定するものだ。
// ❌ これがFatal Errorの原因
var body: some WidgetConfiguration {
IntentConfiguration(
kind: "PlayTimeWidget",
intent: PlayTimeIntent.self,
provider: Provider()
) { entry in
PlayTimeWidgetView(entry: entry)
}
.configurationDisplayName("PlayTime") // ← これは動く
.description("Quick access to \(widgetDisplayName)") // ← 💥 Fatal Error
}
.description()に\(widgetDisplayName)のような文字列補間5を渡すと、以下のエラーでWidget Extensionプロセスが即座にクラッシュする:
Fatal error: Formatted text for description is not supported
configurationDisplayName()との非対称性
厄介なのは、.configurationDisplayName()では文字列補間が動くことだ。
.configurationDisplayName("\(appName) Widget") // ✅ 動く
.description("\(appName) Widget") // ❌ Fatal Error
同じLocalizedStringKey6を受け取るAPIなのに、一方は動いて他方はクラッシュする。この非対称性が罠になっている。.configurationDisplayName()で補間が動いたから.description()でも使えるだろうと思うのは自然な発想だが、実際にはFatal Errorになる。
⚠️ .description()の内部実装はLocalizedStringKeyのフォーマット済みテキストを処理できない。これはWidgetKit側の制限で、Appleのドキュメントには明記されていない。
ハマりポイント — エラーが見えない
Widget Extensionのクラッシュは表示されない
この問題を特に厄介にしているのは、Widget Extension3のクラッシュがXcodeのコンソール7に表示されないことだ。
Widget Extensionはメインアプリとは別プロセスで動作する。Xcodeのデバッガはメインアプリのプロセスにアタッチしているため、Extension側のFatal Errorはコンソールに出力されない。
Xcodeコンソール:
✅ メインアプリのログ → 全て表示される
❌ Widget Extensionのログ → 表示されない(別プロセス)
結果として、「ビルドは通る」「メインアプリは正常」「エラーゼロに見える」のに、WidgetがSmart Stackの候補に出てこないという不可解な状況になる。
⚡ Widget Extensionがクラッシュしても、iOSもwatchOSもユーザーにエラーを表示しない。ただ静かにExtensionが消える。Smart Stackの候補リストから消えるだけで、クラッシュしたことすら分からない。
Fatal Errorの検知方法
simctl log showでExtensionプロセスのログを見る
Widget Extensionのログを確認するには、xcrun simctl8でシミュレータのログを直接参照する必要がある。
# Watch SimulatorのUDIDを取得
WATCH_UDID=$(xcrun simctl list devices | grep "Apple Watch" | grep "Booted" | \
head -1 | grep -oE "[0-9A-F-]{36}")
# Widget Extensionプロセスのログを表示(直近5分間)
xcrun simctl spawn "$WATCH_UDID" log show \
--predicate 'process == "PlayTimeWidgetExtension"' \
--last 5m
このログの中に以下のようなFatal Errorメッセージが含まれている:
Fatal error: Formatted text for description is not supported
chronodログで起動段階のクラッシュを確認
watchOSのchronod9プロセスはWidget Extensionのライフサイクルを管理している。Extensionが正常に起動してcomplication descriptor10を登録できたかどうかは、chronodのログで確認できる。
# chronodのログを確認
xcrun simctl spawn "$WATCH_UDID" log show \
--predicate 'process == "chronod"' \
--last 5m | grep -i "complication\|descriptor\|extension"
正常な場合:
Successfully wrote complication descriptor for PlayTimeWidgetExtension
クラッシュしている場合: 上記のメッセージが出力されない。Extensionが起動段階でクラッシュしているため、descriptorの登録まで到達していない。
companion app未インストール時の挙動
Watch SimulatorにiPhoneのcompanion app11がインストールされていない場合、以下のログが出力される:
Ignoring restricted or unknown extension: com.example.app.PlayTimeWidgetExtension
このメッセージは.description()のクラッシュとは別の問題だが、Widget Extensionが認識されない原因として混同しやすい。companion appが正しくインストールされているか確認してから、.description()の問題を疑うこと。
シミュレータキャッシュの罠
修正してもWidgetが候補に出ない
.description()を修正してリビルドしても、一度クラッシュしたWidget Extensionのキャッシュがシミュレータに残っていて、候補リストに出てこないことがある。
# ❌ リビルドだけでは不十分なことがある
# Xcodeでビルド → インストール → Smart Stackに候補が出ない
simctl eraseでリセット
シミュレータのキャッシュをクリアするには、xcrun simctl eraseでリセットが必要。
# Watch Simulatorをリセット
xcrun simctl erase "$WATCH_UDID"
⚠️ simctl eraseはシミュレータの全データを消去する。他のアプリのデータも失われるので注意。ただし、Widget Extensionのキャッシュ問題を確実に解消するにはこれが最も確実な方法。
リセット後、以下の手順で再確認:
- Xcodeでメインアプリ + Widget Extensionをビルド・インストール
- Watch Simulatorでアプリを起動(companion appとして認識させる)
- Smart Stackの編集画面でWidget候補を確認
修正
静的文字列リテラルを使う
修正は単純。.description()には文字列補間を使わず、静的な文字列リテラル12を渡す。
// ✅ 修正後: 静的文字列リテラルを使用
var body: some WidgetConfiguration {
IntentConfiguration(
kind: "PlayTimeWidget",
intent: PlayTimeIntent.self,
provider: Provider()
) { entry in
PlayTimeWidgetView(entry: entry)
}
.configurationDisplayName("PlayTime")
.description("Quick access to PlayTime") // ← 静的文字列リテラル
}
動的な値を表示名や説明文に含めたい場合は、ローカライズファイル(.stringsや.stringsdict)を使う方法がある。ただし、Widget Extensionの.description()はシステムUIに表示される説明文なので、ほとんどの場合は静的文字列で十分だ。
💡 .configurationDisplayName()と.description()の違いを覚えておくだけで、このFatal Errorは完全に回避できる。.description()には文字列補間を入れない。これだけ。
まとめ
| 問題 | 原因 | 対処 |
|---|---|---|
.description()でFatal Error | 文字列補間が内部でサポートされていない | 静的文字列リテラルを使う |
| エラーがXcodeに表示されない | Widget Extensionは別プロセス | simctl log showでExtensionログを確認 |
| 修正後もWidgetが候補に出ない | シミュレータキャッシュ | simctl eraseでリセット |
| companion appが認識されない | 未インストール or 不一致 | companion appを先にインストール |
Widget Extensionのデバッグは、メインアプリとは異なるアプローチが必要だ。「ビルドが通ってエラーゼロなのに動かない」という状況に遭遇したら、まずsimctl log showでExtensionプロセスのログを確認すること。
Footnotes
-
Smart Stack — Apple WatchのウィジェットスタックUI。複数のウィジェットを重ねて表示し、コンテキストに応じて最適なウィジェットを自動的に前面に出す。 ↩
-
WidgetKit — Apple が提供するウィジェット開発フレームワーク。iOS 14/watchOS 9以降で使用可能。SwiftUIベースでウィジェットのUIを定義する。 ↩
-
Widget Extension — WidgetKitウィジェットを提供するApp Extension。メインアプリとは独立したプロセスとして実行される。 ↩ ↩2
-
IntentConfiguration — WidgetKitで、ユーザーがカスタマイズ可能なウィジェットを定義するための構成型。SiriKit Intentと連携して設定画面を自動生成する。 ↩
-
文字列補間 — Swift の
\(expression)構文。文字列リテラル内に式を埋め込み、実行時に評価された値が文字列に変換される。 ↩ -
LocalizedStringKey — SwiftUIのローカライズ対応文字列型。文字列リテラルから自動生成され、Localizable.stringsとの照合に使われる。文字列補間を含む場合は内部でフォーマット処理が発生する。 ↩
-
Xcodeコンソール — Xcode下部のデバッグエリアに表示されるログ出力。print()やos_log()の出力、クラッシュ情報が表示されるが、デバッガがアタッチしているプロセスのログのみ。 ↩
-
simctl — Xcodeに付属するシミュレータ制御コマンドラインツール。
xcrun simctlで呼び出し、シミュレータの起動・アプリインストール・ログ取得・スクリーンショット等を実行できる。 ↩ -
chronod — watchOSのシステムデーモン。ClockKit/WidgetKitのcomplicationやwidgetのライフサイクル管理、タイムライン更新のスケジューリングを担当する。 ↩
-
complication descriptor — WidgetKitが watchOSに登録するウィジェットの定義情報。ウィジェットの種類、対応するファミリー(circular, rectangular等)、表示名を含む。 ↩
-
companion app — Apple Watchアプリと対になるiPhoneアプリ。watchOSアプリの多くはiPhoneにcompanion appがインストールされていることを前提とする。Widget Extensionもcompanion appが必要。 ↩
-
文字列リテラル — ソースコード上に直接記述された固定文字列(例:
"Hello")。変数や式の評価を含まない静的な値。 ↩