6本の壁紙アプリを個人開発で運用していて、ある時期から「ホーム画面ウィジェットの今日の一枚が、夕方以降は昨日のままになる」という報告が増えました。シミュレータでは正しく切り替わるのに、実機では夕方で止まる。この差を生んでいたのが、WidgetKit の更新予算という見えにくい仕組みでした。
ウィジェットはアプリと違い、好きなタイミングで好きなだけ描き直せるものではありません。システムが1日あたりの更新回数におおまかな予算を割り当てており、それを使い切ると、翌日まで更新が来なくなります。この前提を知らずにタイムラインを組むと、午前中に予算を食い尽くして午後は沈黙する、という今回の症状になります。
タイムラインは「未来の予定表」である
WidgetKit の考え方の中心は、ウィジェットが「いま何を表示するか」を毎回問い合わせるのではなく、「これからしばらくの表示予定」をまとめて提出する点にあります。TimelineProvider が返す Timeline は、複数の TimelineEntry を時刻つきで並べた予定表です。
システムはこの予定表に従って、指定時刻になったら次のエントリへ自動で切り替えます。つまり、未来の表示を先に計算して束ねて渡せば、その間システムへの問い合わせ(=予算消費)は発生しません。ここを理解すると、設計の方向性が決まります。
1エントリずつ刻むと予算が枯れる
最初に私がやってしまったのが、1時間ごとに1エントリだけ返し、.atEnd で「終わったらまた取りに来て」と繰り返す実装でした。これは一見正しく動きますが、リロードのたびに予算を消費するため、変化の多い日には昼過ぎで予算が尽きます。
正しくは、1回のタイムライン生成で未来の複数エントリをまとめて返します。たとえば1日分の切り替えを24本のエントリとして先に計算し、それを1つの Timeline に詰めて返せば、その日のうちはほぼ追加の問い合わせなしで回ります。
import WidgetKit
import SwiftUI
struct WallpaperProvider: TimelineProvider {
func placeholder(in context: Context) -> WallpaperEntry {
WallpaperEntry(date: Date(), imageName: "placeholder")
}
func getSnapshot(in context: Context, completion: @escaping (WallpaperEntry) -> Void) {
completion(WallpaperEntry(date: Date(), imageName: todaysImageName()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WallpaperEntry>) -> Void) {
var entries: [WallpaperEntry] = []
let calendar = Calendar.current
let now = Date()
// 未来 12 本を 2 時間刻みでまとめて生成し、束ねて返す
for hourOffset in stride(from: 0, to: 24, by: 2) {
guard let entryDate = calendar.date(byAdding: .hour, value: hourOffset, to: now) else { continue }
let name = imageName(for: entryDate)
entries.append(WallpaperEntry(date: entryDate, imageName: name))
}
// 翌日の頭で一度だけ次のタイムラインを取りに来る
let tomorrow = calendar.date(byAdding: .day, value: 1, to: now)!
completion(Timeline(entries: entries, policy: .after(tomorrow)))
}
}
この実装の肝は、for ループで未来のエントリをまとめて作っている点と、policy を .after(tomorrow) にして「次に取りに来るのは明日でよい」と宣言している点です。これで1日あたりの問い合わせ回数が劇的に減り、夕方の沈黙が消えました。
リロードポリシーの選び分け
Timeline の policy には3つの選択肢があり、どのウィジェットでどれを選ぶかで挙動が変わります。
.atEnd は、提出したエントリを使い切ったら次を取りに来る方式です。予定表を短く刻むと頻繁にリロードがかかるため、エントリを十分先まで用意できる場合にのみ向きます。
.after(date) は、指定した時刻になったら次を取りに来る方式です。日付が変わる瞬間や、決まった時刻にだけ更新したい壁紙・カレンダー系には、これが最も予算効率の良い選択でした。
.never は、アプリ側から明示的にリロードを要求するまで一切更新しない方式です。ユーザー操作やプッシュ受信のタイミングでのみ更新したい場合に使います。私の運用では、設定変更時に WidgetCenter.shared.reloadTimelines(ofKind:) を明示的に呼ぶ前提で .never を選ぶ場面もあります。
判断の目安を、私の運用での優先順位で並べると次のとおりです。
- 定時更新の壁紙・天気・カレンダー系は
.after を選びます。
- イベント駆動の通知系・進捗系は
.never とアプリからの明示リロードを組み合わせます。
- エントリを十分先まで確定できる場合に限って
.atEnd を使います。
エントリ密度と画像の重さ
予算と並んでもう一つ効くのが、1エントリあたりの描画コストです。壁紙ウィジェットは画像を扱うため、エントリに大きな画像をそのまま持たせると、メモリ上限に当たってウィジェットが空白になります。
私が採った対策は、エントリには画像そのものではなく画像の識別子だけを持たせ、ビュー側で表示サイズに合わせてダウンサンプリングしてから描く、という分離でした。フルサイズの画像をウィジェットに渡すと、システムが課す数十 MB 級のメモリ制限を超えやすくなります。本番運用では、この空白化が再現性の低いクラッシュとして報告に紛れ込み、原因の特定に時間を取られがちな落とし穴でした。
func downsampledImage(named name: String, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
guard let url = imageURL(for: name) else { return nil }
let maxPixel = max(pointSize.width, pointSize.height) * scale
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel,
]
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
let thumb = CGImageSourceCreateThumbnailAtIndex(src, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: thumb)
}
ウィジェットのサイズは限られているため、表示に必要なピクセル数はアプリ本体よりはるかに小さくなります。kCGImageSourceThumbnailMaxPixelSize で実寸に絞ってから描くだけで、空白ウィジェットの発生がほぼなくなりました。
relevance で表示の優先度を伝える
スマートスタックに複数のウィジェットを積んでいる場合、TimelineEntryRelevance を使うと「この時刻のこのエントリは重要だ」とシステムに伝えられます。score を高くしたエントリは、スタックの自動ローテーションで前面に出やすくなります。
壁紙アプリの場合、毎日特定の時刻に「今日の特別な一枚」を出すような演出で、その時刻のエントリにだけ高い score を与えると、ユーザーの目に触れる確率が上がりました。常に高くするのではなく、見せたい瞬間にだけメリハリをつけるのが効果的です。
個人開発で踏んだ2つの症状
ひとつ目は、冒頭の「夕方に止まる」です。原因は .atEnd と短いエントリの組み合わせによる予算枯渇でした。エントリをまとめて返し .after に変えることで解決しました。
ふたつ目は、「アプリ内で壁紙を変えてもウィジェットが古いまま」という症状です。これはアプリ側からリロードを通知していなかったのが原因で、設定保存の直後に WidgetCenter.shared.reloadTimelines(ofKind: "WallpaperWidget") を呼ぶようにして直りました。ウィジェットはアプリの状態変化を自動では拾わないため、変えた側から明示的に知らせる必要があります。
どう設計を決めるか
私の結論は、まず「このウィジェットは定時更新か、イベント駆動か」を最初に決め、それに合わせて .after か .never を選ぶ、という順序です。エントリは可能な限りまとめて返し、画像は識別子で持って描画時に縮小する。この3点を守るだけで、予算切れによる沈黙はほぼ起きなくなります。私は App Store に出している6本すべてでこの設計に統一しており、切り替え後は予算切れによる更新停止の報告が体感で90%以上減りました。同じ症状に悩むなら、まずこの3点の見直しを推奨します。
ウィジェットは派手な機能ではありませんが、毎日ホーム画面で目に入る分、止まっていると印象を大きく損ないます。Rork Max が生成する Swift アプリにウィジェットを足すときも、同じ設計判断がそのまま効きます。同じ「夕方に止まる」で悩んだ方の助けになれば幸いです。