以下の内容はhttps://tech.andpad.co.jp/entry/2025/12/10/100000より取得しました。


FlutterのState Restorationの基本と応用

なぜState Restorationの話?

正直に言うと、Flutter の State Restoration は今年もっとも苦しめられたテーマでした。 「面倒」という言葉で片付けられるものではなく、仕組みを理解していないと破綻する設計領域です。

開発現場でも「なんとなく使っている」「勝手に復元される仕組み」と誤解されやすく、 正しい設計思想・保存のタイミング・責務の境界を理解している人は驚くほど少ないと感じました。

だからこそ、この知識は社内に閉じるべきではない、と考えています。 今年向き合ったすべての試行錯誤と理解を、Advent Calendar の形で公開し、 「State Restorationは難しくない。正しく理解すれば武器になる」ということを伝えたくて、このテーマを選びました。

FlutterにおけるState Restorationとは

FlutterのState Restorationは、「画面(Widget)の状態をアプリ側ではなくOS側に一時的に預けておく仕組み」です。

通常、アプリはバックグラウンドに回ったり、メモリ不足でOSに強制的にプロセスを殺されると、画面の状態(入力内容・スクロール位置・選択中のタブなど)は完全に消えるのが前提です。 しかしState Restorationを有効にすると、Flutterは画面上の「状態」をRestorationManagerがまとめてシリアライズし、OSが管理するインスタンス状態(Android: Bundle / iOS: NSCoder)に保存します。

そのため、アプリのプロセス自体が一度完全に消えても、次にユーザーがアプリを開いた瞬間に、保存していた状態をFlutterが復元できるようになります。 結果として、「途中入力のフォーム」「直前まで開いていた画面」「スクロール位置」などがまるでアプリが 生き返ったかのように再現されるわけです。

State Restorationを使うメリット/デメリット

メリット

  • 入力データの消失リスクをほぼゼロ化できます
    • State Restorationにより、ユーザーがフォーム入力中に他アプリへ切り替えたり作業を中断しても、復帰した際に入力内容が保持されます。そのため、中断が前提となる現場の業務でも離脱率が下がり、最終的に「入力が絶対に消えない」という信頼感につながります。
  • ユーザー体験として「安定したアプリ」という印象を与えられます
    • 入力が消えないという小さな成功体験を積み重ねられることで、ユーザーはアプリを「いつ開いても前の状態に戻れる安定したツール」と認識し、安心感が生まれます。

デメリット

  • タスクキル後も保持されることで“しつこい”挙動になります
    • State RestorationはアプリがOSによって強制終了された場合でも前回の表示状態や入力内容を復元するため、ユーザーが「もうこの画面は終わった」と考えていても勝手に再開されてしまい、意図しない復元がUXを破壊します。
  • 実装が難しく、一定以上の設計力と思想が要求されます
    • RestorablePropertyの選定、Routerとの整合性、状態の責務分離や保存対象の線引きなど、正しく扱うには知識・設計力・思想が必要になります。単純なAPI利用ではなく、「復元すべき状態を定義する力」が求められます。

State Restorationを実現するメカニズム

Androidの場合

以下のシーケンス図のように、Androidは画面の情報を保持しようと動作します。

Android

ユーザーが別アプリへ切り替えてアプリがバックグラウンドへ移動すると、Androidは「このプロセスはいつ終了しても構わない」という状態に入ります。ここがState Restorationのスタート地点になります。

Flutterはこのタイミングで何か特殊な処理をしているわけではありません。 実際に状態が保存されるのは onSaveInstanceState() のタイミングだけです。このイベントでFlutterは RestorationManager が持っている最新の状態をそのままBundleへシリアライズして渡します。重要なのは、この時点ですでに保存は完了しているという点です。 「kill直前に頑張って保存する」といった動きは存在しません。

保存先はアプリの独自ストレージではなく、OSが管理するインスタンス状態領域です。つまり、OS側の“預かり場所”に入ります。そのため制約もOS依存で、Androidではおよそ1MBほどのサイズ制限があり、無視して巨大データを詰め込むと TransactionTooLargeException でクラッシュします。

保存が終わった後は、OSがプロセスをkillするかどうかは完全にOS次第です。アプリはそれを知ることもコントロールすることもできません。この非同期性こそがState Restoration設計の前提で、「保存はもう済んでいるから、いつ死んでも復元できる」という思想になっています。

つまり、シーケンス図が示しているポイントはただ一つです。

保存は onSaveInstanceState の瞬間で完了します。「kill直前で保存」という発想は幻想であり、復元対象はOSによってkillされたプロセスだけです。

この仕組みの上に RestorableProperty や restorablePush が乗っています。

iOSの場合

iOS

iOS側でも、State Restorationは「アプリがバックグラウンドへ移行する瞬間に、現時点の状態をOSに委ねて保存する」という思想で動きます。 Androidの onSaveInstanceState と同様に、killされる直前に保存するわけではありません。

ポイントは、「OSが生存中にスナップショットを確保し、いつkillするかはOSが決める」 という設計思想です。

Flutterが行っていることはあくまで「状態をOSに預ける」という動作だけであり、「いつ復元されるか」「どのタイミングでkillされるか」は全てOSの判断で進みます。

値が変更された場合の保存のされ方

では実際に、画面の状態が変わり、State Restoration がどのように扱われるのでしょうか。 結論は次のシーケンスになります。

状態変化そのものが「保存」ではなく、

保存に必要な最新スナップショットを常に作り続け、OSイベント時に初めて保存される

という仕組みです。

厳密には、保存は 各OS の ライフサイクルイベントの時点で実行されますが、そのための最新スナップショットは常にBucketへ反映されています。

以下がその流れです。

このように、Flutter Engine側にRestorationDataとして渡された画面の情報をスナップショットとしてキャッシュをし、適宜OSのライフサイクルからコールされたState RestorationのタイミングでOS毎にそれぞれデータを書き込んでいます。

一旦ここまでを整理すると、画面の状態が変更されるたびに、その内容は RestorationBucket に書き込まれ、最新状態はバイナリデータ(restorationData)として Flutter Engine に随時プッシュされ、Engine 側でスナップショットとして保持され続けます。 そして、Android の onSaveInstanceState や iOS の willEncodeRestorableStateWithCoder といった OS によるインスタンス保存イベントが発生した瞬間に、保持していた restorationData が OS のインスタンス保存領域(Bundle / NSCoder)に書き込まれます。

保存は「kill の直前」ではなく、「いつ kill されても復元可能な状態を事前に作っておく」設計になっています。

実装方法

実装サンプルのリポジトリはこちら

前提条件

本記事では State Restoration の動作原理に焦点を当てるため、実装をできるだけシンプルにしています。

  • サンプル実装では状態管理ライブラリ(Riverpod 等)は使用していません
    • 理由: Riverpod はState Restorationにはあまり関係がなく、本記事では「RestorableProperty による復元」が中心テーマです
  • ナビゲーションには go_router を使用します
    • go_router はState Restoration と組み合わせた場合の 「画面スタックの復元(ルーティング状態の復元)」 に対応しています
    • ただし、 go_router がサポートするのは ナビゲーション履歴の復元であり、Widget内部の状態(TextField の入力値など)を自動で復元するわけではありません
    • 画面内部の状態を復元するには、本記事で扱うように RestorationMixin と RestorableProperty の実装が必要です

App全体の設定

アプリ全体で State Restoration を有効化するために、MaterialApp.router に restorationScopeId を指定します。 これによって、アプリルートに RootRestorationScope が生成され、アプリの画面ツリー配下で RestorationMixin を持つ widget が、State Restoration の対象として扱われるようになります。

restorationScopeId 自体は「OSに直接保存するID」ではなく、Flutter内部の RestorationBucket の識別子です。 この Scope 以下で発生した RestorableProperty の状態は、RestorationManager により バイナリ化された restorationData として Flutter Engine 側へ伝搬され続けます。 その後、Android の onSaveInstanceState(Bundle) や iOS の willEncodeRestorableStateWithCoder: といった OS側の保存イベントが発生したタイミングで、保持済みの restorationData が OS のインスタンス保存領域に書き出されます。

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'State Restoration Sample',
      routerConfig: _router,
      // アプリ全体の Restoration スコープを有効化
      restorationScopeId: 'app',
    );
  }
}

Routerの設定

画面遷移の履歴(ナビゲーションスタック)も State Restoration の対象にする場合、GoRouter 側にも restorationScopeId*1 を指定します。 これにより、MaterialApp.router(restorationScopeId: 'app') の配下に Router 用のRestorationScope が生成され、OS によるインスタンス状態保存時に「現在の画面スタック構造」が復元対象として扱われます。

※実際の保存処理は、Android の onSaveInstanceState(Bundle) / iOSのapplication:shouldSaveApplicationState:またはapplication:willEncodeRestorableStateWithCoder: が呼ばれたタイミングで実行されます。

final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      name: 'home',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/details',
      name: 'details',
      builder: (context, state) => const DetailsPage(),
    ),
  ],
  restorationScopeId: 'routes'
);

画面の設定

State Restorationで「この画面のどの状態を復元対象とするか」を明示するため、各画面の State では以下の3つを実装します。

  1. State に RestorationMixin を付与する。この State が RestorationBucket 配下に属することを宣言します
  2. restorationId をoverrideし、この State を一意に識別できるIDを返す。Bucket内での識別に使用され、復元時に同じ State とマッチングされます
  3. restoreState() をoverrideし、復元対象の RestorableProperty を登録する。registerForRestoration() により、RestorableProperty が Bucketと紐づきます

以下は TextField の入力値を復元する例です。

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

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

class _HomePageState extends State<HomePage> with RestorationMixin {
  // 普通の TextEditingController だと OS kill 後は復元されない
  // final TextEditingController _controller = TextEditingController();

  // State Restoration 対応版
  final RestorableTextEditingController _controller =
  RestorableTextEditingController();


  @override
  String? get restorationId => 'home_page';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_controller, 'home_text');
  }

(中略)

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            const Text('Home 画面の入力'),
            TextField(
              controller: _controller.value, // Restorableな値を参照するときはvalueを指定
              decoration: const InputDecoration(
                hintText: 'ここにテキストを入力',
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                context.push('/details');
              },
              child: const Text('Details へ'),
            ),
          ],
        ),
      ),
    );
  }
}

Detailsのページも同様に以下のようにします。

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

  @override
  State<DetailsPage> createState() => _DetailsPageState();
}

class _DetailsPageState extends State<DetailsPage> with RestorationMixin {
// 普通の TextEditingController だと OS kill 後は復元されない
  // final TextEditingController _detailController = TextEditingController();

  // State Restoration 対応版
  final RestorableTextEditingController _detailController =
  RestorableTextEditingController();

  @override
  String? get restorationId => 'details_page';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_detailController, 'details_text');
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Details'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            const Text('Details 画面の入力'),
            TextField(
              controller: _detailController.value, // Restorableな値を参照するときはvalueを指定
              decoration: const InputDecoration(
                hintText: 'ここにテキストを入力',
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                context.pop();
              },
              child: const Text('Home へ戻る'),
            ),
          ],
        ),
      ),
    );
  }
}

動作確認

前提

  • Androidでの確認を前提とします。
  • developer modeにおいて、「アクティビティを保持しない(Don't keep activities)」を有効にしておきます。
    • State Restoration が正しく動作しているかを確認するため、OSが画面を破棄する状況を意図的に作る必要があります。
    • State Restorationは「ユーザーが手動でアプリを閉じた場合」では動作しません。
    • OSが“勝手に”プロセスを破棄した場合のみ、復元が行われます。

動作確認手順と結果

以下の手順で、画面遷移スタックとTextFieldの入力の両方が復元されることを確認できます。

  1. Home画面でテキストを入力
  2. push により Details画面へ遷移
  3. Details画面で別のテキストを入力
  4. ホームに戻る(またはホームボタンでバックグラウンドへ)。 Don't keep activities が有効のためHome/Details両方のActivityが破棄される
  5. アプリを再起動する

以下の動作確認だと、この際に画面遷移スタックもHome画面、Details画面の内容も保持され、復元後に最初から Home → Details の状態で起動しているため、各画面の入力値も保持されていることが確認できます。

応用的なState Restoration

RestorationDataが1MBを超える場合

問題

State Restoration では、画面状態は最終的に OS が管理するインスタンス領域(Bundle/NSCoder)に保存されるため、Android では 1MB 程度の上限があります。

(厳密には TransactionTooLargeException は Bundle 固有の制限ではなく、Binderトランザクションサイズが約1MBを超えた場合に発生します)

そのため、

  • ユーザー入力が巨大
  • フォーム項目が非常に多い
  • リスト内の選択/位置情報が膨大

といったケースでは、RestorationData が 1MB を超え、TransactionTooLargeException によりクラッシュします。

対策

State Restoration による復元対象は「状態のキー」だけにし、実データは外部ストレージ(SecureStorageなど)へ退避する方式が必要になります。

大まかな対応方針としては以下の通りです。

  • RestorableValue を継承して
  • toPrimitives() では 保存用のキーのみ返す
  • fromPrimitives() では 保存したキーを用いて外部ストレージから実データを読み出す

RestorableValue の拡張

RestorableValue「保存対象の状態を、OSの保存形式で表せるプリミティブに変換する責務」を持つクラスです。

そのため、以下の 2 メソッドが重要になります。

// OS保存形式 → オブジェクトへ
@override
T fromPrimitives(Object? serialized);

// オブジェクト → OS保存形式
@override
Object? toPrimitives();

通常の RestorableProperty ではこれが自動処理されますが、 巨大データの場合は、この2つをオーバーライドして設計する必要があります。

画面遷移スタックと外部ストレージ連携の方針

画面遷移スタック(どの画面まで遷移していたか)の復元については、基本的に go_router 側に委ねる設計とします。

一方で、「巨大な状態そのもの」は State Restoration には載せず、ID だけを載せて、実データは外部ストレージ(SecureStorage など)に逃がすようにします。

このとき RestorableValue では、toPrimitives / fromPrimitives でそれぞれ次のような役割を持たせます。

  • toPrimitives で行うこと
    • 復元用の restoreId を発行します
    • その restoreId をキーとして、実データ(JSON など)をセキュアストレージに保存します
    • RestorationData 側には、この restoreId だけを返します
  • fromPrimitives で行うこと
    • RestorationData から restoreId を受け取ります
    • その restoreId をキーとして、セキュアストレージから JSON を読み込みます
    • 読み込んだ JSON から元のオブジェクトを復元します

この方針をコードに落とし込むために、まずは前提として、次のような「ファイル(またはセキュアストレージ)への保存クラス」を用意しておきます。

// ファイルの保存を想定
abstract class HugeDataStore {
  Future<void> save(String id, HugeData data);
  Future<HugeData?> load(String id);
}

// 実装は任意(SecureStorageやEncryptedSharedPreferencesなど)
final HugeDataStore hugeDataStore = ...;

RestorableValueの中身は以下の通りとなります。

class RestorableHugeData extends RestorableValue<HugeData?> {
  HugeData? _value;
  String? _restoreId;

  @override
  HugeData? get value => _value;

  @override
  set value(HugeData? newValue) {
    if (newValue == null) return;
    _value = newValue;
    notifyListeners();
  }

  @override
  Object? toPrimitives() {
    if (_restoreId == null) {
      _restoreId = UniqueKey().toString();
    }

    if (value != null) {
      保存処理をするクラス/インスタンス.save(_restoreId!, value!);
    }

    // RestorationData には ID だけを保存
    return _restoreId;
  }

  @override
  Future<void> fromPrimitives(Object? data) async {
    // dataにはIDだけが入っている
    final id = data as String?;
    if (id == null) {
      _value = null;
      return;
    }
    _restoreId = id;

    // 外部ストレージから復元
    final restored = await hugeDataStore.load(id);
    _value = restored;
    notifyListeners();
  }
}

上記のシーケンスを整理すると以下のようになります。

Stateの保存処理

ポイントは「状態変化そのものが保存ではない」という点です。 アプリは常に RestorationBucket に最新スナップショットを積み上げ、OS が保存を要求する瞬間に初めて Bundle / NSCoder に書き込まれる構造になっています。

Stateの復元処理

復元は OS 側が restorationData を返却したところから始まります。 Flutter Engine → RestorationManager → 各 RestorableProperty の fromPrimitives() の流れで UI が再構築されます。

総論

State Restoration を導入するかどうかは「要件次第」です。 ただし、導入するときに設計思想を理解していないと、

  • 意図しない復元により UX が破壊される
  • State が不必要にしつこくなる
  • 不正確な保存対象設計で 1MB 制限に当たる
  • カスタム復元ができず実装が破綻する

といった問題を必ず踏みます。

逆に、状態の責務を正しく切り出し、復元対象を明示できる設計ができれば、 本記事で紹介したように RestorableProperty / restorablePush / カスタム RestorableValue によって十分に拡張可能です。

つまり、State Restoration とは単なる仕組みではなく、 「何を復元すべきかを定義する設計力そのもの」です。

アプリ UX を最大化したい、途中離脱が当たり前の現場向けプロダクトを支えたい、 そういった動機があるなら導入する価値は十分にあります。

本記事がその第一歩になれば幸いです。

最後に

アンドパッドでは、プロダクトと技術が本気で好きな iOS / Android エンジニアを募集しています。 Flutter でもネイティブでも、「現場業務を変えるための技術」を一緒に作りたい方を歓迎します。

hrmos.co

hrmos.co

engineer.andpad.co.jp

*1:ここで指定する restorationScopeId は「画面スタックそのものを即保存する」のではなく、“復元可能なルーティング状態”として登録するための識別子です。




以上の内容はhttps://tech.andpad.co.jp/entry/2025/12/10/100000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14