Implementing State Restoration in Flutter Apps

State restoration is a critical aspect of mobile app development. It ensures that your app remembers and restores its state when it’s unexpectedly terminated or when the user switches between apps. Without state restoration, users might lose their progress or settings, leading to a frustrating experience. In Flutter, implementing state restoration involves several key strategies that, when applied correctly, can greatly enhance the usability and reliability of your app.

Why State Restoration Matters in Flutter

Consider scenarios like filling out a multi-step form, reading a long article, or playing a game. If the app crashes, gets killed by the OS to free up memory, or the user switches to another app, the expectation is that when they return, the app should remember where they left off. Implementing state restoration provides a seamless and consistent user experience.

Core Concepts of State Restoration in Flutter

Flutter offers several mechanisms to implement state restoration effectively. Here are some fundamental concepts:

  • RestorationMixin: Provides a standardized way to save and restore state within your widgets.
  • RestorationBucket: Holds the restoration data for widgets that use RestorationMixin.
  • RestorationScope: Scopes a subtree in the widget tree and attaches a restoration bucket to it.
  • Persistent Storage: Utilizes storage mechanisms to persist the data across app sessions (e.g., shared preferences, local database).

Implementing State Restoration: Step-by-Step

Let’s dive into a practical example of how to implement state restoration in a Flutter app using RestorationMixin.

Step 1: Setting Up the MaterialApp

Ensure your MaterialApp has restorationScopeId set. This is the root for all restoration efforts.


import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'State Restoration Demo',
      restorationScopeId: 'app',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

Step 2: Using RestorationMixin in a Stateful Widget

Extend your StatefulWidget‘s state with RestorationMixin. Implement the required methods for saving and restoring state.


import 'package:flutter/material.dart';

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State with RestorationMixin {
  RestorableInt _counter = RestorableInt(0);

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

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

  void _incrementCounter() {
    setState(() {
      _counter.value++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('State Restoration Demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${_counter.value}',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

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

Key aspects in the above code:

  • RestorationMixin: Provides the restorationId and restoreState methods.
  • restorationId: A unique identifier for this stateful widget, used for restoring the state.
  • restoreState: Called when the app restores its state. Register restorable properties here.
  • RestorableInt: A restorable property that automatically saves and restores integer values. Flutter provides various RestorableProperty types like RestorableString, RestorableBool, etc.
  • registerForRestoration: Registers a RestorableProperty with the restoration system using a unique identifier.
  • dispose: Disposes of the RestorableProperty when the widget is removed from the tree to prevent memory leaks.

Step 3: Restoring More Complex Data

For complex data types (e.g., custom objects), you’ll need to serialize them into primitive types or JSON before saving. Implement a custom RestorableProperty to handle this conversion.


import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class MyCustomObject {
  final String name;
  final int value;

  MyCustomObject({required this.name, required this.value});

  Map toJson() => {
        'name': name,
        'value': value,
      };

  factory MyCustomObject.fromJson(Map json) => MyCustomObject(
        name: json['name'],
        value: json['value'],
      );

  @override
  String toString() {
    return 'MyCustomObject{name: $name, value: $value}';
  }
}

class RestorableMyCustomObject extends RestorableProperty {
  MyCustomObject? _value;

  @override
  MyCustomObject? get value => _value;

  @override
  set value(MyCustomObject? value) {
    if (value == _value) return;
    _value = value;
    notifyListeners();
  }

  @override
  MyCustomObject? fromPrimitives(Object? data) {
    if (data == null) return null;
    return MyCustomObject.fromJson(json.decode(data as String));
  }

  @override
  Object? toPrimitives() {
    if (_value == null) return null;
    return json.encode(_value!.toJson());
  }

  @override
  void didUpdateValue(MyCustomObject? oldValue) {
    if (kDebugMode) {
      print('Value changed from $oldValue to $value');
    }
  }
}

class MyComplexDataPage extends StatefulWidget {
  const MyComplexDataPage({Key? key}) : super(key: key);

  @override
  State createState() => _MyComplexDataPageState();
}

class _MyComplexDataPageState extends State
    with RestorationMixin {
  late final RestorableMyCustomObject _customObject;

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

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

  @override
  void initState() {
    super.initState();
    _customObject = RestorableMyCustomObject()
      ..value = MyCustomObject(name: 'Initial Name', value: 0);
  }

  void _updateCustomObject() {
    setState(() {
      _customObject.value = MyCustomObject(
        name: 'Updated Name',
        value: _customObject.value?.value == null ? 1 : _customObject.value!.value + 1,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Complex Data Restoration Demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Custom Object Data:',
            ),
            Text(
              '${_customObject.value}',
              style: Theme.of(context).textTheme.headline6,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _updateCustomObject,
        tooltip: 'Update Object',
        child: Icon(Icons.update),
      ),
    );
  }

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

In this example:

  • A custom class MyCustomObject is defined.
  • RestorableMyCustomObject extends RestorableProperty<MyCustomObject?> and implements fromPrimitives and toPrimitives to serialize and deserialize the custom object using JSON.
  • When restoring state, the saved JSON string is decoded back into a MyCustomObject.

Step 4: Persisting Data with SharedPreferences

For state that needs to persist across app restarts (not just terminations), integrate SharedPreferences. Override the restoreState and incorporate data persistence.


import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State with RestorationMixin {
  RestorableInt _counter = RestorableInt(0);

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

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_counter, 'counter');
    _loadCounter();
  }

  Future _loadCounter() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() {
      _counter.value = (prefs.getInt('counter') ?? 0);
    });
  }

  Future _incrementCounter() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() {
      _counter.value++;
    });
    await prefs.setInt('counter', _counter.value);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('State Restoration Demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${_counter.value}',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

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

Here’s how persistence is achieved:

  • Load data in _loadCounter from SharedPreferences during the restoreState.
  • Save updated values to SharedPreferences when modifying data, ensuring it persists across app restarts.

Best Practices for State Restoration

  • Identify critical state: Focus on restoring the most crucial information that provides a smooth UX.
  • Minimize data: Store only essential data to reduce overhead and potential delays.
  • Test rigorously: Simulate scenarios where the app is terminated or goes into the background.
  • Handle failures gracefully: Implement error handling for restoring state. Provide a default state if restoration fails.

Conclusion

Implementing state restoration in Flutter is vital for creating user-friendly and reliable apps. Using RestorationMixin and RestorableProperty allows you to seamlessly save and restore state within widgets. For data that should persist across app sessions, integrate storage mechanisms like SharedPreferences. By following best practices and thoroughly testing your implementation, you can deliver an excellent user experience, even in unexpected scenarios.