ホーム画面にウィジェットを置いて、一分おきに表示が変わるはずのタイムラインを予約し、画面を眺めて待ちます。iOS では一分後にきちんと切り替わるのに、Android のエミュレータでは「Initial」のまま、五分たっても動きません。
コードを疑い、ビルドキャッシュを疑い、端末の省電力設定を疑って、一時間ほど手が止まる——そういう夜が起こりうる不具合です。いま GitHub の Expo リポジトリで報告されている内容を、切り分けの順番に並べ直しました。私自身は Android 実機でこの不具合を再現しておらず、以下は Issue の本文と、Expo 側の回答、そして手元で動かした確認スクリプトに基づいています。
最初にお伝えしたいのは、これは設定ミスではなく Android では仕様として動かない という点です。
何が起きているのか
報告では、SDK 58 の開発ビルドで次のような状況でした。
- ウィジェット追加直後は「Initial」の表示
- アプリ側から
updateTimeline()を呼び、一分間隔の五つのエントリを予約 - 待っても表示は変わらないまま
Expo 側の調査コメントによると、原因は二つあります。updateTimeline() は Android では冒頭で何もせずに戻るため、予約したエントリは捨てられます。そして config plugin が生成する appwidget-provider の XML には android:updatePeriodMillis="0" が固定で書かれており、変更する設定項目もありません。
つまり Android のウィジェットは、アプリが自分で updateSnapshot() を呼ばない限り、自分では表示を変えられません。Expo のメンテナーは「Android にはタイムライン機能がない、不足している機能であって回帰ではない」という趣旨で回答し、ドキュメントに「Android では updateTimeline は効果がない」という注記を足す変更が別途提案されています(記事執筆時点で未マージのため、取り込まれた後の文言は公式ドキュメントでご確認ください)。
切り分けの順番
手が止まる前に、次の順で確認していただければ数分で済みます。
- 同じコードを iOS で動かして、切り替わるかを見ます。iOS で動けば、コードの論理は正しい可能性が高いです
Platform.OS === "android"の分岐にupdateTimelineを置いていないかを見ます。置いていれば、その呼び出しは今のところ空振りです- 生成された XML の
updatePeriodMillisを読みます。0なら、システムによる定期更新はありません - アプリを開いた状態で
updateSnapshot()を呼び、表示が変わるかを見ます。変わるなら、描画の経路は健全です
三番を飛ばして省電力設定ばかり疑うと、遠回りになりがちです。いま思えば、私も似た順番で疑いかけていた気がします。端末の設定を一つずつ変える前に、生成物の XML を読むほうが、はるかに早く済みます。
生成された XML を機械的に確かめる
npx expo prebuild -p android のあとで、android ディレクトリ以下の appwidget-provider を拾い、更新間隔を一覧にする小さなスクリプトを書きました。モックの XML では想定どおりに「定期更新なし」と出力されることを確かめています。実際の生成物に対する動作は、手元のプロジェクトでご確認ください。
// android/ 以下の appwidget-provider を探し、updatePeriodMillis を一覧にします。
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
function* walk(dir) {
for (const name of readdirSync(dir)) {
if (name === "build" || name === ".gradle") continue;
const p = join(dir, name);
if (statSync(p).isDirectory()) yield* walk(p);
else if (p.endsWith(".xml")) yield p;
}
}
let stuck = 0;
for (const file of walk(process.argv[2] ?? "android")) {
const xml = readFileSync(file, "utf8");
if (!xml.includes("<appwidget-provider")) continue;
const m = xml.match(/android:updatePeriodMillis="(\d+)"/);
const ms = m ? Number(m[1]) : null;
// 0 は「システムによる定期更新なし」。30 分未満の値は Android 側で切り上げられます。
const note = ms === 0 ? "定期更新なし" : ms === null ? "指定なし" : `${ms / 60000} 分間隔`;
if (ms === 0 || ms === null) stuck++;
console.log(`${file}: ${note}`);
}
console.log(stuck ? `定期更新のないウィジェット: ${stuck} 件` : "すべて定期更新あり");出力が 定期更新なし なら、そのウィジェットは Android では「アプリが動いている間だけ」更新される、という前提で設計する必要があります。
アプリ側でできること
updateSnapshot() はアプリが生きているあいだなら使えます。Expo の回答でも、ウィジェット上のボタンの押下ハンドラから新しい props を返す方法が挙がっていました。次のような分岐で、iOS はタイムライン、Android はスナップショットと使い分けられます。
import { Platform } from "react-native";
import { MyWidget } from "./widgets/MyWidget";
type Entry = { date: Date; props: { label: string } };
export function scheduleLabels(entries: Entry[]) {
if (Platform.OS === "ios") {
// iOS: システムが時刻に合わせて切り替える
MyWidget.updateTimeline(entries);
return;
}
// Android: 予約はできない。いまの表示だけを即時に差し替える
const now = Date.now();
const current = [...entries].reverse().find((e) => e.date.getTime() <= now);
if (current) MyWidget.updateSnapshot(current.props);
}Platform.OS で分けるのは、Android 側の updateTimeline が将来効くようになったときに、呼び出しを残したまま挙動が変わるのを避けるためです。コメントに「いまは何も起こらない」と書いておくと、半年後の自分が読み返したときに迷いません。
ただし限界もあります。報告者が指摘しているとおり、バックグラウンドのタスクから updateSnapshot() を呼ぶ方法は、JavaScript の実行環境を毎回起こす必要があり、省電力の制限下では時刻どおりに動く保証がありません。「表示が数分ずれてもよいもの」には使えますが、「毎朝七時に切り替える」のような約束には向きません。
落とし穴
updatePeriodMillisを後から XML だけ手で書き換えても、次のprebuildで上書きされます。変えるなら config plugin 側ですが、現状その設定項目はありません- Android は
updatePeriodMillisを 30 分未満にしても、30 分に切り上げます。仮に設定できても、分単位の切り替えには使えません - iOS で通ったから Android も通る、という前提を、ウィジェットでは置かないほうが安全です
設計の線引き
私は個人開発で壁紙アプリを複数運用していて、日替わりで表示を変えるウィジェットはいちばん作りたくなる機能の一つです。だからこそ、最初の設計の段階で次のように割り切っています。
時刻に約束のある表示は iOS に、アプリを開いたときに追いつく表示は Android に。 両方に同じ約束をしないことが、いちばん手戻りの少ない線引きでした。
Android でも時刻どおりに切り替えたい場合は、ネイティブの AlarmManager や WorkManager を自分で足す道が残っています。ただし expo-widgets の生成物を自分で拡張することになり、SDK を上げるたびに追従の手間が増えます。そこまでの価値があるかは、そのウィジェットが無くなったときに困る人がいるかどうかで決めるのが、現実的だと思います。
次の一歩
まず prebuild のあとに上のスクリプトを一度実行し、自分のウィジェットが Android で「定期更新なし」になっていないかを見てみてください。見つかったら、その表示を iOS 限定の約束にするか、アプリ起動時に追いつく設計に直すか、どちらかを今日のうちに決めておくのが良いと思います。
iOS 側の更新予算の考え方は WidgetKit のタイムラインと更新予算の考え方 に、同じ expo-widgets の Android ビルド失敗については 版の固定と除外の判断 にまとめています。SDK 58 に上げる前の点検は 削除された API の監査スクリプト もご覧ください。
この原則だけは、機能が増えても守るようにしています。