iOS 18 のウィジェット開発で最初にぶつかる壁は、「なぜ更新されないのか」という問題です。タイムラインを設定しても、アプリを更新してもホーム画面の表示が変わりません。WidgetKit のデバッグはシミュレーターだけでは再現しにくく、実機のログを追いながら原因を絞り込む作業が続きます。
Claude Code をこのデバッグプロセスに組み込んでから、私の開発サイクルは大きく変わりました。エラーログをそのまま貼り付けて「このタイムライン設計の何が問題か」と問えば、WidgetKit 固有の制約まで踏まえた回答が返ってきます。実際に Claude Code を使いながら iOS 18 のウィジェット機能を実装して気づいた知見を、動くコードとともに記録しておきます。
WidgetKit の基礎と iOS 18 での変化
タイムラインとエントリの設計
WidgetKit の中核は TimelineProvider です。iOS 18 からインタラクティブウィジェットが正式対応となり、ボタンタップやトグル操作がウィジェット上で完結できるようになりました。
まず基本的な構造を確認します。
import WidgetKit
import SwiftUI
// タイムラインエントリ(表示データの単位)
struct TaskEntry : TimelineEntry {
let date: Date
let taskCount: Int
let nextDeadline: Date ?
let completionRate: Double
}
// タイムラインプロバイダー
struct TaskTimelineProvider : TimelineProvider {
typealias Entry = TaskEntry
// ウィジェット追加直後のプレースホルダー
func placeholder ( in context: Context) -> TaskEntry {
TaskEntry (
date : Date (),
taskCount : 3 ,
nextDeadline : Calendar.current. date ( byAdding : .hour, value : 2 , to : Date ()),
completionRate : 0.6
)
}
// ウィジェットギャラリーのプレビュー
func getSnapshot ( in context: Context, completion : @escaping (TaskEntry) -> Void ) {
// スナップショットはできるだけ速く返す(非同期処理は最小限に)
let entry = TaskEntry (
date : Date (),
taskCount : 5 ,
nextDeadline : Calendar.current. date ( byAdding : .hour, value : 1 , to : Date ()),
completionRate : 0.8
)
completion (entry)
}
// 実際のタイムライン生成
func getTimeline ( in context: Context, completion : @escaping (Timeline<TaskEntry>) -> Void ) {
var entries: [TaskEntry] = []
let now = Date ()
// App Group 経由でデータを取得
// ⚠️ 直接 UserDefaults.standard を使うとウィジェットからアクセスできない
let sharedDefaults = UserDefaults ( suiteName : "group.com.yourapp.shared" )
let taskCount = sharedDefaults ? . integer ( forKey : "taskCount" ) ?? 0
let completionRate = sharedDefaults ? . double ( forKey : "completionRate" ) ?? 0.0
// 今後1時間分のエントリを15分刻みで生成
for minuteOffset in stride ( from : 0 , to : 60 , by : 15 ) {
let entryDate = Calendar.current. date (
byAdding : .minute,
value : minuteOffset,
to : now
)\ !
entries. append ( TaskEntry (
date : entryDate,
taskCount : taskCount,
nextDeadline : sharedDefaults ? . object ( forKey : "nextDeadline" ) as? Date,
completionRate : completionRate
))
}
// .atEnd: 最後のエントリ後にシステムが再度 getTimeline を呼ぶ
// .after(date): 指定時刻に再取得
// .never: 手動更新のみ(WidgetCenter.shared.reloadTimelines で)
let timeline = Timeline ( entries : entries, policy : .atEnd)
completion (timeline)
}
}
Claude Code にこのコードを渡して「タイムラインが更新されない場合のデバッグチェックリストを作って」と依頼すると、App Group の設定漏れ、Background refresh の権限、タイムライン更新頻度の制限(1時間あたり最大40〜70回)といった見落としがちなポイントを体系的に提示してくれます。
インタラクティブウィジェットの実装(iOS 17+)
iOS 17 から導入されたインタラクティブウィジェットは、Button と Toggle をウィジェット内で直接使えます。ポイントは AppIntent との連携です。
import AppIntents
import WidgetKit
// ウィジェットから呼ばれる AppIntent
struct CompleteTaskIntent : AppIntent {
static var title: LocalizedStringResource = "タスクを完了"
static var description = IntentDescription ( "ウィジェットからタスクを完了します" )
// パラメーターで対象タスクIDを受け取る
@Parameter (title : "タスクID" )
var taskId: String
func perform () async throws -> some IntentResult {
// App Group 経由でタスクを更新
let sharedDefaults = UserDefaults ( suiteName : "group.com.yourapp.shared" )
var completedIds = sharedDefaults ? . stringArray ( forKey : "completedTaskIds" ) ?? []
guard \ ! completedIds. contains (taskId) else {
return . result ()
}
completedIds. append (taskId)
sharedDefaults ? . set (completedIds, forKey : "completedTaskIds" )
// ウィジェットのタイムラインをリロード
// ⚠️ perform() 内では WidgetCenter を呼んでも即時反映されない場合がある
// → Background Task として登録するか、アプリ起動後に reloadAll を呼ぶ
WidgetCenter.shared. reloadTimelines ( ofKind : "TaskWidget" )
return . result ()
}
}
// ウィジェットのビュー(インタラクティブ対応)
struct TaskWidgetView : View {
let entry: TaskEntry
var body: some View {
VStack ( alignment : .leading, spacing : 8 ) {
Text ( "今日のタスク" )
. font (.caption)
. foregroundStyle (.secondary)
Text ( " \( entry. taskCount ) 件" )
. font (.title2. bold ())
// インタラクティブボタン(iOS 17+)
// Button のクロージャー内では AppIntent を指定する
Button ( intent : CompleteTaskIntent ( taskId : "sample-id" )) {
Label ( "完了" , systemImage : "checkmark.circle" )
. font (.caption)
}
. buttonStyle (.borderedProminent)
. tint (.green)
// 進捗バー
ProgressView ( value : entry.completionRate)
. tint (entry.completionRate > 0.8 ? .green : .orange)
}
. padding ()
. containerBackground (.fill.tertiary, for : .widget)
}
}
Claude Code へのプロンプト例 : 「このインタラクティブウィジェットで、ボタンタップ後に即座にUIが変わらない場合の原因と対処法を教えて」
返ってくる回答では、perform() の完了からウィジェット再描画までに1〜2秒のラグがあること、WidgetCenter.shared.reloadTimelines は非同期で確実性がないこと、Optimistic UI パターン(先に表示を変えてから確認する)の実装方法が具体的に示されます。
Live Activities の設計と実装
ActivityKit の基本構造
Live Activities は Dynamic Island とロック画面に表示されるリアルタイム更新 UI です。配達状況、スポーツスコア、長時間タスクの進捗などに最適です。
import ActivityKit
import SwiftUI
// Live Activity の属性定義
struct BuildTaskAttributes : ActivityAttributes {
// 変更されない静的データ
public struct ContentState : Codable , Hashable {
var progress: Double // 0.0 〜 1.0
var currentStep: String
var elapsedSeconds: Int
var isComplete: Bool
}
let taskName: String // 起動時に決まる静的情報
let startedAt: Date
}
// Live Activity の開始
func startLiveActivity ( taskName : String ) -> Activity<BuildTaskAttributes> ? {
guard ActivityAuthorizationInfo ().areActivitiesEnabled else {
print ( "Live Activities が無効です(設定 → 通知 → ライブアクティビティ を確認)" )
return nil
}
let attributes = BuildTaskAttributes (
taskName : taskName,
startedAt : Date ()
)
let initialState = BuildTaskAttributes. ContentState (
progress : 0.0 ,
currentStep : "準備中" ,
elapsedSeconds : 0 ,
isComplete : false
)
let content = ActivityContent (
state : initialState,
staleDate : Calendar.current. date ( byAdding : .minute, value : 30 , to : Date ())
// staleDate を過ぎると「情報が古い」表示になる
)
do {
let activity = try Activity. request (
attributes : attributes,
content : content,
pushType : .token // プッシュ通知でリモート更新する場合
)
return activity
} catch {
// よくあるエラー: com.apple.ActivityKit.ActivityKitError 4
// → Info.plist に NSSupportsLiveActivities を追加し忘れ
print ( "Live Activity 起動失敗: \( error ) " )
return nil
}
}
// 進捗の更新
func updateActivity ( _ activity: Activity<BuildTaskAttributes>, step : String , progress : Double ) async {
let updatedState = BuildTaskAttributes. ContentState (
progress : progress,
currentStep : step,
elapsedSeconds : Int ( Date (). timeIntervalSince (activity.attributes.startedAt)),
isComplete : progress >= 1.0
)
await activity. update (
ActivityContent (
state : updatedState,
staleDate : Calendar.current. date ( byAdding : .minute, value : 5 , to : Date ())
)
)
}
// Live Activity の UI(Dynamic Island コンパクト表示)
struct BuildActivityCompactView : View {
let state: BuildTaskAttributes.ContentState
var body: some View {
HStack {
Image ( systemName : state.isComplete ? "checkmark.circle.fill" : "hammer.fill" )
. foregroundStyle (state.isComplete ? .green : .orange)
ProgressView ( value : state.progress)
. frame ( width : 60 )
Text ( String ( format : "%.0f%%" , state.progress * 100 ))
. font (.caption2. monospacedDigit ())
}
}
}
Claude Code を使った Live Activities のデバッグ
Live Activities のデバッグで厄介なのは、エラーメッセージが曖昧な点です。ActivityKitError のエラーコードだけ返ってきて、原因が分かりにくいことがあります。
Claude Code に以下のようなプロンプトを送ると、原因の絞り込みが一気に進みます。
以下のエラーで Live Activity が起動しません。
エラー: ActivityKitError(code: 4)
環境:
- iOS 18.3.1
- Xcode 16.2
- Target: iOS 17.0+
- Info.plist に NSSupportsLiveActivities を追加済み
考えられる原因と確認手順を教えてください。
返ってくる回答(私が実際に役立てたもの):
NSSupportsLiveActivities の設定先ミス : メインターゲットではなく Extension のターゲットに追加している場合がある
App Group の不一致 : Live Activity と本体アプリで異なる App Group を参照している
シミュレーターでの制限 : 一部の Live Activities 機能はシミュレーターで動作しない(実機必須)
プッシュ通知の権限 : pushType: .token を指定しているのに APNs の設定が不完全
実際に私のケースでは「2」が原因でした。UserDefaults(suiteName:) の suite 名と、Capabilities で設定した App Group の識別子が1文字異なっていました。このような地味なミスを Claude Code との対話で体系的に潰せるのが大きな時短になります。
App Intents で Siri 連携を実装する
AppIntent の基本設計
App Intents は iOS 16 から導入されたフレームワークで、Siri・スポットライト・ショートカット・インタラクティブウィジェットをすべて統一した API で実装できます。
import AppIntents
// 基本的な AppIntent
struct ShowTodayTasksIntent : AppIntent {
static var title: LocalizedStringResource = "今日のタスクを確認"
static var description = IntentDescription (
"今日の未完了タスクをリストアップします" ,
categoryName : "タスク管理"
)
// Siri で使える場合のフレーズ例(Shortcuts.strings で管理)
static var openAppWhenRun: Bool = true
@MainActor
func perform () async throws -> some IntentResult & ReturnsValue< String > {
let tasks = await fetchTodayTasks ()
let summary = tasks. isEmpty
? "今日のタスクはすべて完了しています"
: "残り \( tasks. count ) 件: \( tasks. prefix ( 3 ). map (\. title ). joined ( separator : "、" ) ) "
return . result ( value : summary)
}
private func fetchTodayTasks () async -> [Task] {
// データ取得ロジック
return []
}
}
// パラメーターを持つ AppIntent
struct AddTaskIntent : AppIntent {
static var title: LocalizedStringResource = "タスクを追加"
@Parameter (title : "タスク名" , requestValueDialog : "追加するタスク名を教えてください" )
var taskTitle: String
@Parameter (title : "期限" , default: nil )
var deadline: Date ?
@Parameter (title : "優先度" , default: .medium)
var priority: TaskPriority
func perform () async throws -> some IntentResult & ShowsSnippetView {
let newTask = Task ( title : taskTitle, deadline : deadline, priority : priority)
try await TaskRepository.shared. add (newTask)
// スニペットビューで確認を表示
return . result {
VStack {
Image ( systemName : "checkmark.circle.fill" )
. foregroundStyle (.green)
Text ( "「 \( taskTitle ) 」を追加しました" )
}
}
}
}
// AppEnum(Siri が理解できる列挙型)
enum TaskPriority : String , AppEnum {
case high = "高"
case medium = "中"
case low = "低"
static var typeDisplayRepresentation: TypeDisplayRepresentation = "優先度"
static var caseDisplayRepresentations: [TaskPriority: DisplayRepresentation] = [
.high : "高" ,
.medium : "中" ,
.low : "低"
]
}
// App Shortcuts(Siri が自動的に提案するショートカット)
struct MyAppShortcuts : AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut (
intent : ShowTodayTasksIntent (),
phrases : [
"今日のタスクを確認" ,
" \( . applicationName ) で今日のタスクを見せて" ,
"今日やることを確認"
],
shortTitle : "今日のタスク" ,
systemImageName : "checklist"
)
AppShortcut (
intent : AddTaskIntent (),
phrases : [
" \( . applicationName ) に \( . title ) を追加して" ,
"タスクを追加: \( . title ) "
],
shortTitle : "タスク追加" ,
systemImageName : "plus.circle"
)
}
}
重要 : AppShortcut の phrases に \(.applicationName) を含めないと、Siri のギャラリーに表示されない場合があります。Claude Code に「なぜ Siri のショートカットギャラリーに表示されないのか」と聞くと、このような仕様上の制約をすぐに教えてくれます。
よくある実装ミスと対処法
① WidgetKit が更新されない(最頻出)
症状: タイムラインを再生成しても画面が変わらない
原因と対処:
App Group 未設定 : ウィジェット Extension からアプリのデータを読めません。Capabilities → App Groups で同一のグループ識別子を有効化する
UserDefaults.standard を使っている : ウィジェットから標準の UserDefaults にはアクセスできません。必ず UserDefaults(suiteName: "group.xxx") を使う
更新頻度の制限に引っかかっている : iOS は1時間あたりのウィジェット更新回数を制限します。開発中はシステム設定で制限を無効化できる(設定 → 開発者 → Widget Background Tasks)
// ❌ ウィジェットからアクセスできない
UserDefaults.standard. set (count, forKey : "taskCount" )
// ✅ App Group 経由でアクセスできる
let sharedDefaults = UserDefaults ( suiteName : "group.com.yourapp.shared" )\ !
sharedDefaults. set (count, forKey : "taskCount" )
// アプリ側でデータを更新したらウィジェットに通知する
WidgetCenter.shared. reloadTimelines ( ofKind : "TaskWidget" )
// 全ウィジェットを一括リロードする場合
WidgetCenter.shared. reloadAllTimelines ()
② インタラクティブウィジェットのタップが反応しない
Button(intent:) を使っているのに反応しない場合、containerBackground の設定漏れが原因のことがあります。iOS 17+ ではウィジェットのルートビューに containerBackground が必須です。
// ❌ iOS 17+ で必要な containerBackground が抜けている
struct TaskWidgetView : View {
var body: some View {
VStack { /* ... */ }
. padding ()
// containerBackground がないとインタラクションが無効になる場合がある
}
}
// ✅ containerBackground を追加する
struct TaskWidgetView : View {
var body: some View {
VStack { /* ... */ }
. padding ()
. containerBackground (.fill.tertiary, for : .widget)
}
}
③ AppIntent が Siri に認識されない
// ❌ AppShortcutsProvider に phrases が少なすぎる or applicationName を含まない
AppShortcut (
intent : ShowTodayTasksIntent (),
phrases : [ "タスクを見せて" ] // アプリ名がない
)
// ✅ アプリ名を含む phrases を複数用意する
AppShortcut (
intent : ShowTodayTasksIntent (),
phrases : [
"今日のタスクを確認" , // Spotlight 向け
" \( . applicationName ) でタスク確認" , // Siri 向け(必須)
" \( . applicationName ) で今日やることを教えて"
],
shortTitle : "タスク確認" ,
systemImageName : "checklist"
)
④ Live Activity が起動時にクラッシュする
NSSupportsLiveActivities を Info.plist に追加しているのにクラッシュする場合、Extension のビルドターゲットにも同じエントリが必要なことがあります。
<\!-- メインアプリの Info.plist -->
< key >NSSupportsLiveActivities</ key >
< true />
<\!-- ⚠️ Widget Extension の Info.plist にも追加が必要な場合がある -->
< key >NSSupportsLiveActivities</ key >
< true />
Claude Code にこの問題を報告すると、Xcode のプロジェクト設定ファイル(.xcodeproj)を直接確認して、各ターゲットの Info.plist 設定状況を診断するコマンドまで提示してくれます。
Claude Code を使った効率的な開発フロー
CLAUDE.md のウィジェット開発用セクション
プロジェクトの CLAUDE.md にウィジェット固有の制約を記述しておくと、Claude Code が毎回適切な前提で回答してくれます。
## Widget Extension
### App Group
- Identifier: group.com.yourcompany.yourapp
- UserDefaults suite: UserDefaults(suiteName: "group.com.yourcompany.yourapp")
- データ共有: taskCount, completionRate, nextDeadline, completedTaskIds
### Widget 種類
- TaskWidget: 静的ウィジェット(Small/Medium対応)
- BuildWidget: Live Activity(Dynamic Island対応)
### 制約事項
- ネットワーク通信不可(スナップショット・タイムライン取得時)
- UserDefaults.standard は使用禁止(必ず App Group 経由)
- containerBackground は必須(iOS 17+)
- インタラクティブウィジェットは iOS 17+ のみ
### 更新トリガー
- アプリ側でデータ変更時: WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
- バックグラウンドタスク: BGAppRefreshTask でタイムラインを再生成
この設定をしておくと「ウィジェットでネットワーク通信したい」と相談しても、制約を踏まえた代替案(App Group キャッシュの活用、Background Task との組み合わせ)を提案してくれます。
繰り返し使えるプロンプトパターン
WidgetKit/App Intents 開発で使えるプロンプトパターン:
1. デバッグ: 「このエラーが出て更新されません: [エラーメッセージ]。
App Group の設定は正しく、containerBackground も追加済みです。
他に確認すべき点はありますか?」
2. 設計相談: 「15分ごとに更新するウィジェットで、
APIから取得したデータを表示したいです。
Background Task との組み合わせ方を教えてください」
3. App Store 審査対策: 「このウィジェットの実装で、
審査でリジェクトされる可能性のある箇所を指摘してください」
4. パフォーマンス: 「getTimeline が頻繁に呼ばれてバッテリー消費が多い場合、
最適なタイムラインポリシーはどれですか?」
App Store 審査で気をつけること
ウィジェット付きアプリの審査では、いくつかの固有の理由でリジェクトされることがあります。Claude Code に「ウィジェット実装の審査チェックリストを作って」と依頼すると、以下のような観点が整理されます。
機能性の確認
ウィジェットが「Set Up Widget」のまま表示されていないか(初回設定なしで動作することが求められる)
ウィジェットのすべてのサイズバリアント(Small/Medium/Large)が正しく動作するか
ウィジェットをタップしたときに適切な画面に遷移するか(Link または widgetURL の設定)
コンテンツの確認
ウィジェットのスクリーンショットがメタデータに含まれているか
ウィジェットの説明がプライバシーポリシーと整合しているか(位置情報・通知を使う場合)
パフォーマンス
getSnapshot が即座に完了するか(長時間かかると審査官のデバイスで表示がされない)
メモリ使用量が過大でないか(ウィジェットは厳しいメモリ制限がある)
これらを Claude Code との対話で事前に確認しておくことで、審査のリジェクトを防ぐことができます。
公式ドキュメントには書かれていない、実運用でつまずいた点
WidgetKit の公式ドキュメントは API リファレンスとしてはよくできていますが、「複数のウィジェットを抱えたアプリを長期運用するとどうなるか」までは書かれていません。私は iOS / Android で壁紙・癒し系のアプリを 2014 年から運用しており、累計ダウンロードは 5,000 万を超えました。そのうち壁紙アプリにウィジェット機能を載せてから気づいた、ドキュメント外の落とし穴を共有します。
タイムライン更新は「1日あたりの総予算」で考える
公式には「1時間あたり40〜70回」と表現されることが多いのですが、実機で観察すると、より実態に近いのは アプリ全体で配分される更新予算(バジェット) という捉え方でした。ウィジェットを複数種類提供していると、この予算を奪い合う形になります。
私の壁紙アプリでは当初、3種類のウィジェット(時計・カレンダー・本日のおすすめ壁紙)すべてを15分刻みで更新していました。結果、午後にはバジェットを使い切り、夕方以降にウィジェットが固まる現象が起きました。Crashlytics にクラッシュは出ないのに「ウィジェットが更新されない」という低評価レビューだけが増える、原因の見えにくい不具合です。
対処として、更新頻度を「実際に表示が変わる必要があるか」で再設計しました。
enum WidgetKind { case clock , dailyWallpaper , calendar }
// ❌ 一律15分刻み(バジェットを浪費する)
// let timeline = Timeline(entries: entries, policy: .atEnd)
// ✅ コンテンツの性質に応じて更新間隔を変える
func makeTimeline ( for kind: WidgetKind, entry : WallpaperEntry) -> Timeline<WallpaperEntry> {
let now = Date ()
switch kind {
case .clock :
// 時刻は Text(date, style: .time) を使えばシステム側が自動更新する
// → タイムラインを刻む必要はなく、1時間に1回の再生成で十分
return Timeline ( entries : [entry], policy : . after (now. addingTimeInterval ( 3600 )))
case .dailyWallpaper :
// 「本日のおすすめ」は日付が変わるときだけ切り替わればよい
let nextMidnight = Calendar.current. nextDate (
after : now,
matching : DateComponents ( hour : 0 , minute : 0 ),
matchingPolicy : .nextTime
) !
return Timeline ( entries : [entry], policy : . after (nextMidnight))
case .calendar :
// カレンダーも日付の境界でのみ更新する
let nextMidnight = Calendar.current. nextDate (
after : now,
matching : DateComponents ( hour : 0 , minute : 0 ),
matchingPolicy : .nextTime
) !
return Timeline ( entries : [entry], policy : . after (nextMidnight))
}
}
ポイントは、時刻表示は Text(date, style: .time) を使えばシステム側が描画を自動更新する ため、わざわざタイムラインを毎分刻む必要がないことです。これを知らずに毎分エントリを生成していると、バジェットを無駄に消費します。この設計に切り替えてから、夕方のウィジェット固まり報告はほぼゼロになりました。
更新トリガーの設計は、サブスクリプションの課金状態をウィジェットへ反映する場合にも効いてきます。StoreKit の状態同期パターンは Claude Code × SwiftUI × StoreKit 2 — アプリ内課金・サブスクリプション実装ガイド で詳しく扱っています。
ウィジェットのメモリ上限と画像最適化(壁紙アプリでの実測)
壁紙アプリ特有の問題として、Widget Extension は本体アプリより厳しいメモリ上限を持つ 点があります。公式には具体的な数値が明記されていませんが、実測では Widget Extension のメモリ上限はおおむね 30MB 前後 で、これを超えるとウィジェットが空白(プレースホルダー)のまま描画されませんでした。フルサイズの壁紙画像(6MB の JPEG はデコードすると数十MBのビットマップになる)をそのままウィジェットに渡すと、この上限を簡単に超えます。
対処は、ウィジェットに渡す前に ImageIO でダウンサンプリングすることです。
import UIKit
import ImageIO
// ウィジェット表示サイズに合わせて画像をダウンサンプリングする。
// フルサイズ画像を UIImage(contentsOfFile:) でそのまま読むと、
// デコード後のビットマップがメモリを圧迫し 30MB 制限を超えてしまう。
func downsampledImage ( at url: URL, pointSize : CGSize, scale : CGFloat) -> UIImage ? {
let sourceOpts = [kCGImageSourceShouldCache : false ] as CFDictionary
guard let source = CGImageSourceCreateWithURL (url as CFURL, sourceOpts) else {
return nil
}
let maxDimension = max (pointSize.width, pointSize.height) * scale
let downsampleOpts = [
kCGImageSourceCreateThumbnailFromImageAlways : true ,
kCGImageSourceShouldCacheImmediately : true , // デコードをこの場で行う
kCGImageSourceCreateThumbnailWithTransform : true ,
kCGImageSourceThumbnailMaxPixelSize : maxDimension
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex (source, 0 , downsampleOpts) else {
return nil
}
return UIImage ( cgImage : cgImage)
}
実測値として、私のアプリの Medium ウィジェット(約 360×170 pt、@3x でおよそ 1080×510 px)では、元画像 6MB(デコード後およそ 48MB のビットマップ)をダウンサンプリングで約 2MB まで落とせました。これでメモリ上限に余裕を持って収まり、空白描画の報告がなくなりました。
Claude Code にこの処理を相談すると、UIImage(named:) や UIImage(contentsOfFile:) がデコード後のビットマップをメモリに展開してしまう点を踏まえて、ImageIO のサムネイル生成 API を使う理由まで説明してくれます。画像が絡むクラッシュの原因切り分けには、iOS Crashlytics の障害トリアージを Claude Code で自動化する で紹介しているシンボリケーション自動化が役立ちました。
App Group のデータは「小さく・参照で」共有する
メモリ上限の話と関連して、App Group の UserDefaults や共有コンテナに大きなデータ(画像バイナリ等)を直接置くのは避けるべきです。ウィジェット側は ファイル名や識別子だけを受け取り、必要なときに自前でダウンサンプリングして読む 設計が安全でした。
// ❌ 画像バイナリを UserDefaults に入れる(読み込み時にメモリを圧迫する)
// sharedDefaults.set(imageData, forKey: "wallpaperImage") // 数MB を毎回展開
// ✅ 共有コンテナ内のファイル名だけを共有する
let containerURL = FileManager.default
. containerURL ( forSecurityApplicationGroupIdentifier : "group.com.yourapp.shared" ) !
let imageURL = containerURL. appendingPathComponent ( "today.jpg" )
let sharedDefaults = UserDefaults ( suiteName : "group.com.yourapp.shared" ) !
sharedDefaults. set (imageURL.lastPathComponent, forKey : "wallpaperFileName" )
// ウィジェット側では downsampledImage(at:) を使って読み込む
この設計は、Firebase 経由で配信した壁紙をローカルにキャッシュして表示する構成とも相性がよく、配信パイプライン側の実装は Claude Code × Firebase 開発自動化ガイド にまとめています。
全体を振り返って:実装を始めるための最初の一歩
ウィジェット開発のスタートラインは、App Group の設定 から始まります。これを間違えると後で大幅な手戻りが起きます。
Xcode で以下の順に設定してください。
メインターゲット → Signing & Capabilities → App Groups → + ボタンで追加
Widget Extension ターゲット → 同じ App Groups を有効化
UserDefaults(suiteName: "group.xxx") で共有データにアクセス
データ更新後に WidgetCenter.shared.reloadTimelines(ofKind:) を呼ぶ
この4ステップが正しく動いたら、あとはウィジェットビューの設計に集中できます。CLAUDE.md に App Group の識別子と制約事項を書いておけば、Claude Code が常にその前提で回答してくれるので、同じ質問を繰り返す必要もなくなります。
iOS 18 のウィジェット機能はまだ発展途上で、毎年のアップデートで新しい API が追加されています。今のうちに基本的なパターンを身につけておくと、次の iOS 19 の新機能にもスムーズに対応できるはずです。