App Store で配信している壁紙アプリに「毎日ホーム画面の壁紙ウィジェットが切り替わる」機能を足したときの話です。シミュレータでは気持ちよく動いていました。実機に入れて数日放置したところ、ウィジェットの絵がある朝から固まったまま、何時間経っても変わらなくなりました。アプリを開くと直る。閉じてしばらくするとまた止まる。
最初はキャッシュか日付計算のバグを疑いました。けれど原因はコードの誤りではなく、WidgetKit の「リロード予算」という、画面のどこにも出てこない上限に毎日ぶつかっていたことでした。個人開発でウィジェットを真面目に運用すると必ず通る場所だと思うので、実測した数字とあわせて設計の組み直し方を残しておきます。
ウィジェットは「生きて」いません
最初に外しておきたい誤解があります。ウィジェットは小さなアプリが常時動いているわけではありません。表示されているのは、あらかじめ用意した「タイムライン」を OS が好きなタイミングで描画した静止画です。
タイムラインは TimelineEntry の配列で、各エントリは「この時刻に、この内容を見せてほしい」という宣言です。OS は getTimeline(in:completion:) を呼んでこの配列を受け取り、各エントリの date が来たら対応するビューに差し替えます。差し替えのたびに自分のコードが走るわけではありません。配列を渡し終えた瞬間に、その先の見た目は OS の手に渡ります。
struct WallpaperProvider : TimelineProvider {
func placeholder ( in context: Context) -> WallpaperEntry {
WallpaperEntry ( date : Date (), assetID : "placeholder" )
}
func getSnapshot ( in context: Context, completion : @escaping (WallpaperEntry) -> Void ) {
completion ( WallpaperEntry ( date : Date (), assetID : currentAssetID ()))
}
func getTimeline ( in context: Context, completion : @escaping (Timeline<WallpaperEntry>) -> Void ) {
// ここで配列を一気に組む。呼ばれる回数こそが予算の消費。
let entries = buildDailyEntries ( from : Date ())
completion ( Timeline ( entries : entries, policy : .atEnd))
}
}
つまり「毎日壁紙を変えたい」を「毎日 getTimeline を呼ばせて新しい画像を取りに行く」と読み替えてしまうと、設計を一歩間違えます。私が最初に書いたのがまさにそれでした。
リロード予算という見えない上限
getTimeline が再び呼ばれる契機は大きく三つあります。タイムラインの reloadPolicy が満了したとき、アプリ側から WidgetCenter.shared.reloadTimelines(ofKind:) を呼んだとき、そしてシステムが独自判断で更新したときです。
このうち背景での再読み込みには、ウィジェット単位の1日あたりの予算が割り当てられています。Apple は正確な数値を公開していませんが、頻繁に表示されるウィジェットでおおむね1日40〜70回程度という範囲が、開発者コミュニティでも私の実測でも繰り返し見えてきます。予算は1日かけて少しずつ回復します。
最初の実装では、アプリがフォアグラウンドに来るたびと、1時間ごとのタイマーで reloadTimelines を呼んでいました。さらにタイムラインは「今の1エントリ+1時間後に .atEnd」という短い作りでした。結果、背景リロードだけで1日120回前後を要求していて、午前のうちに予算を使い切り、午後以降は OS がリロードを黙って後回しにしていました。これが「ある朝から止まる」の正体でした。
予算を使い切ったかどうかを知らせる API はありません。エラーも出ません。ただ静かに、絵が古いままになります。落とし穴としてたちが悪いのは、シミュレータと開発中の実機では予算が緩く、しばらく使ってからでないと顕在化しない点です。
設計の転換 — 1日分をまとめて積む
直し方の核心は「変えたい回数だけリロードする」をやめて「1回のリロードで丸一日分のエントリを宣言する」に移ることでした。壁紙を6時・12時・18時・22時に切り替えたいなら、その4枚分のエントリを最初の getTimeline で全部渡してしまいます。切り替え自体は OS がエントリの date を見て勝手にやってくれるので、私のコードは1日に数回しか動きません。
func buildDailyEntries ( from now: Date) -> [WallpaperEntry] {
let calendar = Calendar.current
let switchHours = [ 6 , 12 , 18 , 22 ]
let playlist = WallpaperStore.shared. todaysPlaylist () // App Group から取得済みのローカル配列
var entries: [WallpaperEntry] = []
for (index, hour) in switchHours. enumerated () {
guard let date = calendar. date (
bySettingHour : hour, minute : 0 , second : 0 , of : now
) else { continue }
// 過去の時刻は飛ばし、未来分だけ積む
if date < now && index < switchHours. count - 1 { continue }
let asset = playlist[index % playlist. count ]
entries. append ( WallpaperEntry ( date : max (date, now), assetID : asset.id))
}
return entries
}
ポイントは、エントリの中身に「重い画像そのもの」ではなく assetID のような軽い参照だけを持たせることです。実画像はビューの描画時に App Group のローカルファイルから読みます。エントリを軽くしておくと、24時間分を積んでもタイムラインのシリアライズが破綻しません。
この設計に変えたあと、背景リロードは実測で1日3回前後まで落ちました。.atEnd で最後のエントリ(22時)を過ぎた頃に翌日分を取りに行く、それだけです。予算には常に余裕があり、午後に固まる現象は消えました。
TimelineReloadPolicy をどう選ぶか
reloadPolicy の選択は、予算の使い方そのものです。三つの違いを実運用の観点で整理します。
ポリシー 再読み込みの契機 壁紙ウィジェットでの向き
.atEnd 最後のエントリの日付を過ぎたとき 本命。1日分を積んで末尾で翌日分を取りに行く
.after(date) 指定した日時を過ぎたとき 更新を翌朝など特定時刻に寄せたいとき
.never アプリからの明示的なリロードのみ 新パック購入時など、変化が起きた瞬間だけ更新したいとき
私は通常運用を .atEnd に置き、ユーザーが新しい壁紙パックを取得した瞬間だけ、アプリ側から1回 reloadTimelines(ofKind:) を撃つ形に落ち着きました。.never 一本にしなかったのは、何らかの理由でアプリが長期間開かれないと永久に古くなるためです。.atEnd を土台に置けば、最低でも1日サイクルで自己回復します。
避けたいのは、フォアグラウンド復帰のたびに無条件でリロードを撃つことです。予算を最も雑に溶かす書き方で、私が最初にハマった本番運用での典型的な失敗です。リロードは「中身が本当に変わったとき」に限定するのが原則だと考えています。
ウィジェット拡張のメモリ上限と画像のダウンサンプリング
リロード回数を抑えても、もう一つの壁が残っていました。ウィジェット拡張は本体アプリよりずっと厳しいメモリ上限で動きます。機種によりますが、おおむね30MB前後で、これを超えると拡張は描画前に強制終了され、ウィジェットは白紙やプレースホルダのまま固まります。
壁紙アプリの素材はフル解像度です。4000×2400程度の画像を UIImage(contentsOfFile:) で素直に読むと、デコード後はおよそ38MBに膨らみ、ほぼ確実に上限を超えます。シミュレータのメモリは潤沢なので、ここでもまた実機でしか露見しません。
解決策は、表示に必要なサイズへ読み込み時点でダウンサンプリングすることです。UIImage を作ってから縮小するのでは手遅れで、ImageIO でデコード自体を間引きます。
import ImageIO
import UIKit
func downsampledImage ( at url: URL, pointSize : CGSize, scale : CGFloat) -> UIImage ? {
let options = [kCGImageSourceShouldCache : false ] as CFDictionary
guard let source = CGImageSourceCreateWithURL (url as CFURL, options) else { return nil }
let maxPixel = Int ( max (pointSize.width, pointSize.height) * scale)
let thumbOptions = [
kCGImageSourceCreateThumbnailFromImageAlways : true ,
kCGImageSourceShouldCacheImmediately : true ,
kCGImageSourceCreateThumbnailWithTransform : true ,
kCGImageSourceThumbnailMaxPixelSize : maxPixel
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex (source, 0 , thumbOptions) else { return nil }
return UIImage ( cgImage : cgImage)
}
ウィジェットの表示領域はホーム画面でもせいぜい数百ポイント四方です。kCGImageSourceThumbnailMaxPixelSize をその実寸に合わせると、デコード後のメモリは実測で0.4MB前後まで下がりました。約38MBから0.4MB、およそ99%の削減です。これで強制終了は止まりました。kCGImageSourceShouldCacheImmediately を true にして、メモリのピークを描画タイミングに寄せるのも効きました。
画像供給はアプリ側で — App Group とバックグラウンド更新
ウィジェット拡張の中で新しい壁紙をダウンロードしたくなりますが、これは避けるのが堅実です。getTimeline に与えられる実行時間はごく短く、ネットワークの遅延を抱え込むと、配列を返す前に拡張が時間切れになります。
私の構成では、画像の取得と差し替えはすべて本体アプリの責務にしました。アプリは BGAppRefreshTask で定期的に翌日分のプレイリストを準備し、画像を App Group の共有コンテナへローカル保存します。ウィジェットはそのローカルファイルを読むだけにします。
import BackgroundTasks
func scheduleWallpaperRefresh () {
let request = BGAppRefreshTaskRequest ( identifier : "design.dolice.wallpaper.refresh" )
request.earliestBeginDate = Date ( timeIntervalSinceNow : 6 * 60 * 60 )
try? BGTaskScheduler.shared. submit (request)
}
func handleRefresh ( _ task: BGAppRefreshTask) {
scheduleWallpaperRefresh () // 次回分を必ず先に予約
let work = Task {
await WallpaperStore.shared. stageTomorrowPlaylist () // 共有コンテナへ保存
WidgetCenter.shared. reloadTimelines ( ofKind : "WallpaperWidget" )
}
task.expirationHandler = { work. cancel () }
}
BGAppRefreshTask の起動も OS の裁量なので、確実に毎朝走るわけではありません。だからこそウィジェット側を .atEnd で自己回復するように作り、バックグラウンド更新は「あれば早く新しくなる」程度の補助に留めています。両者のどちらかが滑っても、表示が完全に止まらない二重化です。本番運用では、この「片方が失敗しても破綻しない」前提がいちばん効きました。
実測してから判断する — リロード回数を可視化する
ここまでの数字は推測ではなく、計測してから決めました。OS の予算残量は読めませんが、自分の getTimeline が呼ばれた回数なら数えられます。App Group の UserDefaults にカウンタを積み、日付ごとに集計しました。
func recordTimelineReload () {
let defaults = UserDefaults ( suiteName : "group.design.dolice.wallpaper" ) !
let key = "reloadCount_" + ISO8601DateFormatter. dayString ( from : Date ())
let count = defaults. integer ( forKey : key) + 1
defaults. set (count, forKey : key)
}
この計測コードと、各日の集計をアプリのデバッグ画面に出す処理は、Antigravity に「getTimeline の呼び出し回数を App Group 共有領域に日付別で積んで、設定画面の隠しセクションに一覧表示したい」と意図を渡して書いてもらいました。私自身、その出力を数日眺めて、フォアグラウンド契機のリロードを外す判断を下しました。実装そのものより、判断材料を可視化することのほうが今回は価値がありました。
集計はこう変わりました。
項目 組み直し前 組み直し後
背景リロード回数(実測・1日平均) 約120回 約3回
午後に表示が固まる頻度 ほぼ毎日 観測されず
拡張のデコード後メモリ 約38MB(強制終了) 約0.4MB
数値はあくまで手元の機種と素材での実測で、機種やウィジェットサイズで変わります。けれど「リロードは数えられる」「メモリは桁で減らせる」という二点は、どの環境でも判断の土台になるはずです。
私の運用での落としどころ
最終的な指針はとても素朴なものに収束しました。リロードは予算という有限資源だと考え、1回のタイムラインで宣言できる未来はすべて宣言しておく。画像は読み込み時にダウンサンプリングして、拡張のメモリを最初から低く保つ。取得はアプリ側に寄せ、ウィジェットはローカルを読むだけにする。そして、変えたことを必ず計測してから次の判断をする。
もし同じように毎日更新するウィジェットを作るなら、まずやることは一つだけお勧めします。getTimeline の冒頭にカウンタを仕込み、数日間ただ眺めることです。自分が1日に何回リロードを要求しているのかを知ると、設計の話が一気に具体的になります。シミュレータの快適さに安心せず、実機で数日放置して数字を取る。そこから先の判断は、自然と見えてきます。
同じ場所でつまずいている方の参考になれば幸いです。