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: You need some basic Flutter knowledge to follow this tutorial. If you’re an absolute beginner, check out the tutorial Getting Started With Flutter first.

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:

Don't keep activities option results

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:

iOS app build configuration switching

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

Looking for a state

Before you begin coding, think of what exactly the restorable parts of your app are. A good starting point is to look for StatefulWidgets. 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:

Checkbox value as a persistent state

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:

Checkbox value as an in-memory 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:

Main storyboard restoration ID

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:

  1. The restorationId getter. The value should be unique across your app. Returning null disables the state restoration.
  2. 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:

  1. createDefaultValue, which returns a value to use when there’s no restoration data. In this case, it’s an empty list.
  2. From didUpdateValue, you notify the listeners. Usually, you can invoke notifyListeners() 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.
  3. fromPrimitives builds the instance of your class out of the raw data.
  4. 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:

ToDo List restoration in action

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:

  1. The RestorableDouble for the scroll offset (position).
  2. 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:

  1. Scroll listener updating the restorable offset.
  2. 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:

Restorable scroll position

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:

  1. The restorable route declaration.
  2. The onPresent callback for the navigation start.
  3. The onComplete callback for the navigation finish, which is called when you have a result.
  4. 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:

Restorable navigation route

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:

Restorable item details

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:

  1. The navigation argument serialization.
  2. 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