State Restoration of Flutter App
[ad_1]
Android and iOS can interrupt app processes to optimize resource usage. The system can kill apps in the background to gain more memory and CPU for the foreground. A killed app will start from scratch when a user brings it back. However, the user expects to see the same app state as when they left it instead of starting over again.
In this tutorial, you’ll see how to preserve the state of Flutter apps when the system decides to kill them. In the process, you’ll learn how to:
- Set up the environment.
- Discover the restorable states.
- Implement apps with state restoration.
- Test state restoration.
Note: This tutorial assumes you’re working on macOS and building apps for both Android and iOS. However, you can also work on Linux or Windows and build for Android only. If so, ignore iOS-specific parts, and use the Shift-F10 key shortcut instead of Control-R. You can also build the same app for the web or desktop, but state restoration has no meaning on those platforms.
Getting Started
Download the starter project by clicking Download materials at the top or bottom of the tutorial.
In this tutorial, you’ll build a ToDo list app that lets you add a title and date for each item. Then, you’ll add the state restoration functionality, so you don’t lose any important data if the app closes unexpectedly.
First, you’ll explore the simple app. Open and run the starter project. You can use Control-R in Android Studio. Press the plus button and type a title in the Item details dialog.
A dialog on the top of the navigation stack and the text of the item title make up the in-memory state. So, now you’ll test the (lack of) restoration! You have to do it separately for both platforms.
Testing on Android
Go to Developer settings and turn on the option Don’t keep activities. Then, bring your app to the front. You’ll see the state loss — the app starts from scratch, and the dialog isn’t restored:
To simulate the restoration of a process, you have to send your app to the background. Then, bring another app to the foreground. Finally, return to your app. Returning from the recent app switcher without touching any other app isn’t enough. Also, don’t swipe out your app from the recents. The system won’t restore the state after that.
Note: Disable the Don’t keep activities option after the state restoration testing! Leaving it enabled may cause battery drain and data loss in other apps. A lot of apps don’t handle state restoration properly.
Testing on iOS
iOS doesn’t have the option to enforce process killing like Android. You have to perform some manual work. Start by opening ios/Runner.xcworkspace in Xcode. Set the Build Configuration to Profile, as on the screenshot below:
Note that building the app in Profile mode takes more time than in Debug. In the case of the simple app from this tutorial, it should have no measurable impact. But, when you’re working on larger projects, you may want to use Debug mode by default. In such cases, you can switch to Profile mode only when needed.
Next, press the play button (or Command-R, not Control like in Android Studio!) to run the app. Press the plus button and type a title in the Item details modal. Send the app to the background by pressing the Home button or performing a gesture. Press the stop button (or Command-.) in Xcode. And finally, reopen the app by tapping its icon. Don’t use Xcode to launch the app at this stage!
Discovering the Restorable States
Before you begin coding, think of what exactly the restorable parts of your app are. A good starting point is to look for StatefulWidget
s. As the name suggests, they should contain mutable states. Note that only the in-memory state matters for restoration purposes. Look at the simple example with the checkbox:
Here, you save the checked state instantly in a persistent way, somewhere like the local database, file or backend. So, it makes no sense to include it in the restoration logic, even if a checkbox is inside StatefulWidget
. Now, look at the second example with a checkbox and a button to commit its state:
In this case, the state between tapping a checkbox and a button exists only in memory. So, it should be the subject of restoration. Other common sources of the restorable state include:
-
TextField
(along with text obscuring states) -
Radio
buttons - Expandable and collapsible widgets (e.g.,
Drawer
) - Scrollable containers (e.g.,
ListViews
)
Note the last bullet. The scrollable container may be inside the StatelessWidget
. Yet, its scroll position is an in-memory state. In such a case, you may want to convert your widget to a StatefulWidget
and add a field for the ScrollController
into its State
.
The restorable state covers more than just the widget’s content. The navigation stack is also an in-memory state. Users expect to return to the same place they were before leaving the app. Note that dialogs — like pop-ups and modals — are on the stack too.
Implementing State Restoration
Note: Changes you applied by hot restart and hot reload features are lost when the app process is killed, just like an unpreserved in-memory state. Always cold start your app using Control-R or the play button before testing the state restoration.
Finally, you can get your hands dirty by locating MaterialApp
in main.dart. Replace // TODO: replace with restorableScopeId
with the following line of code:
restorationScopeId: 'app',
This can be any non-nullable string. If you want to test the changes done to the app, stop the app and rerun it with the help of Control-R or Command-R on macOS. Take a closer look, and you’ll see that there’s no visible effect yet. The restorationScopeId
enables the state restoration ability for descendant widgets. It also turns on the basic navigation stack history restoration.
Enabling State Restoration on iOS
You need an extra iOS-specific step to enable state restoration. Open ios/Runner.xcodeproj in Xcode. Then, right-click the ios folder in Android Studio and select Flutter ▸ Open iOS module in Xcode. In Xcode, assign the Restoration ID like in the screenshot below:
The changes in the ios/Runner/Base.lproj/Main.storyboard XML file may include more than the restoration ID. It’s normal that saving the file in a different Xcode version introduces changes in the various lines.
Adding RestorationMixin
Open home_page.dart, and find // TODO: add the RestorationMixin
. Extend a class with RestorationMixin
:
class _HomePageState extends State<HomePage> with RestorationMixin {
Next, find // TODO: implement the RestorationMixin methods
, and replace it with:
@override
String? get restorationId => 'home_page'; // 1
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) { // 2
// TODO: implement the RestorationMixin methods
// TODO: register the list for restoration
// TODO: registering the scroll offset for restoration
// TODO: register the route for restoration
}
In the code above, you have:
- The
restorationId
getter. The value should be unique across your app. Returningnull
disables the state restoration. - The
registerForRestoration
method. You register your restorable properties here.
Repeat the same steps in add_item_page.dart. You can use add_item_page
as the restoration ID there. Run the app by pressing Control-R to check if anything has broken.
Before you register the restorable properties, you have to create them. In the simplest cases, just change the field types to their restorable equivalents. For example, int
to RestorableInt
, TextEditingController
to RestorableTextEditingController
and so on. If there’s no appropriate class in the framework, you have to implement it yourself.
Implementing the Restorable ToDo Item List
You’ll start by creating the restorable ToDo items list. The restoration process starts with serializing. Serialization means converting to primitives, like int
, double
or String
. Read more about primitives in the StandardMessageCodec
documentation. The underlying native mechanisms can only handle the data in a serialized form. In the end, you need a reverse process: deserialization.
Replace // TODO: create the RestorableToDoItemList class
in restorable_todo_item_list.dart with the following code snippet:
class RestorableToDoItemList extends RestorableValue<List<ToDoItem>> {
@override
List<ToDoItem> createDefaultValue() => []; // 1
@override
void didUpdateValue(List<ToDoItem>? oldValue) { // 2
notifyListeners();
}
@override
List<ToDoItem> fromPrimitives(Object? data) => data is! List // 3
? []
: data
.whereType<String>()
.map((e) => ToDoItem.fromJson(jsonDecode(e)))
.toList(growable: false);
@override
Object? toPrimitives() => // 4
value.map((e) => jsonEncode(e)).toList(growable: false);
}
Several methods are used here:
-
createDefaultValue
, which returns a value to use when there’s no restoration data. In this case, it’s an empty list. - From
didUpdateValue
, you notify the listeners. Usually, you can invokenotifyListeners()
without any condition. But, if a primitive representation of the new and old values is the same, you can skip the notifications. This can happen, for example, if some fields of the class are excluded from serialization. -
fromPrimitives
builds the instance of your class out of the raw data. -
toPrimitives
does the opposite operation. Its implementation must be symmetrical to a previous one.
Restoring Main Page
It’s time to use the restorable list. Open main_page.dart, find // TODO: change the type to RestorableToDoItemList
, and change the ToDo list field definition to the following:
class _HomePageState extends State<HomePage> with RestorationMixin {
final _toDos = RestorableToDoItemList();
The list type is now a subtype of the RestorableProperty
instead of the plain List
. Next, change the direct access to the list to a value getter. Find // TODO: use value field of the list
— note that there are two such instances. Replace the first with:
children: _toDos.value.isEmpty
And the second with:
List<Widget> _buildToDoList() => _toDos.value
Next, find // TODO: create a new instance of a list
, and replace the list mutation with a new instance containing an appended item:
setState(() => _toDos.value = [..._toDos.value, item]);
Then, change // TODO: dispose the restorable list
to a dispose method
invocation:
_toDos.dispose();
Finally, register the list for restoration by replacing // TODO: register the list for restoration
with:
registerForRestoration(_toDos, 'home_todos');
Run the app by pressing Control-R, and add some ToDos to the list. Now, perform the testing steps from the Getting Started section to check if the restoration works. You’ll see a result like in the screenshot below:
Restore the Scroll Position
The framework has no class like RestorableScrollController
. So, you have to also implement its restoration yourself. Flutter uses a declarative UI. You can’t query the SingleChildScrollView
widget for its current scroll position, so you have to add ScrollController
to access or set the scroll offset.
Open main_page.dart. Add a ScrollController
along with its restorable offset in place of // TODO: add scroll offset and controller
:
final _scrollOffset = RestorableDouble(0); // 1
final _scrollController = ScrollController(); // 2
In the code above, you have:
- The
RestorableDouble
for the scroll offset (position). - The not restorable scroll controller.
Time to use them! In initState
, find // TODO: listen to the scroll position changes
, and replace it with:
_scrollController
.addListener(() => _scrollOffset.value = _scrollController.offset); // 1
WidgetsBinding.instance?.addPostFrameCallback(
(_) => _scrollController.jumpTo(_scrollOffset.value)); // 2
The code may look complicated, but it’s actually very simple. Here’s what it contains:
- Scroll listener updating the restorable offset.
- Setting the restored scroll position on first initialization.
You have to bind a controller with a scrollable widget. Find // TODO: assign scroll controller
, and insert the following code there:
controller: _scrollController,
Don’t forget to dispose
the controller and offset. Replace // TODO: dispose the scroll controller and offset
with disposal method calls:
_scrollController.dispose();
_scrollOffset.dispose();
To make it work, you need to register the scroll offset field for restoration. Change // TODO: registering the scroll offset for restoration
to:
registerForRestoration(_scrollOffset, 'scroll_offset');
Note that the above function should be inside a restoreState
method. Now, you can run the app and add some ToDos to the list to make it scrollable.
Note: You can enable multiwindow mode and/or enlarge the font scale to reduce the number of needed items.
Scroll through the list and perform the testing steps from the Getting Started section. It should look like this:
Implementing the Restorable Route Returning Result
The last modification on the main page refers to the navigation to the add item page. Adding restorationScopeId
to the app enables navigation route restoration. But it doesn’t cover returning the results from other pages. To fill that gap, find // TODO: replace with restorable route
in a main_page.dart file, and add the following fields:
late final _addItemRoute = RestorableRouteFuture<ToDoItem?>( // 1
onPresent: (navigator, arguments) => navigator.restorablePush( // 2
_addItemDialogRouteBuilder,
arguments: arguments,
),
onComplete: (ToDoItem? item) { // 3
if (item != null) {
setState(() => _toDos.value = [..._toDos.value, item]);
}
});
static DialogRoute<ToDoItem?> _addItemDialogRouteBuilder( // 4
BuildContext context,
Object? arguments,
) => DialogRoute(
context: context,
builder: (_) => const AddItemPage(),
);
In the code above, you have:
- The restorable route declaration.
- The
onPresent
callback for the navigation start. - The
onComplete
callback for the navigation finish, which is called when you have a result. - The
static
route builder. If you pass a non-static function here, code will compile, but you’ll get a runtime error.
Use that route in place of // TODO: present restorable route
:
onPressed: _addItemRoute.present,
tooltip: 'Add item',
You have to dispose the route like any other restorable property. Replace // TODO: dispose the route
with:
_addItemRoute.dispose();
And finally, register the route for restoration by replacing // TODO: register the route for restoration
with:
registerForRestoration(_addItemRoute, 'add_item_route');
Run the app, and tap the floating action button. Perform the testing steps from the Getting Started section. You’ll see a result like this:
Implementing Simple Restorable Properties
Open add_item_page.dart. It has two properties: a text editing controller holding the title and a date that came from the picker. Both properties have restorable versions in the framework. In the case of a text editing controller, the code changes are straightforward. First, replace TODO: add the RestorationMixin
with:
class _AddItemPageState extends State<AddItemPage> with RestorationMixin {
Next, change TextEditingController
to its restorable version, RestorableTextEditingController
. Find // TODO: replace with restorable controller
, and change the line to:
final _controller = RestorableTextEditingController();
Analogously, use RestorableDateTime
in place of // TODO: replace with restorable date
:
final _dueDate = RestorableDateTime(DateTime.now());
You can’t use the new fields directly. Find the lines with // TODO: replace with value property
, and change them accordingly:
controller: _controller.value,
//...
child: Text(DateFormat.yMd().format(_dueDate.value)),
//...
_controller.value.text,
//...
_dueDate.value,
Don’t forget to dispose
a restorable date. Change // TODO: dispose the date
to:
_dueDate.dispose();
Finally, set the restoration ID and register the properties for restoration. Find // TODO: implement the RestorationMixin members
, and replace it with:
@override
String? get restorationId => 'add_item_page';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_controller, 'title');
registerForRestoration(_dueDate, 'due_date');
// TODO: register route for restoration
}
Run the app, and tap the floating action button. Then, type a title and choose a date. Finally, perform the testing steps from the Getting Started section. The result should look like this:
The field for a date is final. You don’t modify the restorable date itself, but its underlying value. Note the default value of the date. There’s no distinction between the value you pick and that default.
Consider a case where you open an item, add dialog and send the app to the background immediately. Then, you return two days later, and the app process was killed in the meantime. Finally, after restoration, you’ll see the date two days in the past. In some cases, you may want to not save and restore the value when a user hasn’t selected anything yet.
Adding State to Date Picker Restoration
The last — but not the least — part of this tutorial is about the restorable route to the DatePicker
. Like on the previous page, find // TODO: replace with restorable route
, remove the callback, and add the fields:
late final RestorableRouteFuture<DateTime?> _restorableDatePickerRouteFuture =
RestorableRouteFuture<DateTime?>(
onComplete: (newDate) {
if (newDate != null) {
setState(() => _dueDate.value = newDate);
}
},
onPresent: (NavigatorState navigator, Object? arguments) =>
navigator.restorablePush(
_datePickerRoute,
arguments: _dueDate.value.millisecondsSinceEpoch, // 1
),
);
static Route<DateTime> _datePickerRoute(
BuildContext context,
Object? arguments,
) => DialogRoute<DateTime>(
context: context,
builder: (context) => DatePickerDialog(
restorationId: 'date_picker_dialog',
initialEntryMode: DatePickerEntryMode.calendarOnly,
initialDate: DateTime.fromMillisecondsSinceEpoch(arguments! as int), // 2
firstDate: DateTime.now(),
lastDate: DateTime(2243),
),
);
In the code above, you have:
- The navigation argument serialization.
- The navigation result deserialization.
You not only receive a result here but also pass an initial date as an argument. The DateTime
class isn’t primitive, so it’s not serializable using StandardMessageCodec
. That’s why you have to pass it as the number of seconds since the Unix epoch: January 1, 1970. The year of last date (2243) is just a maximum supported value.
Use the route
in place of // TODO: present restorable route
:
onTap: _restorableDatePickerRouteFuture.present,
Next, dispose the route. Replace // TODO: dispose the route
with:
_restorableDatePickerRouteFuture.dispose();
Finally, register the route
for restoration in place of // TODO: register route for restoration
in a restoreState
method:
registerForRestoration(_restorableDatePickerRouteFuture, 'date_picker_route_future');
Where to Go From Here?
You made it through the entire tutorial about state restoration in Flutter! Get the complete code for this tutorial by clicking Download materials at the top or bottom of the tutorial.
You’ve gotten a great start on state restoration, but this tutorial doesn’t cover all the capabilities of the state restoration API. There are more classes, like RestorationBucket
. Some classes have more methods, like RestorationMixin.didToggleBucket
. Some methods have more parameters, like oldBucket
and initialRestore
of RestorationMixin.restoreState
. You may find them useful in advanced use cases.
A good starting point in the official Flutter documentation is the RestorationManager
page. You can go forward from there by following the links to the next classes.
Want to learn more about state restoration in the native platforms? Check out our other tutorials: State Restoration in SwiftUI for iOS and Jetpack Saved State for ViewModel: Getting Started for Android.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
[ad_2]
Source link