iOS アプリに Gemini API を組み込んで「いい感じだ」と感じていたある日、ホーム画面のウィジェットにも AI のひとこと表示を追加したくなりました。最初の実装は直感的でした。TimelineProvider.getTimeline() の中で直接 URLSession を呼び出し、Gemini にリクエストを送るだけです。
シミュレーターでは完璧に動きました。実機テストでも問題ありませんでした。しかし App Store にリリースしてから数日後、ウィジェットが静かに止まり始めました。スケルトン表示のまま固まり、AI のメッセージが二度と更新されなくなったのです。
この経験から学んだことを整理します。
なぜ WidgetKit と API 呼び出しは相性が悪いのか
WidgetKit の Widget Extension は、メインアプリとは別プロセスで動作します。iOS はバッテリーと CPU を節約するため、ウィジェットの実行を厳しく管理しています。具体的には3つの制限が重なっています。
実行時間の制限 : Apple のドキュメントには "Widget extensions receive a limited amount of CPU and memory" と明記されており、長時間の非同期処理は途中で強制終了される可能性があります
メモリ上限 : Widget Extension に割り当てられるメモリはおよそ 30MB 前後と、メインアプリよりはるかに小さい領域です。レスポンス JSON のパースや画像の展開で超過すると、その場で kill されます
リロード予算 : タイムラインの再読み込み回数そのものに日次の予算があり、超過分は黙って無視されます
開発中に気づきにくい理由もあります。Xcode からデバッグ実行しているとき、システムはウィジェット Extension に対して通常より寛容な実行時間を与えています。シミュレーターやデバッグビルドでは問題が表面化せず、リリース後に実際のシステム制御下に置かれて初めて失敗します。
Gemini API の応答時間は、私の計測では軽量プロンプトで概ね 1〜3 秒、混雑時や長い入力では 5 秒を超えることがあります。この待機時間が WidgetKit の実行制限に引っかかるのが根本原因です。
Before:動かないパターン
まず、やってしまいがちな実装を確認します。
// ❌ シミュレーターでは動くが、実機リリースで静かに止まる実装
struct AIMessageProvider : TimelineProvider {
func getTimeline ( in context: Context, completion : @escaping (Timeline<Entry>) -> Void ) {
let url = URL ( string : "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=YOUR_API_KEY" ) !
var request = URLRequest ( url : url)
request.httpMethod = "POST"
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
let body: [ String : Any ] = [
"contents" : [[ "parts" : [[ "text" : "今日のひとこと(30文字以内)" ]]]]
]
request.httpBody = try? JSONSerialization. data ( withJSONObject : body)
// この completion が呼ばれる前に Extension が終了することがある
URLSession.shared. dataTask ( with : request) { data, _ , _ in
let message = parseResponse ( data : data) ?? "取得中..."
let entry = AIEntry ( date : Date (), message : message)
let timeline = Timeline ( entries : [entry], policy : . after ( Date (). addingTimeInterval ( 3600 )))
completion (timeline)
}. resume ()
}
}
問題は completion が呼ばれないことではなく、呼ばれる前に Extension がシステムに終了させられることです。iOS は「返答待ちの Widget Extension」をバックグラウンドで生かし続けてはくれません。
After:App Groups キャッシュ経由のパターン
正しい設計原則はシンプルです。ウィジェットは API を叩きません。キャッシュから読むだけ。
API 呼び出しとキャッシュ保存の責任をメインアプリに持たせ、ウィジェットは App Groups の共有領域からデータを読み取るだけにします。
// ✅ メインアプリ側:Gemini API を呼び出してキャッシュに保存する
import WidgetKit
class GeminiWidgetCache {
static let shared = GeminiWidgetCache ()
// Xcode の Capabilities で設定した App Groups 識別子
private let suiteName = "group.com.example.myapp.gemini"
private let messageKey = "widget_ai_message"
private let timestampKey = "widget_ai_timestamp"
private let cacheTTL: TimeInterval = 3600 // 1時間有効
/// キャッシュが古ければ Gemini API を叩いて更新する
func refreshIfNeeded () async {
let defaults = UserDefaults ( suiteName : suiteName)
let lastFetch = defaults ? . double ( forKey : timestampKey) ?? 0
// キャッシュが有効なら API 呼び出しをスキップ(コスト節約)
if Date ().timeIntervalSince1970 - lastFetch < cacheTTL {
return
}
do {
let message = try await fetchFromGemini ()
defaults ? . set (message, forKey : messageKey)
defaults ? . set ( Date ().timeIntervalSince1970, forKey : timestampKey)
// ウィジェットに「新しいデータがある」と通知する(成功時に1回だけ)
WidgetCenter.shared. reloadAllTimelines ()
} catch {
// エラーは静かに記録するだけ。次回の更新機会に再試行する
print ( "[GeminiWidget] Fetch error: \( error. localizedDescription ) " )
}
}
private func fetchFromGemini () async throws -> String {
let url = URL ( string : "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=YOUR_API_KEY" ) !
var request = URLRequest ( url : url)
request.httpMethod = "POST"
request. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request.timeoutInterval = 10 // デフォルト60秒は長すぎる。後述のBGTask枠に収める
let body: [ String : Any ] = [
"contents" : [[ "parts" : [[ "text" : "アプリ開発者へのひとこと(40文字以内)" ]]]],
"generationConfig" : [
"maxOutputTokens" : 80 , // トークン上限でコストをコントロール
"temperature" : 0.9
]
]
request.httpBody = try JSONSerialization. data ( withJSONObject : body)
let (data, response) = try await URLSession.shared. data ( for : request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError (.badServerResponse)
}
if let json = try? JSONSerialization. jsonObject ( with : data) as? [ String : Any ],
let candidates = json[ "candidates" ] as? [[ String : Any ]],
let content = candidates. first ? [ "content" ] as? [ String : Any ],
let parts = content[ "parts" ] as? [[ String : Any ]],
let text = parts. first ? [ "text" ] as? String {
return text. trimmingCharacters ( in : .whitespacesAndNewlines)
}
throw URLError (.cannotParseResponse)
}
/// ウィジェット側から呼ぶ読み取り専用メソッド(API 呼び出しなし)
func read () -> (message: String , isStale: Bool ) {
let defaults = UserDefaults ( suiteName : suiteName)
let message = defaults ? . string ( forKey : messageKey) ?? "アプリを開いてメッセージを取得してください"
let lastFetch = defaults ? . double ( forKey : timestampKey) ?? 0
let isStale = Date ().timeIntervalSince1970 - lastFetch > cacheTTL
return (message, isStale)
}
}
// ✅ ウィジェット側:キャッシュから読むだけ(ネットワーク待ちゼロ)
struct AIMessageProvider : TimelineProvider {
func getTimeline ( in context: Context, completion : @escaping (Timeline<Entry>) -> Void ) {
let cached = GeminiWidgetCache.shared. read ()
let entry = AIEntry (
date : Date (),
message : cached.message,
isStale : cached.isStale // 古い場合は薄い表示など UI で区別する
)
// 次の Timeline 更新は1時間後
let nextRefresh = Calendar.current. date ( byAdding : .hour, value : 1 , to : Date ()) !
let timeline = Timeline ( entries : [entry], policy : . after (nextRefresh))
completion (timeline)
}
}
メインアプリでは SceneDelegate.sceneDidBecomeActive や AppDelegate.applicationDidBecomeActive から await GeminiWidgetCache.shared.refreshIfNeeded() を呼び出すだけです。
なお、モデル ID は現行の gemini-3.5-flash を指定しています。旧 gemini-2.5-flash からの移行はモデル ID の差し替えだけで動きます。ウィジェットのひとこと表示のような軽量用途では、より安価な Flash 系で十分というのが私の判断です。
アプリを開かない日にウィジェットが古くなる問題
ここまでの実装でリリースした後、レビューに「数日前のメッセージのまま変わらない」という指摘をいただきました。ログを確認すると原因は明快でした。キャッシュの更新契機が「アプリを開いたとき」しかないため、アプリを開かないユーザーのウィジェットは古くなる一方だったのです。
ウィジェットだけ置いてアプリ本体をほとんど開かない。考えてみれば、それはウィジェットが気に入られている証拠でもあります。この鮮度問題は BGTaskScheduler で補えます。
// ✅ メインアプリ側:BGAppRefreshTask でキャッシュを定期更新する
import SwiftUI
import BackgroundTasks
@main
struct MyApp : App {
@Environment (\.scenePhase) private var scenePhase
init () {
// register は起動のたびに必ず実行する(submit より先・1回だけ)
BGTaskScheduler.shared. register (
forTaskWithIdentifier : "com.example.myapp.gemini.refresh" ,
using : nil
) { task in
MyApp. handleWidgetRefresh ( task : task as! BGAppRefreshTask)
}
}
var body: some Scene {
WindowGroup {
ContentView ()
}
. onChange ( of : scenePhase) { _ , newPhase in
if newPhase == .background {
MyApp. scheduleWidgetRefresh ()
}
}
}
/// 次のバックグラウンド更新を予約する
static func scheduleWidgetRefresh () {
let request = BGAppRefreshTaskRequest ( identifier : "com.example.myapp.gemini.refresh" )
request.earliestBeginDate = Date ( timeIntervalSinceNow : 4 * 3600 ) // 4時間後以降
do {
try BGTaskScheduler.shared. submit (request)
} catch {
// 同一IDの予約が既にある場合などは失敗するが、致命的ではない
print ( "[BGTask] submit failed: \( error ) " )
}
}
static func handleWidgetRefresh ( task : BGAppRefreshTask) {
let work = Task {
await GeminiWidgetCache.shared. refreshIfNeeded ()
// 次回分を予約してから完了を報告する
scheduleWidgetRefresh ()
task. setTaskCompleted ( success : true )
}
// 実行時間切れに備えて expiration handler を必ず設定する
task.expirationHandler = {
work. cancel ()
task. setTaskCompleted ( success : false )
}
}
}
設定はコードだけでは完結しません。次の2点を忘れると register が例外を投げたり、タスクが一度も起動しなかったりします。
Signing & Capabilities でメインアプリに Background Modes を追加し、Background fetch にチェックを入れる
Info.plist に BGTaskSchedulerPermittedIdentifiers 配列を追加し、com.example.myapp.gemini.refresh を登録する
押さえておきたいのは、earliestBeginDate が「その時刻になったら実行される」という約束ではないことです。実際の起動タイミングは iOS がユーザーの利用パターンや充電状況から判断します。私の手元のデバイスでは1日 1〜3 回程度、低電力モード中はほぼ実行されませんでした。「確実な定期実行」ではなく「アプリを開かない日の保険」と捉えるのが実態に合っています。
動作確認には Apple 公式の手順として、Xcode のデバッガで一時停止し、次の LLDB コマンドで起動をシミュレートできます。
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.myapp.gemini.refresh"]
バックグラウンドでの URLSession の振る舞いには別の落とし穴もあります。ストリーミング応答を扱うアプリでは iOS の URLSession で Gemini API のストリーミングが途切れる原因と対処 も合わせて確認していただければと思います。
コスト面での重要な違い
ウィジェットが直接 API を叩く設計と、キャッシュ経由の設計では API 呼び出し回数に大きな差が生じます。
WidgetKit は iOS のシステムが判断したタイミングで getTimeline() を呼び出します。これは条件によっては 1 日に数十回に達することもあります。
直接呼び出し方式(1 ユーザーあたり): 約 30 回/日 × 30 日 = 月 900 リクエスト
App Groups キャッシュ方式(アプリ起動時 + BGTask 更新): 平均 3 回/日 × 30 日 = 月 90 リクエスト
およそ 10 分の 1 です。1 回あたりのトークン数が小さくても、無料枠の RPD(1日あたりリクエスト数)やレート制限を圧迫するのは回数そのものです。ユーザー数が 1,000 人を超えると、月 90 万リクエストと 9 万リクエストの差になり、クォータ設計がまったく変わってきます。個人開発のプロダクトで予算の上限を守る仕組みづくりは Gemini API を組み込んだ個人プロダクトのコスト暴走を防ぐ でより詳しく解説しています。
もうひとつ、運用して初めて実感した利点があります。先日 Gemini API で大規模な障害(error 1076/1099 が広範に発生したもの)があった際、キャッシュ方式のウィジェットは何事もなかったかのように直前のメッセージを表示し続けました。API の可用性とウィジェットの表示品質が切り離されている。この安心感は、直接呼び出し方式では得られないものです。
運用で気づいた点 — 公式ドキュメントの行間
私自身、リリースから数週間にわたり実機のログを眺めて分かったことをまとめます。数値は私の環境での実測であり、デバイスや利用状況で変わる前提でお読みください。
getTimeline() の呼び出し頻度は想像以上に揺れます 。同じ実装でも1日 15 回程度の日もあれば 40 回を超える日もありました。スマートスタックに入れているユーザーのデバイスでは回転表示のたびに呼ばれるため、多くなる傾向があります
reloadAllTimelines() には予算があります 。更新のたびに複数回呼んだり、失敗時にも呼んだりすると、肝心なときに無視されます。「API 取得成功時に 1 回だけ」に絞ってから、更新の取りこぼしが目に見えて減りました
placeholder(in:) と getSnapshot() ではネットワークに触れない こと。特にスナップショットはウィジェットギャラリーのプレビューでも呼ばれるため、context.isPreview のときは固定の見本文言を即時返却するのが安全です
timeoutInterval は明示的に短くする こと。デフォルトの 60 秒のままだと、BGAppRefreshTask に与えられる実行枠(実測でおよそ 30 秒弱)を1回の通信待ちで使い切ってしまいます。私は 10 秒に設定し、失敗は次回更新に回す方針にしています
stale 表示は信頼につながります 。キャッシュが古いときに文字色を落とし最終更新時刻を小さく添えるようにしたところ、「ウィジェットが動かない」という問い合わせが目に見えて減りました。動いていないのではなく古いだけ、と伝わることが大切なのだと思います
Xcode での App Groups 設定手順
App Groups を使うには Xcode の Capabilities 設定が必要です。
メインアプリターゲットの設定 :
プロジェクトファイルを開き、メインアプリターゲットを選択
Signing & Capabilities タブ → + Capability → App Groups を追加
+ ボタンで group.com.yourcompany.appname.gemini 形式の識別子を作成
Widget Extension ターゲットの設定 :
Widget Extension ターゲットを選択し、同じ手順で App Groups を追加
既存の Group ID を選択 (新規作成せず、メインアプリと同じ識別子を指定)
自動署名(Automatically manage signing)を使っている場合は Xcode が Provisioning Profile を自動更新します。手動署名の場合は Apple Developer Portal で Profiles の再生成が必要です。
リリース前チェックリスト
私が実機リリース前に毎回確認している項目です。上から順に潰していけば、この記事で扱った失敗はほぼ防げます。
App Groups の識別子がメインアプリと Widget Extension の両ターゲットで完全に一致している
API キーやその取得ロジックが Widget Extension 側のターゲットに含まれていない(メインアプリのみ)
placeholder(in:) / getSnapshot() がネットワークに触れず即時返却している
機内モードで初回起動したとき、ウィジェットにデフォルト文言が表示される
キャッシュ TTL と timeline policy の間隔が整合している(policy が TTL より極端に短くない)
reloadAllTimelines() の呼び出しが「取得成功時に 1 回」に絞られている
BGTaskSchedulerPermittedIdentifiers と register(forTaskWithIdentifier:) の識別子が一致している
TestFlight ビルド(Release 構成)で一晩置いて、ウィジェットが自動更新されるのを確認した
maxOutputTokens の上限とプロンプトの固定化で、1回あたりの出力が暴れないようにしている
低電力モードで BGTask が動かなくても、stale 表示によって体験が破綻しない
8 番だけは時間がかかりますが、省略しないことをおすすめします。デバッグビルドの寛容な実行環境では、この記事で扱った問題のほとんどが再現しないためです。
Firebase AI Logic を使った安全な実装について
上記のコード例では API キーをクライアントコードに直接記述していますが、本番アプリではこの方法は推奨されません。キーが逆コンパイルで漏洩するリスクがあるためです。
Firebase AI Logic SDK を使うと、Firebase Authentication と連携した安全な API 呼び出しが可能になります。ウィジェット向けにはキャッシュパターンをそのまま活用しつつ、メインアプリ内の API 呼び出し部分を Firebase AI Logic に置き換えるアプローチが現実的です。本番品質の iOS 実装全般は Gemini API × SwiftUI 本番アプリ実装の勘所 にまとめています。
全体を振り返って
WidgetKit で Gemini API を安定動作させるための原則は「ウィジェットは読み取り専用、API 呼び出しはメインアプリの責任 」です。そこに BGTaskScheduler を一枚重ねることで、アプリを開かない日の鮮度まで面倒を見られるようになります。
まず Xcode で App Groups を設定し、UserDefaults(suiteName:) を使ってメインアプリからウィジェットへの一方向データ共有を試してみてください。キャッシュの読み書きが確認できたら Timeline 実装、最後に BGTask の順で組み込むと、切り分けが楽になります。ウィジェットの次の一歩として、アプリ本体側で Function Calling を使った機能拡張に進む場合は Gemini の Function Calling を iOS/Android アプリに統合する設計パターン が参考になるはずです。
同じように個人開発でウィジェットと向き合っている方の参考になれば幸いです。