Flutterアプリのライフサイクルでつまずきやすいポイント

Flutter
この記事は約22分で読めます。

この記事の最終更新日: 2026年5月24日

Flutterでアプリを作っていると、画面の実装やAPI通信だけでなく、アプリのライフサイクルを意識しなければいけない場面があります。

たとえば、次のような処理です。

アプリがバックグラウンドに入ったら一時保存する
アプリに戻ってきたらデータを再取得する
画面復帰時にログイン状態を確認する
通知から戻ってきたときに特定画面を開く
位置情報やタイマーを一時停止・再開する

一見すると簡単そうですが、実際にはかなりつまずきやすいです。

特に初心者は、

disposeが呼ばれると思っていた
バックグラウンドに入ったら必ずpausedになると思っていた
resumedで毎回APIを叩いたら二重実行された
通知や権限ダイアログでもライフサイクルが変わって混乱した

という問題にハマりがちです。

この記事では、Flutterアプリのライフサイクルでつまずきやすいポイントを、初心者にもわかりやすく整理します。


Flutterのライフサイクルとは?

Flutterのライフサイクルとは、アプリが現在どの状態にあるかを表すものです。

たとえば、アプリが画面に表示されて操作できる状態なのか、バックグラウンドに回った状態なのか、ユーザーから見えない状態なのか、といった状態を扱います。

Flutterでは、アプリの状態を AppLifecycleState で表します。公式APIドキュメントでは、AppLifecycleState はアプリが実行中に取りうる状態を表すenumであり、プラットフォームによって直接対応しない状態はFlutter側で補完されると説明されています。(api.flutter.dev)

代表的な状態は次の通りです。

detached
resumed
inactive
hidden
paused

この状態を見て、アプリ復帰時の処理や、バックグラウンド移行時の処理を書くことができます。


よく使うライフサイクル状態

Flutterの AppLifecycleState は、ざっくり次のように理解するとわかりやすいです。

状態ざっくりした意味
resumedアプリが表示され、ユーザー操作を受け付けている
inactive表示はされているが、一時的に操作できない状態
hiddenアプリのビューがユーザーから見えない状態
pausedアプリがバックグラウンドに回っている状態
detachedFlutterエンジンは存在するが、ビューから切り離されている状態

ただし、これらはiOSやAndroidのライフサイクル名と完全に1対1で対応しているわけではありません。Flutter公式ドキュメントでも、Flutterの状態名は歴史的理由や名前の衝突を避ける都合により、すべてのプラットフォームの状態名と完全一致するわけではないと説明されています。たとえばAndroidでは、Activityの onPause に対応してFlutterは inactive に入り、onStop に対応して paused に入るとされています。(api.flutter.dev)

つまり、Flutterのライフサイクルは「Flutterとしての共通表現」であり、iOSやAndroidのネイティブライフサイクルをそのまま置き換えたものではありません。


つまずき1:アプリのライフサイクルとWidgetのライフサイクルを混同する

最初につまずきやすいのが、アプリのライフサイクルWidgetのライフサイクルを混同することです。

Flutterには、StatefulWidgetinitStatebuilddispose などがあります。

一方で、アプリ全体には resumedinactivepaused などの状態があります。

この2つは別物です。

Widgetのライフサイクル:
initState
build
didUpdateWidget
dispose

アプリのライフサイクル:
resumed
inactive
hidden
paused
detached

初心者がよく勘違いするのは、「アプリがバックグラウンドに行ったら dispose が呼ばれる」と考えてしまうことです。

しかし、通常はアプリがバックグラウンドに入っただけでは、その画面の dispose が呼ばれるとは限りません。

dispose は、そのWidgetがWidgetツリーから取り除かれるときに呼ばれるものです。

バックグラウンド移行時の保存処理を dispose に書いてしまうと、想定通り動かない可能性があります。


つまずき2:paused だけ見ればよいと思ってしまう

バックグラウンド移行を検知したいときに、paused だけを見ればよいと思ってしまうケースがあります。

しかし、Flutterのライフサイクルには inactivehidden もあります。

特に hidden は注意が必要です。

Flutterの移行ガイドでは、AppLifecycleState.hidden は、すべてのアプリビューがユーザーから見えなくなった状態を表すために追加されたと説明されています。AndroidとiOSでは、inactive から paused へ移る途中、または paused から inactive へ戻る途中で一時的に hidden に入るとされています。(Flutter ドキュメント)

つまり、状態遷移は単純に、

resumed → paused
paused → resumed

だけではありません。

実際には、次のような遷移を意識する必要があります。

resumed
↓
inactive
↓
hidden
↓
paused

復帰時は逆方向に進む場合があります。

そのため、switch 文で AppLifecycleState を網羅している場合は、hidden を考慮しないとコンパイルエラーや処理漏れにつながることがあります。Flutterの移行ガイドでも、AppLifecycleState.hidden が追加されたため、すべてのcaseを扱うswitch文では新しいcaseを追加する必要があると説明されています。(Flutter ドキュメント)


つまずき3:すべてのライフサイクル通知を必ず受け取れると思ってしまう

Flutterのライフサイクル通知は便利ですが、すべての通知を必ず受け取れる前提で設計するのは危険です。

Flutter公式ドキュメントでは、アプリケーションはすべての可能なライフサイクル通知を常に受け取れるとは限らないと説明されています。(api.flutter.dev)

たとえば、次のような場面では、想定通りの通知を受け取れない可能性があります。

OSがアプリを強制終了した
端末のメモリ不足でプロセスが落ちた
ユーザーがアプリをスワイプ終了した
クラッシュした
電源が切れた
デバッグ環境と実機で挙動が違う

そのため、ライフサイクルイベントを「補助的なきっかけ」として使うのはよいですが、重要なデータ保存や復旧処理をライフサイクルイベントだけに依存するのは危険です。

重要なデータは、入力のたびに保存する、画面遷移時に保存する、アプリ起動時に状態を復元するなど、複数のタイミングで守る設計にした方が安全です。


つまずき4:resumed で毎回APIを叩いて二重実行する

アプリがフォアグラウンドに戻ったときに、データを再取得したい場面があります。

そのため、resumed のタイミングでAPIを叩く実装はよくあります。

しかし、何も考えずに resumed のたびにAPIを実行すると、次のような問題が起きます。

同じAPIが何度も呼ばれる
画面復帰のたびにローディングが出る
権限ダイアログから戻っただけで再取得される
通知から戻ったときに複数回処理される
通信中に再度同じ通信が走る

resumed は「アプリが操作可能な状態に戻った」という便利なタイミングですが、「必ず1回だけ呼ばれる」と考えるべきではありません。

再取得処理を書くなら、最低限次のような制御を入れた方がよいです。

bool _isRefreshing = false;
DateTime? _lastRefreshAt;

Future<void> refreshOnResume() async {
  if (_isRefreshing) return;

  final now = DateTime.now();
  if (_lastRefreshAt != null &&
      now.difference(_lastRefreshAt!) < const Duration(seconds: 30)) {
    return;
  }

  _isRefreshing = true;

  try {
    // API再取得など
    _lastRefreshAt = now;
  } finally {
    _isRefreshing = false;
  }
}

「復帰したら毎回実行」ではなく、「必要なら実行」という設計にすると安定します。


つまずき5:権限ダイアログでもライフサイクルが変わることを忘れる

位置情報、通知、カメラ、写真などの権限ダイアログを表示すると、アプリのライフサイクル状態が変わることがあります。

たとえば、権限ダイアログ表示中に inactive になり、ダイアログから戻ったときに resumed になることがあります。

そのため、resumed に「アプリ復帰時の重要処理」を大量に書いていると、権限ダイアログを閉じただけで処理が走る場合があります。

位置情報権限を求める
↓
アプリがinactiveになる
↓
ユーザーが許可・拒否する
↓
resumedになる
↓
意図せず復帰処理が走る

権限リクエスト直後に resumed が来る可能性を考慮していないと、次のような不具合につながります。

権限確認処理が二重に走る
API再取得が不要に走る
画面遷移が勝手に発生する
ダイアログが連続表示される

対策としては、権限リクエスト中のフラグを持つ方法があります。

bool _requestingPermission = false;

Future<void> requestPermission() async {
  _requestingPermission = true;

  try {
    // 権限リクエスト処理
  } finally {
    _requestingPermission = false;
  }
}

void handleResumed() {
  if (_requestingPermission) {
    return;
  }

  // 通常の復帰処理
}

権限まわりとライフサイクルはセットで考える必要があります。


つまずき6:通知から戻ったときの処理をライフサイクルだけで扱おうとする

通知をタップしてアプリに戻ったとき、resumed が呼ばれることがあります。

しかし、「通知から戻った」という情報をライフサイクルだけで判断するのは難しいです。

なぜなら、resumed は通知タップ以外でも発生するからです。

ホーム画面からアプリに戻った
アプリ切り替えで戻った
権限ダイアログから戻った
電話やシステムUIから戻った
通知をタップして戻った

これらはすべて「アプリが前面に戻った」という意味では似ていますが、アプリ側でやりたい処理は違います。

通知から戻ったときに特定画面へ遷移したいなら、通知パッケージ側のコールバックや、通知ペイロードを使って判断するべきです。

ライフサイクルの resumed は、あくまで「アプリが前面に戻った」ことを知るためのものです。

「なぜ戻ってきたのか」までは、別の情報で判断する必要があります。


つまずき7:WidgetsBindingObserver の解除を忘れる

Flutterでライフサイクルを監視する古典的な方法として、WidgetsBindingObserver を使う方法があります。

公式APIドキュメントでは、WidgetsBindingObserver で通知を受け取るには WidgetsBinding.instance.addObserver を呼び、メモリリークを避けるために不要になったら removeObserver で解除する必要があると説明されています。(api.flutter.dev)

基本形は次のようになります。

class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        // アプリが前面に戻ったとき
        break;
      case AppLifecycleState.inactive:
        // 一時的に非アクティブ
        break;
      case AppLifecycleState.hidden:
        // 画面が見えない状態
        break;
      case AppLifecycleState.paused:
        // バックグラウンド
        break;
      case AppLifecycleState.detached:
        // ビューから切り離された状態
        break;
    }
  }
}

ここで removeObserver を忘れると、画面破棄後も通知を受け取ろうとして、予期しない挙動やメモリリークにつながる可能性があります。

特定の画面でだけライフサイクルを監視するなら、登録と解除を必ずセットで書きましょう。


つまずき8:AppLifecycleListener を知らない

Flutterでは、WidgetsBindingObserver だけでなく、AppLifecycleListener も使えます。

Flutter公式APIドキュメントでは、AppLifecycleListener はアプリのライフサイクル変化を監視するためのリスナーであり、onStateChange を定義することで状態変化を受け取れると説明されています。また、アプリ終了リクエストを扱う onExitRequested も用意されています。(api.flutter.dev)

たとえば、次のように使えます。

class _HomePageState extends State<HomePage> {
  late final AppLifecycleListener _listener;

  @override
  void initState() {
    super.initState();

    _listener = AppLifecycleListener(
      onStateChange: (state) {
        switch (state) {
          case AppLifecycleState.resumed:
            // 復帰時の処理
            break;
          case AppLifecycleState.paused:
            // バックグラウンド移行時の処理
            break;
          case AppLifecycleState.inactive:
          case AppLifecycleState.hidden:
          case AppLifecycleState.detached:
            break;
        }
      },
    );
  }

  @override
  void dispose() {
    _listener.dispose();
    super.dispose();
  }
}

WidgetsBindingObserver でも実装できますが、ライフサイクルイベントを扱う目的が明確な場合は、AppLifecycleListener の方が読みやすい場合があります。

既存コードでは WidgetsBindingObserver、新しく書くコードでは AppLifecycleListener というように、プロジェクトの方針に合わせて選ぶとよいでしょう。


つまずき9:バックグラウンド移行時に重い処理をしようとする

アプリがバックグラウンドに入るタイミングで、保存処理や同期処理をしたくなることがあります。

しかし、このタイミングで重い処理をするのは危険です。

たとえば、次のような処理です。

大量データのアップロード
長いAPI通信
大きなファイル保存
複雑なDB更新
位置情報の長時間取得

バックグラウンド移行時に必ず十分な処理時間がもらえるとは限りません。

途中で処理が止まる可能性もあります。

そのため、重要なデータを paused のタイミングだけで保存しようとするのは避けた方がよいです。

おすすめは、次のような設計です。

入力のたびに小さく保存する
画面遷移時に保存する
アプリ復帰時に未同期データを再送する
バックグラウンド移行時は補助的な保存だけにする

ライフサイクルイベントは、重い処理をまとめて実行する場所ではなく、状態変化を検知して最低限の処理を行う場所だと考える方が安全です。


つまずき10:タイマーやアニメーションを止め忘れる

アプリがバックグラウンドに入ったとき、タイマー、アニメーション、位置情報監視、音声処理などを止めるべき場面があります。

止め忘れると、次のような問題につながります。

バッテリー消費が増える
復帰時にタイマーがずれる
二重にタイマーが動く
不要なAPI通信が続く
アニメーション状態が壊れる

たとえば、画面上でカウントダウンタイマーを使っている場合、アプリがバックグラウンドに入っている間も同じように動くとは限りません。

復帰時に現在時刻との差分から再計算する設計にした方が安定します。

DateTime? _backgroundAt;

void onPaused() {
  _backgroundAt = DateTime.now();
}

void onResumed() {
  if (_backgroundAt == null) return;

  final elapsed = DateTime.now().difference(_backgroundAt!);
  // 経過時間をもとに状態を補正する
}

バックグラウンド中も処理が継続する前提ではなく、復帰時に整合性を取り直す設計が重要です。


つまずき11:iOSとAndroidで同じように動くと思ってしまう

Flutterはクロスプラットフォームですが、ライフサイクルの元になるOSの挙動はiOSとAndroidで異なります。

Flutter公式ドキュメントでも、AppLifecycleState は各プラットフォームで同じ状態機械を共有するために一部の状態を合成すると説明されています。つまり、Flutterが共通の抽象化を提供している一方で、元のOS挙動は完全には同じではありません。(api.flutter.dev)

そのため、次のような差が起きる可能性があります。

Androidではpausedになるタイミングが、iOSでは違って見える
iOSではinactiveを経由する場面がある
システムダイアログやアプリ切り替え時の挙動が違う
端末やOSバージョンで通知タイミングが変わる

特に次の機能を扱う場合は、iOS実機とAndroid実機の両方で確認した方がよいです。

通知
位置情報
バックグラウンド処理
権限ダイアログ
アプリ復帰時の再取得
タイマー
音声再生

Flutterのライフサイクルは便利ですが、OS差分を完全に消してくれるものではありません。


つまずき12:アプリ起動時と復帰時を混同する

アプリが初めて起動したときと、バックグラウンドから復帰したときは別物です。

しかし、どちらも「ユーザーがアプリを開いた」という意味では似ています。

そのため、同じ処理をしてしまいがちです。

初回起動:
初期化
ログイン状態確認
設定読み込み
必要ならチュートリアル表示

復帰時:
データ再取得
権限状態の再確認
画面状態の補正
通知・位置情報状態の確認

初回起動でやるべき処理と、復帰時にやるべき処理を分けないと、無駄な初期化や画面遷移が発生します。

たとえば、resumed のたびに初期化処理を走らせると、アプリ復帰時に画面がリセットされるような不具合につながります。

起動時処理は main や初期画面で行い、復帰時処理はライフサイクルで最小限に行うのがよいです。


実装例:AppLifecycleListenerで復帰時にデータを再取得する

復帰時にデータを再取得したい場合の例です。

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late final AppLifecycleListener _lifecycleListener;

  bool _isRefreshing = false;
  DateTime? _lastRefreshAt;

  @override
  void initState() {
    super.initState();

    _lifecycleListener = AppLifecycleListener(
      onStateChange: _onLifecycleChanged,
    );
  }

  void _onLifecycleChanged(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        _refreshIfNeeded();
        break;
      case AppLifecycleState.inactive:
      case AppLifecycleState.hidden:
      case AppLifecycleState.paused:
      case AppLifecycleState.detached:
        break;
    }
  }

  Future<void> _refreshIfNeeded() async {
    if (_isRefreshing) return;

    final now = DateTime.now();

    if (_lastRefreshAt != null &&
        now.difference(_lastRefreshAt!) < const Duration(seconds: 30)) {
      return;
    }

    _isRefreshing = true;

    try {
      // API再取得など
      await Future.delayed(const Duration(seconds: 1));
      _lastRefreshAt = now;
    } finally {
      _isRefreshing = false;
    }
  }

  @override
  void dispose() {
    _lifecycleListener.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text('Home'),
      ),
    );
  }
}

ポイントは、resumed のたびに無条件で処理しないことです。

二重実行や短時間の連続実行を防ぐだけで、かなり安定します。


実装例:WidgetsBindingObserverでライフサイクルを監視する

既存コードや古い記事では、WidgetsBindingObserver を使った例も多いです。

didChangeAppLifecycleState は、システムがアプリをバックグラウンドに置いたり、フォアグラウンドに戻したりしたときに呼ばれるメソッドです。公式APIドキュメントでも、AppLifecycleListener はこのメソッドに対する代替APIとして紹介されています。(api.flutter.dev)

class LifecycleObserverPage extends StatefulWidget {
  const LifecycleObserverPage({super.key});

  @override
  State<LifecycleObserverPage> createState() => _LifecycleObserverPageState();
}

class _LifecycleObserverPageState extends State<LifecycleObserverPage>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    debugPrint('Lifecycle state: $state');

    if (state == AppLifecycleState.resumed) {
      // 復帰時の処理
    }

    if (state == AppLifecycleState.paused) {
      // バックグラウンド移行時の処理
    }
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text('Lifecycle Observer'),
      ),
    );
  }
}

WidgetsBindingObserver を使う場合は、addObserverremoveObserver を必ずセットで書くことが重要です。


ライフサイクルで扱うべき処理・扱わない方がよい処理

ライフサイクルで扱うべき処理と、避けた方がよい処理を整理します。

処理ライフサイクルでの扱い
軽いデータ再取得適している
権限状態の再確認適している
タイマーの補正適している
画面状態の再同期適している
入力中データの最小保存場合によって適している
大量データアップロード避けた方がよい
長時間のAPI通信避けた方がよい
重いDB更新避けた方がよい
画面遷移の乱発避けた方がよい
通知タップ判定のすべてライフサイクルだけでは不十分

ライフサイクルイベントは「状態変化のきっかけ」です。

重い処理をまとめて実行する場所ではありません。


Flutterアプリのライフサイクル実装チェックリスト

最後に、実装時のチェックリストです。

アプリライフサイクルとWidgetライフサイクルを分けて考えているか
disposeにバックグラウンド保存処理を依存していないか
resumedで無条件にAPIを叩いていないか
短時間の二重実行を防いでいるか
inactive / hidden / paused を考慮しているか
hiddenのcaseをswitch文に含めているか
権限ダイアログ後のresumedを考慮しているか
通知タップ処理をライフサイクルだけで判断していないか
WidgetsBindingObserverを使う場合、removeObserverしているか
AppLifecycleListenerを使う場合、disposeしているか
iOS実機とAndroid実機の両方で確認しているか
重要な保存処理をライフサイクル通知だけに依存していないか

このあたりを押さえておくと、ライフサイクルまわりの不具合をかなり減らせます。


まとめ

Flutterアプリのライフサイクルは、アプリが前面にあるのか、バックグラウンドに入ったのか、ユーザーから見えない状態なのかを扱うための重要な仕組みです。

ただし、初心者が思っているよりも複雑です。

特につまずきやすいのは、次のポイントです。

アプリのライフサイクルとWidgetのライフサイクルを混同する
pausedだけ見ればよいと思ってしまう
すべての通知を必ず受け取れると思ってしまう
resumedでAPIを二重実行してしまう
権限ダイアログや通知復帰を考慮していない
ObserverやListenerの解除を忘れる
iOSとAndroidで同じように動くと思ってしまう

Flutterでは、WidgetsBindingObserverAppLifecycleListener を使ってライフサイクルの変化を検知できます。

ただし、ライフサイクルイベントはあくまで「きっかけ」です。

重要なデータ保存、通知タップ判定、バックグラウンド処理、権限管理などをすべてライフサイクルだけに依存させるのは危険です。

アプリ復帰時に必要な処理を最小限にし、二重実行を防ぎ、iOS・Androidの実機で確認することが、Flutterアプリのライフサイクルでつまずかないための基本です。

コメント

タイトルとURLをコピーしました