Порхающие* велосипеды. Что делать с сохранением состояния во Flutter?
(*одно из значений слова flutter — порхать)
Разбираемся, есть ли жизнь сохранение состояния во Flutter-приложении. Что будет, если ОС решит его перезапустить. Куда денется пользовательский ввод и навигация, и как с этим справляться.
Дисклеймеры:
- для понимания нужно иметь стартовые знания о Flutter;
- рассуждаю с точки зрения Android, про iOS — это не ко мне;
- не являюсь специалистом по Flutter/Dart, подхожу с позиции новичка;
- в некоторых местах опускаю второстепенные моменты (например, реализации вспомогательных функций), найти недостающее можно в полном коде проекта.
Что такое Flutter
На Google I/O 17 был анонсирован Flutter — фреймворк для кроссплатформенной разработки мобильных приложений.
Flutter сделан на С и С++, реализует свой 2D движок для рендеринга (WebView не используется). Чем-то похож на React, разработка ведется на языке Dart. Код пишется один раз, а при сборке компилируется в нативный для каждой платформы.
Почему Flutter?
Flutter (далее по тексту флаттер) манит списком преимуществ:
- кроссплатформенность: тратим меньше времени на разработку за счет переиспользования кода;
- быстрота работы: AOT компиляция в нативный код;
- набор компонентов: есть много типовых виджетов в стилях Material Design и Cupertino;
- hot reload: снова экономим время.
Во избежание крестовых походов за и против: конечно же нельзя выбрать один инструмент, который будет подходить для решения абсолютно всех задач, каждый инструмент хорош для своего контекста. Как мне кажется, флаттер было бы удобно использовать для небольших проектов с условно «типовыми» интерфейсом и сценариями использования. В этом случае он серьезно сокращал бы процесс и избавлял от рутинной работы (написание одного и того же кода для двух платформ + попытки сделать так, чтобы «на андроид как на ios»).
Так бери Flutter и пользуйся, в чем проблема?
Меня беспокоит несколько моментов. Их надо прояснить перед тем, как бросаться делать что-то для продакшена:
- жизненный цикл;
- архитектура;
- а действительно ли все так быстро?
Начнем по-порядку. В этой статье будем разбираться с вопросами жизненного цикла. Возможно, у меня типичная паранойя андроид-разработчика. Первое, что приходит мне в голову, когда я узнаю о каком-то новом решении, это «А оно обеспечивает сохранение состояния? Что будет, если активити умрет? А если умрет процесс приложения?»
Посмотрим, что делает Flutter с сохранением состояния
Что ж, проверим! Создаю маленький демо-проект из кодлаба. Это приложение, которое в списке отображает случайно сгенерированные слова. По мере прокручивания добавляются новые слова, так что список условно бесконечный. Еще можно эти слова добавлять в избранное и просматривать избранное на отдельном экране.
Ставлю флажок «do not keep activities», запускаю, сворачиваю, разворачиваю, и… бинго! Ну, то есть совсем наоборот, но пессимист во мне говорит «а я вас предупреждал» и потирает ручки.
Что происходит:
- список слов создается заново;
- «избранное» сбрасывается;
- позиция скролла не сохраняется;
- навигация не сохраняется (была на втором экране, при разворачивании выкидывает на первый)
По первым двум пунктам (список слов создается заново, «избранное» сбрасывается) все в порядке — это про бизнес-логику, от фреймворка никто и не ждет сохранения таких вещей. Если я хочу, чтобы слова сохранялись до явного перезапуска приложения пользователем, то сохраню их куда-нибудь, хотя бы просто в shared preferences.
По третьему пункту (не сохраняется позиция скролла) — вроде бы это нормально. Мало ли что происходит с данными при перезапуске, может сохранение здесь и не нужно. Да и RecyclerView тоже не сохранил бы позицию скролла автоматически.
Интереса ради я проверила, что происходит с TextField (аналог EditText). Оказалось, что пользовательский ввод из него в такой ситуации пропадает, что вроде бы совсем нехорошо. Дальше я полезла смотреть на другие виджеты, которые позволяют делать ввод: Slider, Switcher, CheckBox и т.д.
Выяснилось, что здесь в принципе немного другая (отличная от андроидовской) логика организации ввода. В основном, виджеты не хранят в себе пользовательский ввод. То есть, если тыкать в чекбокс, там не появится галочка. Чтобы она появилась, нужно завести для этого отдельное поле, передать его в виджет, и на событие клика поле изменять. Изменяется поле → изменяется отрисовка.
Если строить цепочку дальше, то потеря значений этих полей с состоянием будет означать сброс пользовательского ввода.
И неприятные новости: если хранить эти поля где-то в in memory cache, они будут потеряны в случае перезапуска процесса. Более того, если хранить их где-то в Dart (в поле любого класса, даже если он синглтон), они будут потеряны даже при перезапуске активити.
А вот при смене ориентации все нормально будет. Потому что внутри флаттера — одна вью в одной активити, на которой и происходит вся отрисовка. И при смене ориентации эта активити не пересоздается.
По четвертому пункту (не сохраняется навигация) — очень огорчительно. Андроид сохраняет навигацию, а флаттер нет.
Иду гуглить и выясняю, что:
а) не я одна задалась этим вопросом, люди это активно обсуждают на гитхабе;
б) авторы флаттера пока что не делают для сохранения навигации/состояния ничего. Предоставляют разработчикам справляться с этим самостоятельно.
Если процитировать их ответы:
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.
Итого из неприятностей:
- нужно куда-то девать состояние виджетов, чтобы оно восстанавливалось между перезапусками активити;
- нужно каким-то образом сохранять навигацию.
Пробуем сохранять состояние
Посмотрим, есть ли недорогой способ это решить. Мне требуется proof-of-concept. Привет, велосипеды!
В качестве примера я продолжу мучить то самое демо-приложение из кодлаба. Задача: сохранить навигацию и пользовательский ввод. Пользовательским вводом в данном случае пусть будет позиция скролла, чтобы не усложнять. Плюс еще я сохраню сгенерированные слова и «избранное» в shared preferences, но к теме это не относится — описывать не буду.
Я немного изменила демку, чтобы было проще с ней иметь дело: вынесла виджет со случайными словами в отдельный файл, заменила WordPair на строки, и еще по мелочи.
+ Все, что хорошо описано в кодлабе, я не буду повторять здесь. Про базовые принципы, структуру приложения, дерево виджетов, логику формирования списка слов смотрите там.
Пользовательский ввод и навигацию хочу сохранять в бандл (помним, что есть только одна активити). Очевидно, нужна будет коммуникация между Dart и андроидом. Давайте разбираться, как ее наладить (документация). На стороне флаттера нужно создать MethodChannel:
save(String key) async {
const platform = const MethodChannel('app.channel.shared.data');
platform.invokeMethod("save", /* здесь данные для сохранения */);
}
И на стороне андроида создать MethodChannel c таким же именем:
MethodChannel(getFlutterView(), "app.channel.shared.data")
.setMethodCallHandler { call, result ->
if (call.method.contentEquals("save")) {
// здесь сохраняем данные
}
}
В каком формате сохранять/передавать данные? На стороне Dart это может быть что угодно, и через MethodChannel можно передать какой угодно тип. Но на стороне андроида мне хочется иметь дело с чем-то единообразным, что я буду класть в Bundle. Для начала попробую данные (чем бы они ни были) в json, json в строки, строки в бандл.
Сохраняем ввод
Сначала разберемся с пользовательским вводом. Заведем абстрактный класс, который будет хранить в себе данные касательно состояния:
abstract class Restorable {
save(String key);
Future restore(String key);
}
Аргумент key требуется для соотнесения определенного Restorable с определенным виджетом. То есть при создании виджетов нужно будет выдавать им уникальные ключи.
Реализация для сохранения позиции скролла будет выглядеть так:
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 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;
}
}
Чтобы не писать сериализацию руками, я пользуюсь библиотекой json_annotation. Как пользоваться, описано на сайте флаттера.
На стороне андроида в активити заведем поле для хранения данных:
var savedFromFlutter: MutableMap = mutableMapOf()
В onCreate пробрасываем методы:
MethodChannel(getFlutterView(), "app.channel.shared.data").setMethodCallHandler { call, result ->
if (call.method.contentEquals("save")) {
savedModels.put(call.argument("key"), call.argument("value"))
} else if (call.method.contentEquals("read")) {
result.success(savedModels.get(call.argument("key")))
}
}
И делаем сохранение/восстановление:
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("savedFromFlutter"))
}
Теперь нужно научить виджет сохраняться и восстанавливаться с помощью Restorable. В нашем примере есть виджет RandomWords:
class RandomWords extends StatefulWidget {
@override
createState() => new RandomWordsState();
}
А его состояние выглядит так:
class RandomWordsState extends State {
final _suggestions = [];
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]);
}
);
}
}
При создании виджета будем ему передавать ключ:
class RandomWords extends StatefulWidget {
final String stateKey;
RandomWords(this.stateKey);
@override
createState() => new RandomWordsState();
}
В RandomWordsState заведем поле под состояние:
class RandomWordsState extends State {
RandomWordsInput input = new RandomWordsInput();
RandomWordsState() {
_init();
}
// …
}
Чтобы управлять позицией скролла, понадобится ScrollController:
final ScrollController scrollController = new ScrollController();
Функция _init () будет читать сохраненное состояние и передвигать скролл на позицию:
_init() async {
RandomWordsInput newInput = await model.read(widget.stateKey);
setState(() {
input = newInput;
scrollController.jumpTo(input.scrollPosition);
});
}
Функция для построения виджета изменяется следующим образом:
Widget _buildSuggestions() {
return
new NotificationListener(
onNotification: _onNotification,
child: new ListView.builder(
padding: const EdgeInsets.all(16.0),
controller: scrollController,
itemBuilder: (context, i) {
// …
}
),);
}
Функция _onNotification обновляет позицию скролла:
_onNotification(Notification n) {
input.scrollPosition = scrollController.position.pixels;
input.save(widget.modelKey);
}
Cоздается этот виджет теперь с указанием ключа:
class _MyHomePageState extends State {
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: new RandomWords("list"),
);
}
}
Теперь позиция скролла сохраняется между перезапусками активити, ура.
Сохраняем навигацию
Для начала немного перепишем переход на другой экран в нашем примере, будем использовать именованные роуты (описано в документации).
Перечисляем роуты (у нас всего один) при создании приложения:
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: {
'/saved': (BuildContext context) =>
new SavedPage(title: 'Saved Suggestions'),
},
);
}
}
Заведем класс, который будет сохранять в себе историю переходов:
class Routes {
static Queue routes = new Queue();
static var _firstTime = true;
}
Как и в Restorable, сделаем методы для сохранения и восстановления:
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();
routes.addAll(JSON.decode(s));
}
_firstTime = false;
for (String route in routes) {
Navigator.of(context).pushNamed(route);
}
}
То есть при восстановлении мы просто берем все сохраненные роуты и по ним восстанавливаем цепочку экранов.
Осталось при переходе на экран с избранным сохранять роут, а при возвращении назад убирать его. С переходом все просто, редактируем функцию, которая это делает:
void _pushSaved() async {
Routes.routes.addLast('/saved');
await Routes.save();
Navigator.of(context).pushNamed('/saved');
}
С возвращением чуть хитрее. Чтобы отловить момент, когда пользователь нажимает «Назад», надо обернуть виджет на экране с «избранным» в WillPopScope. И еще завести функцию (здесь _onWillPop), которая будет обрабатывать нажатие «Назад»:
class _SavedPageState extends State {
@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 _onWillPop() async {
Routes.routes.removeLast();
await Routes.save();
return true;
}
}
И еще нужно восстанавливать историю переходов. Сделаем это на главном экране:
class _MyHomePageState extends State {
@override
Widget build(BuildContext context) {
Routes.restore(context);
// …
}
// …
}
Это все! Теперь сохраняется и навигация, и позиция скролла.
Использовать ли Flutter
Мне кажется, отсутствие сохранения навигации из коробки — это очень странно. С сохранением пользовательского ввода можно еще поспорить. Вдруг кто-то считает, что его вполне устраивает сохранение при перевороте девайса и потеря при разрушении активити. Меня не устраивает.
Будут ли разработчики флаттера с этим что-то решать — пока непонятно, но на их гитхабе весьма активные словесные баталии.
На данный момент вполне реально сделать сохранение состояния самостоятельно. Хотя, конечно, это выглядит как too much boilerplate.
У меня пока что есть желание поизучать флаттер дальше, и поглядеть, развеются ли остальные мои сомнения. А потом уже решать насчет его применимости.