癒し系アプリの「呼吸ガイド」を作っていたとき、画面のグラデーションがゆっくり伸縮するアニメーションを入れました。見た目は気に入っていたのですが、ある日ベータ版を一日持ち歩いた帰りに端末がほんのり温かく、バッテリーの内訳を見ると、このアプリが「バックグラウンドのアクティビティ」で妙に上位に来ていたのです。
不思議だったのは、フレーム落ち(ジャンク)としては一切表に出ていなかった点でした。スクロールはなめらかで、体感の不具合はゼロ。けれど呼吸アニメーションは、設定画面に進んでも、ホームに戻ってロックしても、UI スレッドの上で律儀に伸縮を続けていました。見えないところで回り続ける無音のループ——これがバッテリーを削る正体でした。
個人開発でいくつもの壁紙・癒し系アプリを運用してきて学んだのは、アニメーションの「重さ」よりも「いつ止まるか」を設計するほうが、電池持ちには効くということです。ここでは Rork が出力する Expo アプリを題材に、画面外とバックグラウンドのループを確実に止めるライフサイクル設計を共有します。
なぜ画面外でもアニメーションは止まらないのか
直感に反しますが、別画面に進んでもアニメーションは勝手に止まりません。理由は2つあります。
ひとつは、React Navigation のスタックは前の画面をアンマウントせずマウントしたまま残すからです。詳細画面に進んでも、その下のリスト画面は生きていて、そこで withRepeat のループが走っていればそのまま回り続けます。タブナビゲーターはさらに顕著で、タブを切り替えても各タブの画面は基本的にマウントされ続けます。
もうひとつは、Reanimated の withRepeat が UI スレッド(ワークレット)上で自走するからです。JavaScript スレッドのレンダリングが止まっても、UI スレッドのアニメーションは独立して進みます。これは滑らかさのための設計ですが、裏を返すと「JS から見えない場所で走り続ける」ことを意味します。
そして肝心なのは、これがジャンクとして検出されないことです。フレーム落ちは「描画が間に合わない」状態ですが、ここでの問題は「描画が間に合っているのに、見えない画面のために描き続けている」状態です。プロファイラのヒッチ表示には出ず、Energy Log(消費電力)にだけ静かに積み上がります。
アニメ手段ごとの「画面外で止まるか」
止め方を設計する前に、自分のアプリが使っているアニメーション手段が、画面外で自動的に止まるのか・止まらないのかを把握しておくと判断が速くなります。
| 手段 | 画面外で自動停止 | 止め方 |
| Reanimated withRepeat | 止まらない | cancelAnimation を明示的に呼ぶ |
| Animated(RN標準)の loop | 止まらない | ref を保持して .stop() |
| Lottie(lottie-react-native) | 止まらない | ref の pause() / autoPlay を外す |
| expo-video / expo-av | 止まらない | player の pause() |
| CSS的な無限ループ(WebView) | 状況次第 | WebView の停止か非表示 |
| FlatList セル内のループ | 仮想化で部分的に停止 | 可視判定で個別制御 |
要点は、**ほとんどの手段は「自分で止めない限り止まらない」**ということです。FlatList の仮想化だけは画面外セルをアンマウントしてくれますが、ヘッダーやパララックス層は仮想化の外なので例外です。
フォーカスとアプリ状態を1つのフックに束ねる
止めるべき条件は2つあります。「この画面が前面にあるか(フォーカス)」と「アプリ自体が前面にあるか(AppState)」です。両方が真のときだけアニメーションを走らせ、どちらかが偽になったら止めます。
React Navigation の useIsFocused と React Native の AppState を1つのフックに束ねます。
import { useEffect, useState } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { useIsFocused } from '@react-navigation/native';
/**
* 画面が前面にあり、かつアプリがアクティブなときだけ true を返す。
* これを使ってアニメーションの再生・停止を一元化する。
*/
export function useShouldAnimate(): boolean {
const isFocused = useIsFocused();
const [appActive, setAppActive] = useState(
AppState.currentState === 'active'
);
useEffect(() => {
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
setAppActive(state === 'active');
});
return () => sub.remove();
}, []);
return isFocused && appActive;
}
AppState の 'active' は前面で操作可能な状態、'background' はホームに戻ったりロックした状態、'inactive' はその過渡(着信やコントロールセンターを引き出した瞬間など)です。過渡で一瞬止めても見た目に害はないので、ここでは 'active' 以外をすべて停止扱いにしています。
Reanimated のループを確実に止める
useShouldAnimate の真偽に応じて、withRepeat を開始し、偽になったら cancelAnimation で止めます。ここで実装の落とし穴がひとつあります。
import { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
cancelAnimation,
Easing,
} from 'react-native-reanimated';
export function BreathingGlow() {
const shouldAnimate = useShouldAnimate();
const progress = useSharedValue(0);
useEffect(() => {
if (shouldAnimate) {
progress.value = withRepeat(
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.ease) }),
-1, // 無限
true // 往復
);
} else {
cancelAnimation(progress);
// 落とし穴: cancelAnimation は「その瞬間の値」で止まる。
// 中途半端なスケールで固まらないよう、静止状態へ戻す。
progress.value = withTiming(0, { duration: 300 });
}
return () => cancelAnimation(progress);
}, [shouldAnimate]);
const style = useAnimatedStyle(() => ({
transform: [{ scale: 1 + progress.value * 0.06 }],
opacity: 0.7 + progress.value * 0.3,
}));
return <Animated.View style={[styles.glow, style]} />;
}
cancelAnimation は「停止」ではなく「今の値で凍結」です。呼吸アニメが伸び切る途中で画面を離れると、戻ってきたとき中途半端に大きいまま固まっていることがあります。だから停止時は cancelAnimation のあとに、静止状態(ここでは 0)へ短い withTiming で戻すのが安全です。クリーンアップ関数の cancelAnimation も忘れないでください。これがないと、画面が素早く再フォーカスされたときにループが二重起動し、かえって電池を食います。
useFocusEffect でクリーンアップを確実にする
useIsFocused の真偽を useEffect で見る代わりに、React Navigation の useFocusEffect を使うと、フォーカスを失った瞬間にクリーンアップが走ることが保証され、二重起動を防ぎやすくなります。Lottie や動画のように ref 経由で制御するものはこちらが向いています。
import { useRef, useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import LottieView from 'lottie-react-native';
export function AmbientLottie() {
const ref = useRef<LottieView>(null);
useFocusEffect(
useCallback(() => {
ref.current?.play();
return () => ref.current?.pause(); // フォーカスを失ったら必ず止まる
}, [])
);
// autoPlay は付けない。再生制御は useFocusEffect に一本化する。
return <LottieView ref={ref} source={require('../assets/ambient.json')} loop />;
}
ポイントは autoPlay を付けないことです。autoPlay と useFocusEffect の両方で再生をいじると、初回マウント時に二重に走って制御が読みにくくなります。再生の起点は一箇所に集約します。
動画も同じ考え方で、expo-video の player を pause() するだけです。バックグラウンドで音だけ鳴らしたい設計でない限り、フォーカスを失ったら止めるのが電池にもデータにも優しい選択です。
リスト内の常時ループは可視判定で間引く
壁紙アプリのギャラリーのように、セルごとに小さなループ(きらめきやシマー)を持たせたい場合、FlatList の仮想化だけでは画面ギリギリのセルが回り続けます。onViewableItemsChanged で実際に見えているセルだけ再生する設計にすると、同時に走るループ数を一定に抑えられます。
const visibleIds = useSharedValue<string[]>([]);
const onViewableItemsChanged = useRef(({ viewableItems }) => {
visibleIds.value = viewableItems.map((v) => v.item.id);
}).current;
const viewabilityConfig = useRef({
itemVisiblePercentThreshold: 50, // 半分以上見えたら可視扱い
}).current;
// 各セル側で visibleIds に自分の id が含まれるかでループの on/off を切り替える
これで「画面に見えている数枚だけがアニメーションし、スクロールアウトしたら止まる」状態になります。私の壁紙アプリでは、この間引きを入れる前後でスクロール中の CPU 使用率が体感で大きく下がりました。
効いているかをどう測るか
設計を入れたら、思い込みでなく数字で確かめます。手軽な順に3つあります。
ひとつ目は、停止ロジックに開発用のログを仕込み、画面遷移やロックのたびに shouldAnimate が false になってアニメが止まっているかをコンソールで確認することです。まずは「止まるべきときに止まっているか」を論理で押さえます。
ふたつ目は、Xcode の Debug Navigator で、画面を離れた状態のときに CPU 使用率がアイドルまで落ちるかを見ることです。落ちなければ、どこかでループが生き残っています。
みっつ目は、Instruments の Energy Log や、実機を充電器から外して一定時間放置し、設定アプリのバッテリー内訳で自分のアプリの「バックグラウンド」割合を見ることです。修正前後で比較すると、無音のループがどれだけ削れたかが見えます。
導入の順序
一度に全部を変えると切り分けが難しくなります。本番運用に乗せる前に、次の順で段階的に入れることを推奨します。
useShouldAnimate フックを1つ用意する
- 最も常時回っているアニメーション(全画面の背景ループや呼吸アニメ)から1つだけ繋ぎ込み、画面を離れて CPU がアイドルへ落ちることを確認する
- Lottie・動画を
useFocusEffect 方式へ寄せる
- リストの可視判定を足し、最後に AdMob のバナーやネイティブ広告がアニメーションと一緒に裏で動き続けていないかも併せて確認する
各段階で一度ずつ実機で電池の内訳を見ておくと、どの修正が効いたかが本番リリース後にも記録として残ります。私自身、この順番で潰していくと「どこで電池が漏れていたか」が毎回はっきり切り分けられました。
仕上がりの判断はシンプルで、「アプリを開いていて目に見えているものだけがアニメーションし、それ以外はすべて静止している」状態になれば設計は正しく回っています。見えないループを残さないこと——それが、軽さの正体だと考えています。
最後までお読みいただきありがとうございました。同じように電池持ちに悩んでいる方の手がかりになれば嬉しいです。