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 useRestorationMixin.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 therestorationIdandrestoreStatemethods.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 variousRestorablePropertytypes likeRestorableString,RestorableBool, etc.registerForRestoration: Registers aRestorablePropertywith the restoration system using a unique identifier.dispose: Disposes of theRestorablePropertywhen 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
MyCustomObjectis defined. RestorableMyCustomObjectextendsRestorableProperty<MyCustomObject?>and implementsfromPrimitivesandtoPrimitivesto 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
_loadCounterfromSharedPreferencesduring therestoreState. - Save updated values to
SharedPreferenceswhen 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.