夕方になるとホーム画面のウィジェットが昨日の一枚のまま止まる、という問題を片づけた翌週のことでした。次に届いた要望は「アプリを一度も開かない日でも、朝には次の壁紙に変わっていてほしい」というものです。
素直に考えれば、更新の主語はウィジェット側にあります。表示しているのはウィジェットなのですから、次の画像を用意する仕事も同じターゲットに持たせればよい——そう考えて、Widget Extension のコードに BGAppRefreshTaskRequest を組み込みました。
ビルドは通りました。submit も true を返しました。それなのに、翌朝の画面は前日のままでした。原因にたどり着くまで半日かかっております。
submit は通り、register だけが拒まれます
先に結論をお伝えします。BGTaskScheduler は、拡張から依頼を出すことは許していますが、依頼を受け取る側になることは許していません。SDK のヘッダには registerForTaskWithIdentifier:usingQueue:launchHandler: に対して拡張では使えない旨の注釈が付いており、その理由が「Only the host application may register launch handlers」と一行だけ添えられています(BackgroundTasks.framework の BGTaskScheduler.h )。
同じヘッダの説明文には、拡張から依頼を出せる場合があること、そして背景作業のために起動されるのはホストアプリだけであることが、続けて書かれています。つまり submit の成功は「受け付けました」以上の意味を持ちません。
呼び出し Widget Extension から 実際に起きること
submitTaskRequest:error:呼べます 依頼は受理され、戻り値は true になります
registerForTaskWithIdentifier:...呼べません 拡張では利用できない指定が付いています
起動されるターゲット — ホストアプリのみで、拡張は起動されません
私が書いてしまったのは、次のような拡張側のコードでした。動きはします。ただし、受け手がどこにもいません。
// WallpaperWidget/RefreshScheduler.swift(この設計は行き止まりです)
import BackgroundTasks
enum RefreshScheduler {
static func scheduleFromWidget () {
let request = BGAppRefreshTaskRequest ( identifier : "net.dolice.wallpaper.dailyRefresh" )
request.earliestBeginDate = Calendar.current. date ( byAdding : .hour, value : 6 , to : Date ())
do {
try BGTaskScheduler.shared. submit (request)
// ここは通ります。true が返り、ログにも「予約できた」と出ます。
// しかし launchHandler は拡張に登録できないため、誰も起きてきません。
NSLog ( "[widget] submitted dailyRefresh" )
} catch {
NSLog ( "[widget] submit failed: \( error ) " )
}
}
}
submit が例外を投げないので、ログには成功しか残りません。落ちないぶん、気づくのが遅れます。私はこの種の無言の失敗をいちばん警戒しております。
本番運用でいちばん怖いのは、対処のしようがある不具合ではなく、こうして誰にも報告されない取りこぼしなのかもしれません。クラッシュしていれば計測に乗りますが、これは乗りません。
Info.plist の台帳は親アプリ側にしかありません
もう一つ、拡張に更新を持たせようとすると必ずぶつかる壁があります。識別子の許可リストです。
BGTaskScheduler の NotPermitted(Code=3)は、UIBackgroundModes に適切なモードが含まれていないか、識別子が BGTaskSchedulerPermittedIdentifiers に無い場合に返ります。ここで大事なのは、その配列が置かれるべき場所がアプリの Info.plist だという点です(BGTaskScheduler.Error.Code.unavailable の説明 と併せて読むと、Code=1 と Code=3 の役割分担がはっきりします)。
拡張の Info.plist に書いても効きません
Xcode のターゲット一覧で Widget Extension を選び、そちらの Info.plist に BGTaskSchedulerPermittedIdentifiers を足したくなります。私も一度そうしました。エラーは何も出ません。何も起きないだけです。
Rork や Expo の構成では app.config 側に書きます
Rork が出力するのは React Native と Expo の構成ですので、Info.plist を手で編集するのではなく、設定ファイル側に寄せます。
// app.config.js — 親アプリの Info.plist に入る場所へ書きます
export default {
expo: {
ios: {
bundleIdentifier: "net.dolice.wallpaper" ,
infoPlist: {
UIBackgroundModes: [ "fetch" , "processing" ],
BGTaskSchedulerPermittedIdentifiers: [
"net.dolice.wallpaper.dailyRefresh"
]
}
}
}
} ;
識別子は 1 か所で定義して両方から参照します
親アプリと拡張で識別子の文字列がずれると、register と submit が別々のタスクを指すことになり、これも無言で外れます。私は共有ターゲットに定数を置いて、両方からそれを参照する形にしております。
// Shared/RefreshTaskID.swift — 親アプリと Widget Extension の両方に含めます
public enum RefreshTaskID {
public static let daily = "net.dolice.wallpaper.dailyRefresh"
public static let appGroup = "group.net.dolice.wallpaper"
public static let widgetKind = "DailyWallpaperWidget"
}
更新の主語を親アプリへ戻します
行き止まりを認めたあと、設計を書き直しました。更新の主語は親アプリです。ウィジェットは、書き込まれた結果を読んで描くだけの存在に戻します。
親アプリ側は三つの仕事を順番に引き受けます。起動時に register すること、目覚めたときに次の画像を決めて App Group へ書くこと、そして書き終えてから WidgetCenter に再読み込みを頼むことです。
// AppDelegate.swift(ホストアプリ側。register はここでしか行えません)
import BackgroundTasks
import WidgetKit
func application ( _ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any ] ? ) -> Bool {
// 起動完了より前に登録する必要があります。遅れると登録自体が失敗します。
BGTaskScheduler.shared. register ( forTaskWithIdentifier : RefreshTaskID.daily,
using : nil ) { task in
handleDailyRefresh (task as! BGAppRefreshTask)
}
scheduleNextRefresh ()
return true
}
func scheduleNextRefresh () {
let request = BGAppRefreshTaskRequest ( identifier : RefreshTaskID.daily)
// 直近すぎる時刻を指定すると、OS 側で丸められて意図とずれます。
request.earliestBeginDate = Calendar.current. date ( byAdding : .hour, value : 6 , to : Date ())
do {
try BGTaskScheduler.shared. submit (request)
} catch {
NSLog ( "[app] submit failed: \( error ) " )
}
}
func handleDailyRefresh ( _ task: BGAppRefreshTask) {
// 次の実行を先に予約します。ここを忘れると 1 回きりで終わります。
scheduleNextRefresh ()
let work = Task {
let picked = await DailyWallpaperPicker. pickNext ()
SharedStore. write (picked) // App Group のコンテナへ保存します
WidgetCenter.shared. reloadTimelines ( ofKind : RefreshTaskID.widgetKind)
task. setTaskCompleted ( success : true )
}
task.expirationHandler = {
work. cancel ()
task. setTaskCompleted ( success : false )
}
}
書き込みと再読み込みの順番は入れ替えられません。先に reloadTimelines を呼ぶと、ウィジェットは古い値を読み直すだけになります。私はここを一度取り違えて、直したはずの症状が再発しました。
拡張から予約したい場面が残る場合でも、submit だけを拡張に置き、register とハンドラは親アプリに置く形になります。依頼者と受取人を分けて考えると、構成が一気に見通しやすくなります。
どちらを採用する場合でも、私は最初の一歩として親アプリ単独の構成から始めることを推奨します。拡張からの予約を足すのは、親アプリ側で確実に起きることを確認してからで遅くありません。切り分けの手数がそのぶん減ります。
予算は 2 つあり、混同すると診断を誤ります
もう一段深いところに、私が長く取り違えていた点があります。ここで効いてくる予算は 1 つではありません。
一つ目は BGAppRefreshTask が実際に起きられるかどうかで、これは利用頻度や電池残量を見た OS の裁量です。二つ目は WidgetKit がタイムラインを描き直せる回数で、WidgetCenter の解説 にあるとおり、再読み込みの要求は出せても即座の実行が約束されるものではありません。
この二つを一つだと思っていると、診断が必ずずれます。私が踏んだ落とし穴もここでした。回避の仕方は単純で、症状をどちらの層のものか先に決めてから手を動かすことです。実際に見分ける手がかりは次のとおりです。
観測できた症状 疑うべき予算 最初に見る場所
App Group の保存時刻が更新されていません BGAppRefreshTask 側 getPendingTaskRequests と最終実行の記録
保存時刻は新しいのに表示が古いままです WidgetKit の reload 側 reloadTimelines の呼び出し位置とタイムラインのエントリ設計
朝は追従し、夕方から止まります WidgetKit の reload 側 1 日ぶんのエントリを束ねて返しているか
夕方から止まる症状の直し方は、以前にウィジェットが古いまま固まるときの App Group とタイムライン設計 へ書き残しております。今回の話は、その手前にある「そもそも誰が起きるのか」の層です。
submit が true を返しても、そのまま信じないでください
無言の失敗を防ぐために、私は予約と実行を自己申告させるようにしました。予約が残っているかは getPendingTaskRequests で確認できます。実行されたかどうかは、ハンドラの中から App Group へ時刻を書いて、開発用の画面に出しています。
// 開発ビルドの設定画面に置いている自己申告ビューです
import BackgroundTasks
import SwiftUI
struct RefreshDiagnosticsView : View {
@State private var pending: [ String ] = []
@State private var lastRun: Date ? = SharedStore. lastRefreshAt ()
var body: some View {
List {
Section ( "予約されている依頼" ) {
if pending. isEmpty {
Text ( "なし(submit が通っていても、ここが空なら消えています)" )
} else {
ForEach (pending, id : \. self ) { Text ( $0 ) }
}
}
Section ( "最後に走った時刻" ) {
Text (lastRun. map { $0 . formatted () } ?? "記録なし" )
}
}
. task {
let requests = await BGTaskScheduler.shared. pendingTaskRequests ()
pending = requests. map (\.identifier)
}
}
}
見るべき順番は決めてあります。App Group の最終実行時刻が空なら親アプリが起きていないので、識別子と Info.plist を疑います。時刻は新しいのに表示が古いなら、起きてはいるので reloadTimelines の側を疑います。この 2 段階に分けるだけで、切り分けの時間がはっきり短くなりました。
Code=1 が返ってくる場合の原因の並べ方は、BGTaskScheduler.submit が Error Code=1 で失敗するときの切り分け手順 にまとめてあります。本稿の内容と合わせると、拡張まわりの取りこぼしまで含めて追えるはずです。
AppIntent から即時に更新したくなったときの限界
iOS 17 以降のウィジェットはボタンを置けますので、その場で次の画像に切り替えたくなります。ところが AppIntent の perform() の中で再読み込みを頼んでも、反映が数分遅れる、実機でだけ再現しない、という報告が開発者フォーラムの相談 に複数あがっています。私の手元でも、シミュレータでは即座に切り替わるのに実機では待たされる、という差が出ました。
そこで、押した瞬間は App Group に書いた値をそのまま描き、システムの再読み込みは後追いで構わないという設計にしています。見た目の即応は自分の書き込みで作り、正しさの担保はシステムの再読み込みに預けます。 この分担にしてから、実機とシミュレータの差で悩む場面が減りました。
6 本のアプリに展開して残した運用ルール
壁紙アプリ 6 本へ同じ構成を広げるとき、判断がぶれないように短い決まりを置きました。背景更新の登録は親アプリだけの仕事にすること、識別子は共有ターゲットの定数からしか読まないこと、そして書き込みが終わってから再読み込みを頼むこと。この三つです。
もし今、拡張のターゲットに BGTaskScheduler を書きかけているのでしたら、まず register の行を親アプリ側へ移してみてください。私の場合、この一行の引っ越しだけで、翌朝の画面がようやく変わりました。
最後までお読みくださり、ありがとうございました。