axiom-extensions-widgets について
Appleのウィジェット、Live Activities、コントロールセンターのコントロールを開発・デバッグするためのスキルです。拡張機能の制約、データ共有、更新動作、よくある問題を扱います。
主な機能
- 01ウィジェットのタイムラインと更新指針
- 02App Groupsによるデータ共有
- 03Live Activitiesの制限とライフサイクル
- 04コントロールセンターの非同期データ処理
- 05ウィジェットと操作のトラブルシューティング
活用シーン
- 01古いウィジェットデータの原因調査
- 02アプリと拡張機能間のデータ共有
- 03Live Activitiesの実装やデバッグ
- 04応答しないコントロールセンター操作の修正
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-extensions-widgetsインストール後、Claude Code で/axiom-extensions-widgetsを入力して読み込まれたか確認します。
詳細
エクステンションとウィジェット — 規律
基本理念
「ウィジェットはミニアプリではありません。アプリのデータを一目で確認できるビューであり、最適なタイミングでレンダリングされ、システムによって表示されます。エクステンションは、メモリと実行時間が制限されたサンドボックス環境で実行されます。」
考え方:ウィジェットはライブビューではなく、タイムライン上のアーカイブされたスナップショットだと考えてください。ウィジェットは継続的に「実行」されるのではなく、レンダリングされてアーカイブされ、そのスナップショットがシステムによって表示されます。
エクステンションのサンドボックス化:エクステンションには次の制限があります。
- メモリ制限(約30MB)
- ウィジェットビューからのネットワークアクセス不可(ネットワーク取得はTimelineProvider内でのみ可能)
- メインアプリとは別のバンドルコンテナ
- データ共有にはApp Groupsが必要
このスキルを使う場面
✅ 次の場合にこのスキルを使います:
- ウィジェット(ホーム画面、ロック画面、StandBy、コントロールセンター)を実装するとき
- Live Activitiesを作成するとき
- ウィジェットに古いデータが表示される原因を調査するとき
- ウィジェットがギャラリーに表示されないとき
- インタラクティブなボタンが反応しないとき
- Live Activityの開始に失敗するとき
- コントロールセンターのコントロールが反応しないとき
- アプリとウィジェット/エクステンション間でデータを共有するとき
❌ 次の場合はこのスキルを使わないでください:
- App Intentsだけの実装(app-intents-refを使用)
- SwiftUIのレイアウトに関する質問(swiftui-layoutを使用)
- パフォーマンスのプロファイリング(swiftui-performanceを使用)
- 一般的なデバッグ(xcode-debuggingを使用)
関連スキル
- extensions-widgets-ref — 包括的なAPIリファレンス
- app-intents-ref — インタラクティブなウィジェット向けのApp Intents
- swift-concurrency — データ取得のための非同期処理パターン
- swiftdata — App GroupsでのSwiftDataの使用
プロンプトの例
1. 「ウィジェットが更新されません」
→ このスキルでは、タイムラインポリシー、更新の上限、手動再読み込み、App Groupsの設定を扱います
2. 「アプリとウィジェットの間でデータを共有するにはどうすればよいですか?」
→ このスキルでは、App Groupsのエンタイトルメント、共有UserDefaults、コンテナURLについて説明します
3. 「アプリを更新しても、ウィジェットに古いデータが表示されます」
→ このスキルでは、コンテナパス、UserDefaultsのスイート名、WidgetCenterによる再読み込みを扱います
4. 「Live Activityの開始に失敗します」
→ このスキルでは、4KBのデータ上限、ActivityAttributesの制約、認証チェックを扱います
5. 「コントロールセンターのコントロールが反応するまでに時間がかかります」
→ このスキルでは、非同期のValueProviderパターンと楽観的UIを扱います
6. 「インタラクティブなウィジェットのボタンを押しても何も起きません」
→ このスキルでは、App Intentのperform()実装とWidgetCenterによる再読み込みを扱います
要注意事項/アンチパターン
パターン1:ウィジェットビューでのネットワークリクエスト
時間コスト:ウィジェットが空白になったりエラーが表示されたりする原因のデバッグに2〜4時間
症状
- ウィジェットはレンダリングされるが、データが表示されない
- コンソールに「NSURLSession not available in widget extension」というエラーが出る
- ウィジェットが断続的に空白になる
❌ BAD Code
struct MyWidgetView: View {
@State private var data: String?
var body: some View {
VStack {
if let data = data {
Text(data)
}
}
.onAppear {
// ❌ WRONG — Network in widget view
Task {
let (data, _) = try await URLSession.shared.data(from: apiURL)
self.data = String(data: data, encoding: .utf8)
}
}
}
}
失敗する理由:ウィジェットビューはレンダリング、アーカイブされた後に再利用されます。ビュー内のネットワークリクエストは信頼できず、実行されない場合があります。
✅ GOOD Code
// Main app — prefetch and save
func updateWidgetData() async {
let data = try await fetchFromAPI()
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set(data, forKey: "widgetData")
WidgetCenter.shared.reloadAllTimelines()
}
// Widget TimelineProvider — read from shared storage
struct Provider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let shared = UserDefaults(suiteName: "group.com.myapp")!
let data = shared.string(forKey: "widgetData") ?? "No data"
let entry = SimpleEntry(date: Date(), data: data)
let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
}
}
パターン:メインアプリでデータを取得して共有ストレージに保存し、ウィジェットで読み込みます。
TimelineProviderからネットワークリクエストを行えますか?
はい。ただし、重要な注意点があります。
struct Provider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
// ✅ Network requests ARE allowed here
let data = try await fetchFromAPI()
let entry = SimpleEntry(date: Date(), data: data)
completion(Timeline(entries: [entry], policy: .atEnd))
}
}
}
制約:
- 30秒のタイムアウト — getTimeline()が完了しない場合、システムによってエクステンションが終了されます
- バックグラウンドセッション不可 — 大きなファイルはダウンロードできません
- バッテリー消費 — タイムラインを再読み込みするたびにバッテリーを消費します
- 保証なし — 接続状態が悪いと失敗する場合があります
ベストプラクティス:メインアプリで先行取得します(より高速で信頼性が高い)。TimelineProviderからのネットワークアクセスはフォールバックとしてのみ使用します。
パターン2:App Groupsの設定漏れ
時間コスト:ウィジェットに空またはデフォルトのデータが表示される原因のデバッグに1〜2時間
症状
- ウィジェットにプレースホルダーまたはデフォルト値しか表示されない
- メインアプリでの変更がウィジェットに反映されない
- ウィジェット内でUserDefaultsを読み込むとnilが返される
❌ BAD Code
// Main app
UserDefaults.standard.set("Updated", forKey: "myKey")
// Widget extension
let value = UserDefaults.standard.string(forKey: "myKey") // Returns nil!
失敗する理由:UserDefaults.standardは、アプリとエクステンションで異なるコンテナにアクセスします。
✅ GOOD Code
// 1. Enable App Groups entitlement in BOTH targets:
// - Main app target: Signing & Capabilities → + App Groups → "group.com.myapp"
// - Widget extension target: Same group identifier
// 2. Main app
let shared = UserDefaults(suiteName: "group.com.myapp")!
shared.set("Updated", forKey: "myKey")
// 3. Widget extension
let shared = UserDefaults(suiteName: "group.com.myapp")!
let value = shared.string(forKey: "myKey") // Returns "Updated"
確認:
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)
print("Shared container: \(containerURL?.path ?? "MISSING")")
// Should print path, not "MISSING"
パターン 3:過剰な更新(予算の枯渇)
時間コスト:ユーザー体験の低下、バッテリー消耗、ウィジェットの更新停止
症状
- 最初は頻繁にウィジェットが更新されますが、その後停止します
- コンソールログ:「Timeline reload budget exhausted」
- 数時間後にウィジェットが古い状態になります
❌ 悪いコード
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// ❌ WRONG — 60 entries at 1-minute intervals
for minuteOffset in 0..<60 {
let date = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: Date())!
entries.append(SimpleEntry(date: date, data: "Data"))
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
問題点:システムが許可する更新は 1 日あたり 40~70 回です。この方法では 1 時間あたり 24 回更新するため、2~3 時間で予算を使い果たします。
✅ 良いコード
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// ✅ CORRECT — 8 entries at 15-minute intervals (2 hours coverage)
for offset in 0..<8 {
let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: Date())!
entries.append(SimpleEntry(date: date, data: getData()))
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
ガイドライン:
- ほとんどのウィジェットでは、15~60 分間隔にします
- 時間に敏感なデータ(株価、スポーツ)では、5~15 分間隔にします
- 自動更新には
.atEndポリシーを使用します - ユーザーの利用状況に基づいて、最適な更新タイミングをシステムに判断させます
パターン 4:コントロールでメインスレッドをブロックする
時間コスト:コントロールセンターのコントロールが反応しなくなり、UX が低下します
症状
- コントロールセンターでコントロールをタップすると、数秒間スピナーが表示されます
- コントロールが「固まった」ように見えます
- 即座に視覚的なフィードバックがありません
❌ 悪いコード
struct ThermostatControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Thermostat") {
ControlWidgetButton(action: GetTemperatureIntent()) {
// ❌ WRONG — Synchronous fetch blocks UI
let temp = HomeManager.shared.currentTemperature() // Blocking call
Label("\(temp)°", systemImage: "thermometer")
}
}
}
}
問題点:ボタンはメインスレッドで描画されます。ネットワークやデータベースの呼び出しでブロックすると、UI がフリーズします。
✅ 良いコード
struct ThermostatProvider: ControlValueProvider {
func currentValue() async throws -> ThermostatValue {
// ✅ CORRECT — Async fetch, non-blocking
let temp = try await HomeManager.shared.fetchTemperature()
return ThermostatValue(temperature: temp)
}
var previewValue: ThermostatValue {
ThermostatValue(temperature: 72) // Instant fallback
}
}
struct ThermostatValue: ControlValueProviderValue {
var temperature: Int
}
struct ThermostatControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Thermostat", provider: ThermostatProvider()) { value in
ControlWidgetButton(action: AdjustTemperatureIntent()) {
Label("\(value.temperature)°", systemImage: "thermometer")
}
}
}
}
パターン:非同期データには ControlValueProvider を使用し、即時表示用の previewValue フォールバックを用意します。
パターン 5:却下ポリシーの未設定(ゾンビ Live Activity)
時間コスト:ユーザーの不満、低評価レビュー
症状
- イベント終了後も Live Activity がロック画面に何時間も表示されます
- 完了したアクティビティをユーザーが手動で却下しなければなりません
- アクティビティに「配達済み」と表示されますが、消えません
❌ 悪いコード
// Start activity
let activity = try Activity.request(attributes: attributes, content: initialContent)
// Later... event completes
// ❌ WRONG — Never call .end()
// Activity stays forever until user dismisses
問題点:明示的に終了しない限り、アクティビティは無期限に表示され続けます。
✅ 良いコード
// When event completes
let finalState = DeliveryAttributes.ContentState(
status: .delivered,
deliveredAt: Date()
)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .default // Removes after ~4 hours
)
// Or for immediate removal
await activity.end(nil, dismissalPolicy: .immediate)
// Or remove at specific time
let dismissTime = Date().addingTimeInterval(30 * 60) // 30 min
await activity.end(nil, dismissalPolicy: .after(dismissTime))
ベストプラクティス:
.immediate— 一時的なイベント(タイマー完了、曲の再生終了).default— ほとんどのアクティビティ(「完了」状態を約 4 時間表示).after(date)— 終了時刻が決まっている場合(会議終了、飛行機の着陸)
パターン 6:4KB のデータ制限超過(Live Activities)
時間コスト:アクティビティが通知なく開始に失敗し、デバッグが困難になります
症状
Activity.request()がエラーをスローします- コンソール:「Activity attributes exceed size limit」
- アクティビティがロック画面に表示されません
❌ 悪いコード
struct GameAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var teamALogo: Data // ❌ Large image data
var teamBLogo: Data
var playByPlay: [String] // ❌ Unbounded array
var statistics: [String: Any] // ❌ Large dictionary
}
var gameID: String
var venueName: String
}
// Fails if total size > 4KB
let activity = try Activity.request(attributes: attrs, content: content)
失敗する理由:ActivityAttributes と ContentState の合計サイズは 4KB 未満でなければなりません。
✅ 良いコード
struct GameAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var teamAScore: Int // ✅ Small primitives
var teamBScore: Int
var quarter: Int
var timeRemaining: String // "2:34"
var lastPlay: String? // Single most recent play
}
var gameID: String // ✅ Reference, not full data
var teamAName: String
var teamBName: String
}
// Use asset catalog for images in view
struct GameLiveActivityView: View {
var context: ActivityViewContext<GameAttributes>
var body: some View {
HStack {
Image(context.attributes.teamAName) // Asset catalog
Text("\(context.state.teamAScore)")
// ...
}
}
}
対策:
- オブジェクト全体ではなく、ID や参照を保存します
- 画像には埋め込みの Data ではなく、アセットカタログを使用します
- ContentState は最小限に保ちます(変更可能なデータのみ)
- 派生データにはビュー内の計算プロパティを使用します
サイズの目標(安全マージン)
上限:4096 バイト(4KB)
目安:
- ✅ 2KB 未満:拡張の余地があり安全 — v1.0 で推奨
- ⚠️ 2~3KB:許容範囲ですが、機能を追加する際は注意深く監視してください
- 🔴 3.5KB 以上:リスクがあります — 今後フィールドを追加すると上限を超える可能性があります
安全マージンが重要な理由:後からフィールド(新機能や追加データ)を追加することになります。3.8KB から始めると、拡張の余地がまったくありません。
サイズの確認:
let attributes = GameAttributes(gameID: "123", teamAName: "Hawks", teamBName: "Eagles")
let state = GameAttributes.ContentState(teamAScore: 14, teamBScore: 10, quarter: 2, timeRemaining: "5:23", lastPlay: nil)
let encoder = JSONEncoder()
if let attributesData = try? encoder.encode(attributes),
let stateData = try? encoder.encode(state) {
let totalSize = attributesData.count + stateData.count
print("Total size: \(totalSize) bytes")
if totalSize < 2048 {
print("✅ Safe with room to grow")
} else if totalSize < 3072 {
print("⚠️ Acceptable but monitor")
} else if totalSize < 3584 {
print("🔴 Risky - optimize now")
} else {
print("❌ CRITICAL - will likely fail")
}
}
サイズが 2KB を超えた場合の最適化の優先順位:
- 固定の選択肢がある場合、
Stringの説明を enum に置き換えます - 文字列の値を短くします("Team A" → "A")
- より小さい型を使用します(範囲が許す場合は Int → Int8)
- 使用頻度の低いオプションフィールドを削除します
パターン 7:ウィジェットがギャラリーに表示されない
時間コスト:表示されないウィジェットのデバッグに 30 分
症状
- ウィジェットのビルドは正常に完了します
- コンソールにエラーはありません
- ウィジェットピッカー/ギャラリーにウィジェットが表示されません
- ホーム画面に追加できません
❌ 悪いコード
@main
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
MyWidgetView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("Shows data")
// ❌ MISSING: supportedFamilies() — widget won't appear!
}
}
失敗する理由:supportedFamilies() がないと、システムはどのサイズを選択肢として表示すればよいかわかりません。
✅ 良いコード
@main
struct MyWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
MyWidgetView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("Shows data")
.supportedFamilies([.systemSmall, .systemMedium]) // ✅ Required
}
}
その他のよくある原因:
- ウィジェットターゲットの「Skip Install」が YES に設定されています(NO にする必要があります)
- ウィジェット拡張機能がアプリの「Embed App Extensions」に追加されていません
- ビルドフォルダのクリーンが必要です(
Cmd+Shift+K)
決定木
Widget/Extension Issue?
│
├─ Widget not appearing in gallery?
│ ├─ Check WidgetBundle registered in @main
│ ├─ Verify supportedFamilies() includes intended families
│ └─ Clean build folder, restart Xcode
│
├─ Widget not refreshing?
│ ├─ Timeline policy set to .never?
│ │ └─ Change to .atEnd or .after(date)
│ ├─ Budget exhausted? (too frequent reloads)
│ │ └─ Increase interval between entries (15-60 min)
│ └─ Manual reload
│ └─ WidgetCenter.shared.reloadAllTimelines()
│
├─ Widget shows empty/old data?
│ ├─ App Groups configured in BOTH targets?
│ │ ├─ No → Add "App Groups" entitlement
│ │ └─ Yes → Verify same group ID
│ ├─ Using UserDefaults.standard?
│ │ └─ Change to UserDefaults(suiteName: "group.com.myapp")
│ └─ Shared container path correct?
│ └─ Print containerURL, verify not nil
│
├─ Interactive button not working?
│ ├─ App Intent perform() returns value?
│ │ └─ Must return IntentResult
│ ├─ perform() updates shared data?
│ │ └─ Update App Group storage
│ └─ Calls WidgetCenter.reloadTimelines()?
│ └─ Reload to reflect changes
│
├─ Live Activity fails to start?
│ ├─ Data size > 4KB?
│ │ └─ Reduce ActivityAttributes + ContentState
│ ├─ Authorization enabled?
│ │ └─ Check ActivityAuthorizationInfo().areActivitiesEnabled
│ └─ pushType correct?
│ └─ nil for local updates, .token for push
│
├─ Control Center control unresponsive?
│ ├─ Async operation blocking UI?
│ │ └─ Use ControlValueProvider with async currentValue()
│ └─ Provide previewValue for instant fallback
│
└─ watchOS Live Activity not showing?
├─ supplementalActivityFamilies includes .small?
└─ Apple Watch paired and in range?
必須の最初の手順
ウィジェットや拡張機能の問題をデバッグする前に、次のチェックリストを完了してください。
ウィジェットのデバッグチェックリスト
-
☐ メインアプリと拡張機能の両方のターゲットでApp Groupsが有効
# Verify entitlements codesign -d --entitlements - /path/to/YourApp.app # Should show com.apple.security.application-groups -
☐ ウィジェットギャラリーにウィジェットが表示される(ホーム画面だけでなく)
- ホーム画面を長押し → +ボタン → ウィジェットを検索
- 正しい名前と説明で表示されることを確認
-
☐ タイムラインエラーのコンソールログ
# Xcode Console # Filter: "widget" OR "timeline" # Look for: "Timeline reload failed", "Budget exhausted" -
☐ 手動リロードテスト
WidgetCenter.shared.reloadAllTimelines()- これで解決する場合 → 問題はタイムラインポリシーまたは更新予算です
-
☐ 共有コンテナにアクセス可能
let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: "group.com.myapp" ) print("Container: \(container?.path ?? "NIL")") // Must print valid path, not "NIL"
Live Activityのデバッグチェックリスト
-
☐ ActivityAttributesが4KB未満
let encoded = try JSONEncoder().encode(attributes) print("Size: \(encoded.count) bytes") // Must be < 4096 -
☐ 認証の確認
let authInfo = ActivityAuthorizationInfo() print("Enabled: \(authInfo.areActivitiesEnabled)") -
☐ pushTypeがサーバー統合と一致
nil→ ローカル更新のみ.token→ プッシュ通知が必要
-
☐ 終了ポリシーを実装
- すべてのactivity.end()でポリシーを指定する必要があります
コントロールセンターウィジェットのチェックリスト
- ☐ 非同期データにはControlValueProviderを使用
- ☐ previewValueで即時のフォールバックを提供
- ☐ App Intentのperform()はasync
- ☐ ビュー内でブロッキングするネットワーク/データベース呼び出しを行わない
プレッシャーのかかる状況
シナリオ1:「ウィジェットに本番環境で誤ったデータが表示される」
状況
- アプリをApp Storeでリリース済み
- ウィジェットに誤った情報や古い情報が表示されるとユーザーから報告
- 開発環境では正常に動作
プレッシャーの兆候
- 🚨 App Storeのレビュー — ウィジェットの不具合に言及する星1つのレビュー
- ⏰ 時間的プレッシャー — 至急ホットフィックスが必要
- 👔 経営陣の注目 — 経営陣から状況報告を求められる
誤った判断につながる落とし穴(してはいけないこと)
-
「タイムラインの再読み込み頻度を上げればいい」
- 失敗する理由:予算を使い果たし、問題が悪化します
-
「テストではウィジェットが動作していた」
- 失敗する理由:開発環境と本番環境でApp Groupsの設定が一致していません
-
「ユーザーにスマートフォンを再起動してもらえばいい」
- 失敗する理由:解決策にならず、評判を損ないます
必須の体系的な解決手順
手順1:App Groupsを確認する(30分)
// Add logging to BOTH app and widget
let group = "group.com.myapp.production" // Must match exactly
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: group
)
print("[\(Bundle.main.bundleIdentifier ?? "?")] Container: \(container?.path ?? "NIL")")
// Log EVERY read/write
let shared = UserDefaults(suiteName: group)!
print("Writing key 'lastUpdate' = \(Date())")
shared.set(Date(), forKey: "lastUpdate")
確認:アプリを実行し、次にウィジェットを実行します。どちらも同じコンテナパスを出力する必要があります。
手順2:コンテナパスを確認する
# Device logs (Xcode → Window → Devices and Simulators → View Device Logs)
# Filter: Your app bundle ID
# Look for: Container path mismatches
よくある問題:
- アプリで
group.com.myapp.devを使用 - ウィジェットで
group.com.myapp.productionを使用 - 修正:両方の.entitlementsファイルで、まったく同じグループIDが設定されていることを確認
手順3:バージョンスタンプを追加する
// Main app — stamp every write
struct WidgetData: Codable {
var value: String
var timestamp: Date
var appVersion: String
}
let data = WidgetData(
value: "Latest",
timestamp: Date(),
appVersion: Bundle.main.appVersion
)
shared.set(try JSONEncoder().encode(data), forKey: "widgetData")
// Widget — verify version
if let data = shared.data(forKey: "widgetData"),
let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) {
print("Widget reading data from app version: \(decoded.appVersion)")
}
手順4:アプリ起動時に強制的に再読み込みする
// AppDelegate / @main App
func applicationDidBecomeActive(_ application: UIApplication) {
WidgetCenter.shared.reloadAllTimelines()
}
コミュニケーションのテンプレート
関係者向け:
Status: Investigating widget data sync issue
Root cause: App Groups configuration mismatch between app and widget extension in production build
Fix: Updated both targets to use identical group identifier, added logging to prevent recurrence
Timeline: Hotfix submitted to App Store review (24-48h)
Workaround for users: Force-quit app and relaunch (triggers widget refresh)
短縮できる時間
- 体系的な修正を行わない場合:試行錯誤に4~8時間かかり、複数回の再提出が必要
- この手順を使う場合:特定、修正、検証に1~2時間
シナリオ2:「Live Activityを即座に更新する必要がある」
状況
- スポーツのスコアアプリ
- 実際の試合でイベントが起きてから数秒以内にスコアが更新されることをユーザーが期待
- 現在のタイムラインベースの方法では遅すぎる
プレッシャー
- 競争上のプレッシャー:「ほかのアプリのほうが更新が速い」
- 期限:マーケティング部門が「リアルタイム」更新を約束
誤った判断につながる落とし穴(してはいけないこと)
-
「5秒ごとにエントリーを作ればいい」
- 失敗する理由:リアルタイムではなく、バッテリーを消耗し、スケールしません
-
「ウィジェットビューにWebSocketを追加すればいい」
- 失敗する理由:拡張機能は永続的な接続を維持できません
-
「更新間隔を1秒に短縮すればいい」
- 失敗する理由:タイムラインシステムは1分未満の更新を想定して設計されていません
必須の解決策:段階的なアプローチ
重要な事実確認:プッシュ通知のエンタイトルメント承認には3~7日かかります。承認前に機能を約束してはいけません。
フェーズ1:ローカル更新でリリースする(承認不要)
アプリ主導の更新をすぐにリリースします:
// Start activity WITHOUT push (no entitlement needed)
let activity = try Activity.request(
attributes: attributes,
content: initialContent,
pushType: nil // Local updates only
)
// In your app when data changes (user opens app, pulls to refresh)
await activity.update(ActivityContent(
state: updatedState,
staleDate: nil
))
期待値を設定:更新はユーザーがアプリを操作したときに行われます。これはv1.0では許容できる範囲であり、承認は一切不要です。
フェーズ2:承認後にプッシュを追加する(3~7日)
エンタイトルメントの承認後、プッシュに切り替えます:
手順1:Live Activitiesのプッシュを有効にする
// 1. Entitlement: "com.apple.developer.activity-push-notification"
// 2. Request activity with push token
let activity = try Activity.request(
attributes: attributes,
content: initialContent,
pushType: .token
)
// 3. Monitor for token
Task {
for await pushToken in activity.pushTokenUpdates {
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
await sendTokenToServer(activityID: activity.id, token: tokenString)
}
}
手順2:サーバー側のプッシュ(フェーズ2のみ)
{
"aps": {
"timestamp": 1633046400,
"event": "update",
"content-state": {
"teamAScore": 14,
"teamBScore": 10,
"quarter": 2,
"timeRemaining": "5:23"
},
"alert": {
"title": "Touchdown!",
"body": "Team A scores"
}
}
}
標準のプッシュ上限:1時間あたり約10~12件
ステップ 3:高頻度アップデートの権限をリクエストする(フェーズ 2、iOS 18.2 以降)
より頻繁なプッシュ通知が必要なアプリ(スポーツ、株価など)の場合:
<key>com.apple.developer.activity-push-notification-frequent-updates</key>
<true/>
App Store Connect で正当な理由が必要です:「ユーザーエンゲージメントのため、スポーツのライブスコアには即時のアップデートが必要です」
検証
// Log push receipt in Live Activity widget
#if DEBUG
let logURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.myapp"
)!.appendingPathComponent("push_log.txt")
let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
try! "\(timestamp): Received push\n".append(to: logURL)
#endif
コミュニケーション用テンプレート
マーケティング/経営陣向け(フェーズ 1):
Launch Timeline:
- Phase 1 (immediate): Live Activities with app-driven updates. Updates appear when users open app or pull to refresh.
- Phase 2 (3-7 days): Push notification integration after Apple approval. Updates arrive within 1-3 seconds of server events.
Recommendation: Launch Phase 1 to market, communicate Phase 2 as "coming soon" once approved.
マーケティング/経営陣向け(フェーズ 2):
"Real-time" positioning requires clarification:
Technical: Live Activities update via push notifications with 1-3 second latency from server to device
Constraints: Apple's push system has rate limits (~10/hour standard, axiom-higher with special entitlement)
Competitive analysis: Competitors likely use same system with similar limitations
Recommendation: Position as "near real-time" (accurate) vs "instant" (misleading)
現実的な見通し
- プッシュ通知は利用可能な最速の仕組みです
- 1~3 秒の遅延は通常の範囲です
- バッテリー最適化のため、予算の上限があります
- ユーザーは、ミリ秒単位でスコアが速く更新されることより、バッテリーが長持ちすることを好みます
プッシュ通知の設定(APNs 認証、ペイロード形式、トークン管理、サービス拡張機能)の詳細については、axiom-push-notifications と axiom-push-notifications-ref を参照してください。このスキルでは、ActivityKit の UI と状態管理について説明します。
シナリオ 3:「コントロールセンターのコントロールが遅い」
状況
- 照明のスマートホームコントロール
- コントロールセンターのコントロールをタップしてから反応するまで 3~5 秒かかる
- ユーザーは即時のフィードバックを期待しています
必須の修正:楽観的 UI + 非同期値プロバイダー
問題のあるコード
struct LightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Light") {
ControlWidgetToggle(
isOn: LightManager.shared.isOn, // ❌ Blocking fetch
action: ToggleLightIntent()
) { isOn in
Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
}
}
}
}
修正後のコード
// 1. Value Provider for async state
struct LightProvider: ControlValueProvider {
func currentValue() async throws -> LightValue {
// Async fetch from HomeKit/server
let isOn = try await HomeManager.shared.fetchLightState()
return LightValue(isOn: isOn)
}
var previewValue: LightValue {
// Instant fallback from cache
let shared = UserDefaults(suiteName: "group.com.myapp")!
return LightValue(isOn: shared.bool(forKey: "lastKnownLightState"))
}
}
struct LightValue: ControlValueProviderValue {
var isOn: Bool
}
// 2. Optimistic Intent
struct ToggleLightIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Light"
func perform() async throws -> some IntentResult {
// Immediately update cache (optimistic)
let shared = UserDefaults(suiteName: "group.com.myapp")!
let currentState = shared.bool(forKey: "lastKnownLightState")
let newState = !currentState
shared.set(newState, forKey: "lastKnownLightState")
// Then update actual device (async)
try await HomeManager.shared.setLight(isOn: newState)
return .result()
}
}
// 3. Control with provider
struct LightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "Light", provider: LightProvider()) { value in
ControlWidgetToggle(
isOn: value.isOn,
action: ToggleLightIntent()
) { isOn in
Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
.tint(isOn ? .yellow : .gray)
}
}
}
}
結果:キャッシュされた状態を使ってコントロールが即座に反応し、実際のデバイスはバックグラウンドで更新されます。
最終チェックリスト
ウィジェットやライブアクティビティをリリースする前に:
リリース前
- ☐ 両方のターゲット(アプリと拡張機能)で App Groups の権限を設定する
- ☐ 共有 UserDefaults で
suiteNameを使用する(.standardではない) - ☐ タイムラインのエントリーを 5 分以上の間隔にする(予算の使い果たしを防ぐ)
- ☐ ウィジェットのビューでネットワーク通信を行わない(TimelineProvider 内でのみ実行する)
- ☐ ActivityAttributes + ContentState を 4KB 未満にする
- ☐ ライブアクティビティで適切な閉じ方ポリシーを指定して
.end()を呼び出す - ☐ コントロールセンターのコントロールで、非同期データに ControlValueProvider を使用する
- ☐ 実機でテストする(シミュレーターだけではなく)— 必須の理由:
- シミュレーターではタイムラインの予算上限が適用されない
- プッシュ通知はシミュレーターでは機能しない
- App Groups のコンテナーパスはシミュレーターと実機で異なる
- シミュレーターではメモリ上限が適用されない
- バックグラウンド更新の動作が異なる
- ☐ サポートするすべてのウィジェットファミリーをテストする
- ☐ ウィジェットがウィジェットギャラリーに表示されることを確認する
リリース後のモニタリング
- ☐ 「タイムラインの再読み込み予算を使い果たしました」エラーを監視する
- ☐ 分析データでウィジェットのデータの古さを追跡する
- ☐ ウィジェット関連の苦情がないか App Store のレビューを確認する
- ☐ デバッグ用に App Group コンテナーへのアクセスを記録する
よくある失敗のパターン
- App Groups が未設定 → ウィジェットにデフォルトのデータが表示される
- グループ ID が誤っている → アプリとウィジェットが通信できない
- 更新頻度が高すぎる → 数時間後にウィジェットが更新されなくなる
- ビュー内でネットワーク通信を行う → ウィジェットが空白で表示される
- 閉じ方ポリシーがない → ライブアクティビティが終了せずに残る
- メインスレッドをブロックする → コントロールが応答しなくなる
覚えておいてください:ウィジェットは小型のアプリではありません。システムが描画する、一目で確認できるスナップショットです。拡張機能は、厳しいリソース制限のあるサンドボックス環境で動作します。よくある落とし穴を避けるため、このスキルのパターンに従ってください。
リソース
スキル:axiom-extensions-widgets-ref、axiom-push-notifications、axiom-push-notifications-ref、axiom-background-processing