通知権限を確認したら、React Native の AppState が無限に往復した
React Native と Expo で作った Android アプリが、稼働中だけちらつき続けた。StatusBar も、時刻と関係のないボタンも描き直されているように見える。一方、iOS では起きない。時計の更新頻度と購読範囲を見直しても止まらず、原因は読み取り API のsnapshot() に紛れ込んだ通知権限の確認だった。
Android だけ、画面が止まらなくなった
最初に疑ったのは時計だった。画面には稼働時間や週の残り時間があり、React 側では現在時刻を定期的に更新していた。親で時刻を購読すると、その値を直接使わない StatusBar やボタンまで同じ再描画に巻き込まれる。Android でだけ目立つこともあり、描画負荷の問題に見えた。
ただ、症状は時刻表示の更新では説明できない頻度で続いていた。コンポーネントに一時的な描画計測を入れると、時計を表示する子だけでなく、その親から繰り返し描き直されていることが分かった。見るべきものは「どの子をmemo するか」ではなく、「親の state を誰が更新し続けているか」に変わった。
時計の購読範囲を狭めても止まらない
先に入れた修正では、1秒ごとだった React 側の時計を分の切り替わりに合わせ、購読者を画面全体から必要な画面へ移した。日付変更の監視も独立したコンポーネントへ分け、時刻に依存しないボタンは安定した callback とmemo で切り離した。これは再描画の責務としては正しいし、不要な更新も減らせる。
しかし、ちらつきは残った。時計が原因なら、分単位に落とした時点で連続した描画は止まるはずだ。止まらない以上、時計の最適化は症状を弱める修正でしかなく、state を更新している別の入口がある。ここで memo を増やして押さえ込む案は捨てた。親が更新を続ける限り、新しい子を足すたびに同じ問題を踏むからだ。
再描画が多いとき、最初に memo を足すと原因が見えにくくなる。子の描画回数より先に、親の state を更新したイベントの経路を追う必要がある。
snapshot() が AppState を自分で動かしていた
状態の起点を追うと、フォアグラウンド復帰時の処理に着いた。日付が変わった可能性があるため、AppState が active になったらsnapshot() で保存済み状態を読み直していた。問題は、その読み取りが純粋な読み取りではなかったことだ。
// 修正前: 読み取りにも通知同期を付けていた
const api = {
...core,
snapshot: resync(core.snapshot),
};
AppState.addEventListener("change", (state) => {
if (state === "active") void run(api.snapshot);
});
// resync の中から到達する
await Notifications.requestPermissionsAsync();ストアには「処理後にローカル通知を予約し直す」ラッパーがあり、打刻や設定変更だけでなくsnapshot() にも付いていた。通知同期は Expo のrequestPermissionsAsync() を呼ぶ。Android では権限確認が別の GrantPermissionsActivity を開くため、アプリは一度active を離れ、戻る。その復帰イベントが再びsnapshot() を呼んでいた。
iOS でちらつかなかったのは、React の差ではなく、この Android 固有の Activity 遷移がループの接続点だったからだ。各処理は単独では不自然に見えない。「復帰したら読み直す」「状態を読んだら通知を同期する」「必要なら権限を確認する」が、つながったときだけ自己循環になる。
読み取り、副作用、busy を3つに分ける
修正の中心は snapshot() を軽くすることではなく、契約を変えることだった。通常のsnapshot() は保存済み状態を読むだけに戻す。通知同期を伴うinitialize() は起動時にだけ使い、打刻や設定変更の command はこれまでどおり通知を予約し直す。
// 通常の snapshot は純粋な読み取りのまま残す
const initialize = resync(core.snapshot);
const api = {
...core,
initialize,
punch: resync(core.punch),
undoPunch: resync(core.undoPunch),
updateSettings: resync(core.updateSettings),
};UI 側でも、処理の直列化と busy を分けた。enqueue() は snapshot の取得順を守って state を反映する共通経路、run() はボタンを押した command の経路、refresh() は画面復帰や日付変更の passive refresh とした。復帰時の読み直しでボタンの busy 表示を切り替えないため、正常な復帰処理も静的な UI を巻き込まない。
const enqueue = useCallback((action: () => Promise<Snapshot>) => {
// 結果の反映と直列化だけを担当する。busy は触らない
const task = queue.current.then(async () => {
const next = await action();
setSnapshot(next);
return true;
});
queue.current = task;
return task;
}, []);
const run = useCallback(async (action) => {
pending.current += 1;
setBusy(true);
const ok = await enqueue(action);
pending.current -= 1;
if (pending.current === 0) setBusy(false);
return ok;
}, [enqueue]);
// 画面復帰はユーザー操作ではない
const refresh = useCallback(() => void enqueue(api.snapshot), [enqueue]);通知側も、いきなり権限を要求しない。まず現在の権限を読み、許可済みか、もう質問できない状態ならその結果を使う。質問できて未許可のときだけ request を出す。権限 Activity を開く操作が、必要な場面に限定された。
const currentPermission = await Notifications.getPermissionsAsync();
const permission =
currentPermission.status === "granted" || !currentPermission.canAskAgain
? currentPermission
: await Notifications.requestPermissionsAsync();再発防止では、通常の api.snapshot()が通知同期を一度も呼ばないことと、起動用の api.initialize()が一度だけ呼ぶことをテストにした。修正後はこの2件を含む83件のテストが成功した。画面のちらつきを直接テストするのではなく、ループを再接続してしまう依存関係をテストしている。
まとめ
今回の不具合は「React の再描画が多い」のではなく、読み取り API がライフサイクルイベントを発生させ、そのイベントが同じ読み取りを呼ぶ循環だった。描画最適化だけでは、循環の出口は閉じない。
snapshot のような名前の API は、呼び出し側が何度でも安全に読めると期待する。端末権限、通知予約、Activity 遷移のような副作用を混ぜるなら、起動や command として別の入口にするべきだ。同じように、ユーザー操作の busy と passive refresh も分ける。状態の種類ではなく、何がその更新を始めたかで経路を分けると、React の描画境界も自然に決まる。