Fluttering * bikes. What to do with saving state in Flutter?


(* one of the meanings of the word flutter is to flutter)


Figure out if a life saving state in a Flutter application. What happens if the OS decides to restart it. Where will user input and navigation go, and how to deal with it.


Disclaimers:


  • to understand, you need to have a basic knowledge of Flutter;
  • I argue from the point of view of Android, about iOS - this is not for me;
  • не являюсь специалистом по Flutter§ Dart , подхожу с позиции новичка;
  • in some places I omit secondary issues (for example, the implementation of auxiliary functions), you can find the missing in the full project code .

What is Flutter


On Google I / O 17, Flutter was announced - a framework for cross-platform mobile application development.


Flutter is made in C and C ++, implements its own 2D engine for rendering (WebView is not used). Something similar to React, development is conducted in the Dart language . The code is written once, and during assembly it is compiled into native for each platform.


Why flutter?


Flutter (hereinafter referred to as flutter) beckons with a list of advantages:


  • cross-platform : we spend less time on development due to code reuse;
  • speed of work : AOT compilation into native code;
  • set of components : there are many standard widgets in the styles of Material Design and Cupertino;
  • hot reload : save time again.

To avoid crusades for and against: of course, you cannot choose one tool that will be suitable for solving absolutely all tasks, each tool is good for its context. It seems to me that flutter would be convenient to use for small projects with conditionally “typical” interfaces and usage scenarios. In this case, he would seriously shorten the process and eliminate the need for routine work (writing the same code for two platforms + trying to make it “on android like on ios”).


So take Flutter and use, what's the problem?


I am worried about a few points. They need to be clarified before rushing to do something for production:


  • life cycle;
  • architecture;
  • Is it really that fast?

Let's start in order. This article will deal with life cycle issues. Perhaps I have a typical paranoia of an android developer. The first thing that comes to my mind when I learn about some new solution is “Does it ensure the preservation of state? What happens if the activist dies? And if the application process dies? ”


Let's see what stateful Flutter does


Well, check it out! I create a small demo project from codlab . This is an application that displays randomly generated words in a list. As you scroll, new words are added, so the list is conditionally infinite. You can also add these words to your favorites and view your favorites on a separate screen.


I check the "do not keep activities" checkbox, start, collapse, expand, and ... bingo! Well, that is, quite the opposite, but the pessimist in me says "I warned you" and rubs his hands.


What's happening:


  1. the word list is recreated;
  2. "Favorites" are reset;
  3. the scroll position is not saved;
  4. navigation is not saved (it was on the second screen, when deployed, throws it on the first)


On the first two points (the list of words is created anew, the “favorites” are reset) everything is in order - this is about business logic, no one expects to save such things from the framework. If I want the words to be saved until the user explicitly restarts the application, I’ll save them somewhere, even if just in shared preferences.


On the third point (the scroll position is not saved) - it seems to be normal. You never know what happens to the data when you restart, maybe saving here is not necessary. Yes, and RecyclerView would also not save the scroll position automatically.


For the sake of interest, I checked what happens with TextField (analogue of EditText). It turned out that user input from it disappears in such a situation, which seems to be completely bad. Then I climbed to look at other widgets that allow you to do input: Slider, Switcher, CheckBox, etc.
It turned out that here, in principle, a slightly different (different from android) logic of input organization. Basically, widgets do not store user input. That is, if you poke into the checkbox, there will not appear a check mark. For it to appear, you need to create a separate field for this, transfer it to the widget, and change the field for the click event. The field changes -> the rendering changes.


If you build the chain further, then the loss of the values ​​of these fields with the state will mean resetting user input.


And the unpleasant news: if you store these fields somewhere in the in memory cache, they will be lost if the process restarts. Moreover, if you store them somewhere in the Dart (in the field of any class, even if it is a singleton), they will be lost even if you restart the activity.
But when you change the orientation, everything will be fine. Because inside the flutter, there is one view in one activity, on which the whole rendering takes place. And when you change the orientation, this activity is not recreated.


On the fourth point (navigation is not saved) - very disappointing. Android retains navigation, but no flutter.


I go google and find out that:
a) I was not the only one to ask this question, people are actively discussing this at the github;
b) flutter authors do not yet do anything to save navigation / status. Allow developers to handle this on their own.


To quote their answers:


We don't currently do anything to make this easy. We haven't studied this problem in detail yet. For now I recommend storing the information you want to persist manually, and applying it afresh when the app is restored.

Total of troubles:


  • you need to put the state of the widgets somewhere so that it recovers between restarts of the activity;
  • you need to somehow save the navigation.

Trying to keep state


Let's see if there is an inexpensive way to solve this. I need proof-of-concept. Hi bikes!


As an example, I will continue to torment the very demo application from the codelab. Task: save navigation and user input. In this case, let the user input be the scroll position, so as not to complicate. Plus, I will save the generated words and "favorites" in shared preferences, but this does not apply to the topic - I will not describe it.


I changed the demo a bit to make it easier to deal with it: I moved the widget with random words to a separate file, replaced WordPair with lines, and even little things.
+ Everything that is well described in the codelab, I will not repeat here. About the basic principles, application structure, widget tree, the logic of forming a list of words, see there.


I want to save user input and navigation in a bundle (remember that there is only one activity). Obviously, you will need communication between Dart and android. Let's figure out how to fix it ( documentation ). On the flutter side, you need to create a MethodChannel:


save(String key) async {
 const platform = const MethodChannel('app.channel.shared.data');
 platform.invokeMethod("save", /* здесь данные для сохранения */);
}

And on the android side, create a MethodChannel with the same name:


MethodChannel(getFlutterView(), "app.channel.shared.data")
.setMethodCallHandler { call, result ->
   if (call.method.contentEquals("save")) {
       // здесь сохраняем данные
   }
}

In what format to save / transmit data? On the Dart side, this can be anything, and any type can be passed through the MethodChannel. But on the android side, I want to deal with something uniform that I will put in the Bundle. To get started, I'll try the data (whatever it is) in json, json in lines, lines in a bundle.


Save input


First, let's deal with user input. We will create an abstract class that will store data regarding the state:


abstract class Restorable {
  save(String key);
  Future<Restorable> restore(String key);
}

The key argument is required to map a specific Restorable to a specific widget. That is, when creating widgets, you will need to give them unique keys.
The implementation for maintaining the scroll position will look like this:


class RandomWordsInput implements Restorable {
  double scrollPosition = -1.0;

  RandomWordsInput();

  save(String key) async {
    String json = JSON.encode(this);
    const platform = const MethodChannel('app.channel.shared.data');
    platform.invokeMethod("saveInput", {"key": key, "value": json});
  }

  Future<RandomWordsInput> restore(String key) async {
    const platform = const MethodChannel('app.channel.shared.data');
    String s = await platform.invokeMethod("readInput", {"key" : key});

    if (s != null) {
      var restoredModel = new RandomWordsInput.fromJson(JSON.decode(s));
      scrollPosition = restoredModel.scrollPosition;
    } else {
      _empty();
    }
    return this;
  }

  _empty() {
    scrollPosition = 0.0;
  }
}

In order not to write serialization by hand, I use the json_annotation library. How to use is described on the flutter site .


On the side of the android in the activity we will create a field for storing data:


var savedFromFlutter: MutableMap<String, String> = mutableMapOf()

In onCreate we forward methods:


MethodChannel(getFlutterView(), "app.channel.shared.data").setMethodCallHandler { call, result ->
   if (call.method.contentEquals("save")) {
       savedModels.put(call.argument<String>("key"), call.argument<String>("value"))
   } else if (call.method.contentEquals("read")) {
       result.success(savedModels.get(call.argument<String>("key")))
   }
}

And do the save / restore:


override fun onSaveInstanceState(outState: Bundle) {
   super.onSaveInstanceState(outState)
   outState.putParcelable("savedFromFlutter", toBundle(savedModels));
}

override fun onRestoreInstanceState(savedInstanceState: Bundle) {
   super.onRestoreInstanceState(savedInstanceState)
   savedModels = fromBundle(savedInstanceState.getParcelable<Bundle>("savedFromFlutter"))
}

Now you need to teach the widget to save and restore using Restorable. In our example, there is a RandomWords widget:


class RandomWords extends StatefulWidget {
  @override
  createState() => new RandomWordsState();
}

And his condition looks like this:


class RandomWordsState extends State<RandomWords> {
final _suggestions = <WordPair>[];
  Widget _buildSuggestions() {
    return new ListView.builder(
      padding: const EdgeInsets.all(16.0),
      itemBuilder: (context, i) {
        if (i.isOdd) return new Divider();

        final index = i ~/ 2;
        // If you've reached the end of the available word pairings...
        if (index >= _suggestions.length) {
          // ...then generate 10 more and add them to the suggestions list.
          _suggestions.addAll(generateWordPairs().take(10));
        }
        return _buildRow(_suggestions[index]);
      }
    );
  }
}

When creating the widget, we will pass the key to it:


class RandomWords extends StatefulWidget {
 final String stateKey;

 RandomWords(this.stateKey);

 @override
 createState() => new RandomWordsState();
}

In RandomWordsState, we will create a field under the state:


class RandomWordsState extends State<RandomWords> {
 RandomWordsInput input = new RandomWordsInput();

 RandomWordsState() {
   _init();
 }

 // …

}

To control the scroll position, you need a ScrollController:


final ScrollController scrollController = new ScrollController();

The _init () function will read the saved state and move the scroll to the position:


_init() async {
 RandomWordsInput newInput = await model.read(widget.stateKey);
 setState(() {
   input = newInput;
   scrollController.jumpTo(input.scrollPosition);
 });
}

The function for building the widget is changed as follows:


Widget _buildSuggestions() {
 return
   new NotificationListener(
     onNotification: _onNotification,
     child: new ListView.builder(
         padding: const EdgeInsets.all(16.0),
         controller: scrollController,
         itemBuilder: (context, i) {
          // …
         }
     ),);
}

The _onNotification function updates the scroll position:


_onNotification(Notification n) {
 input.scrollPosition = scrollController.position.pixels;
 input.save(widget.modelKey);
}

This widget is now created with the key:


class _MyHomePageState extends State<MyHomePage> {

 @override
 Widget build(BuildContext context) {
   return new Scaffold(
     appBar: new AppBar(
       title: new Text(widget.title),
     ),
     body: new RandomWords("list"),
   );
 }
}

Now the position of the scroll is saved between restarts of activity, cheers.


Save the navigation


To begin with, we will slightly rewrite the transition to another screen in our example, we will use named routes (described in the documentation ).
We list the routes (we have only one) when creating the application:


class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(title: 'Startup Name Generator'),
      routes: <String, WidgetBuilder>{
        '/saved': (BuildContext context) =>
        new SavedPage(title: 'Saved Suggestions'),
      },
    );
  }
}

We will create a class that will preserve the transition history:


class Routes {
  static Queue<String> routes = new Queue<String>();
  static var _firstTime = true;
}

As in Restorable, we’ll make methods for saving and restoring:


static save() async {
    const platform = const MethodChannel('app.channel.shared.data');
    platform.invokeMethod(
        "saveInput", {"key": "routes", "value": JSON.encode(routes.toList())});
  }

  static restore(BuildContext context) async {
    if (!_firstTime) {
      return;
    }
    const platform = const MethodChannel('app.channel.shared.data');
    String s = await platform.invokeMethod("readInput", {"key": "routes"});
    if (s != null) {
      routes = new Queue<String>();
      routes.addAll(JSON.decode(s));
    }
    _firstTime = false;
    for (String route in routes) {
      Navigator.of(context).pushNamed(route);
    }
  }

That is, during recovery, we simply take all the saved routes and restore the chain of screens from them.


It remains when you go to the screen with your favorites to save the route, and when you return back to remove it. With the transition, everything is simple, we edit the function that does this:


void _pushSaved() async {
    Routes.routes.addLast('/saved');
    await Routes.save();
    Navigator.of(context).pushNamed('/saved');
  }

Welcome back a little trickier. To catch the moment when the user clicks "Back", you need to wrap the widget on the screen with the "favorites" in WillPopScope. And also get a function (here _onWillPop) that will handle pressing the "Back":


class _SavedPageState extends State<SavedPage> {

  @override
  Widget build(BuildContext context) {
   // …
    return new Scaffold(
      appBar: new AppBar(
        title: new Text('Saved Suggestions'),
      ),
      body: new WillPopScope(
        onWillPop: _onWillPop,
        child: new ListView(children: divided),),
    );
  }

  Future<bool> _onWillPop() async {
    Routes.routes.removeLast();
    await Routes.save();
    return true;
  }
}

And you also need to restore the conversion history. Let's do it on the main screen:


class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    Routes.restore(context);
    // …
  }
  // …
}

It's all! Now both the navigation and the scroll position are saved.


Use Flutter


It seems to me that the lack of saving navigation out of the box is very strange. While saving user input, you can still argue. Suddenly, someone thinks that he is quite happy with the preservation of the device during a coup and the loss of activity when it is destroyed. I'm not happy.


Whether the flutter developers will decide something with this is not yet clear, but there are very active verbal battles on their github.


At the moment, it’s quite realistic to do state preservation on your own. Although, of course, it looks like too much boilerplate.


For the time being, I have a desire to study flutter further, and see if the rest of my doubts will fly away. And then decide about its applicability.