Управление состоянием в приложениях на Flutter


    Общие принципы


    Flutter — реактивный фреймворк, и для разработчика, специализирующегося на нативной разработке, его философия может быть непривычна. Поэтому начнём с небольшого обзора.


    Пользовательский интерфейс на Flutter, как и в большинстве современных фреймворков, состоит из дерева компонентов (виджетов). При изменении какого-либо компонента, происходит перерендеринг этого и всех его дочерних компонентов (с внутренними оптимизациями, о которых ниже). При глобальном изменении отображения (например, повороте экрана), перерисовывается всё дерево виджетов.


    Этот подход может показаться неэффективным, но на самом деле он передаёт программисту контроль за скоростью работы. Если производить обновление интерфейса на самом верхнем уровне без необходимости — всё будет работать медленно, но при правильной компоновке виджетов, приложения на Flutter могут быть очень быстрыми.


    Во Flutter существует два типа виджетов — Stateless и Stateful. Первые (аналог Pure Components в React) не имеют состояния и полностью описываются своими параметрами. Если не меняются условия отображения (скажем, размер области, в которой должен показываться виджет) и его параметры, система переиспользует ранее созданное визуальное представление виджета, поэтому использование Stateless виджетов хорошо сказывается на производительности. При этом всё равно при каждой перерисовке виджета формально создаётся новый объект и запускается конструктор.


    Stateful виджеты сохраняют некоторое состояние между рендерингами. Для этого они описываются двумя классами. Первый из классов, собственно виджет, описывает объекты, которые создаются при каждой отрисовке. Второй класс, описывает состояние виджета и его объекты передаются в создаваемые объекты виджета. Изменение состояния Stateful виджетов является основным источником перерисовки интерфейсов. Для этого нужно изменить его свойства внутри вызова метода SetState. Таким образом, в отличие от многих других фреймворков, во Flutter нет неявного отслеживания состояния — любое изменение свойств виджета вне метода SetState не приводит к перерисовке интерфейса.


    Теперь, после описания основ, можно начать с простого приложения, использующего Stateless и Stateful виджеты:


    Базовое приложение
    import 'dart:math';
    
    import 'package:flutter/material.dart';
    
    void main() => runApp(new MyApp());
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return new MaterialApp(
          title: 'Flutter Demo',
          theme: new ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: Scaffold(
            appBar: AppBar(
              title: Text('Sample app'),
            ),
            body: new MyHomePage(),
          ),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      @override
      _MyHomePageState createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
    
      Random rand = Random();
    
      @override
      Widget build(BuildContext context) {
        return new ListView.builder(itemBuilder: (BuildContext context, int index) {
          return Text('Random number ${rand.nextInt(100)}',);
        });
      }
    }

    Полный пример


    Результат


    Если нужны более живучие состояния


    Идём дальше. Состояние Stateful виджетов сохраняется между перерисовками интерфейсов, но только до тех пор, пока виджет нужен, т.е. реально находится на экране. Проведём простой эксперимент — разместим наш список на вкладке:


    Приложение с вкладками
    class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
    
      Random rand = Random();
    
      TabController _tabController;
    
      final List<Tab> myTabs = <Tab>[
        new Tab(text: 'FIRST'),
        new Tab(text: 'SECOND'),
      ];
    
      @override
      void initState() {
        super.initState();
        _tabController = new TabController(vsync: this, length: myTabs.length);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
            title: Text('Sample app'),
        ),
        body: new TabBarView(
            controller: _tabController,
            children: [
              new ListView.builder(itemBuilder: (BuildContext context, int index) {
                return Text('Random number ${rand.nextInt(100)}',);
              }),
              Text('Second tab'),
            ],),
          bottomNavigationBar: new TabBar(
            controller: _tabController,
            tabs: myTabs,
            labelColor: Colors.blue,
          ),
        );
      }
    }

    Полный пример


    Результат


    При запуске можно увидеть, что при переключении между вкладками, состояние удаляется (вызывается метод dispose()), при возврате создаётся снова (метод initState()). Это разумно, так как хранение состояния неотображаемых виджетов будет отнимать ресурсы системы. В том случае, когда состояние виджета должно переживать его полное скрытие, возможны несколько подходов:


    Во-первых, можно использовать отдельные объекты (ViewModel) для хранения состояния. Dart на уровне языка поддерживает фабричные конструкторы, которые можно использовать для создания фабрик и синглтонов, хранящих необходимые данные.


    Мне больше нравится этот подход, т.к. он позволяет изолировать бизнес-логику от пользовательского интерфейса. Это особенно актуально в связи с тем, что Flutter Release Preview 2 добавил возможность создавать pixel-perfect интерфейсы для iOS, но делать это нужно, разумеется, на соответствующих виджетах.


    Во-вторых, можно использовать знакомый программистам React подход поднятия состояния, когда данные хранятся в компонентах, расположенных выше по дереву. Поскольку Flutter перерисовывает интерфейс только при вызове метода setState(), эти данные можно менять и использовать без рендеринга. Такой подход несколько более сложен и повышает связность виджетов в структуре, но позволяет точечно задавать уровень хранения данных.


    Наконец существуют библиотеки хранения состояния, например flutter_redux.


    Для простоты используем первый подход. Сделаем отдельный класс ListData, синглтон, хранящий значения для нашего списка. При отображении будем использовать этот класс.


    Приложение с вкладками и восстановлением данных
    class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
    
      TabController _tabController;
    
      final List<Tab> myTabs = <Tab>[
        new Tab(text: 'FIRST'),
        new Tab(text: 'SECOND'),
      ];
    
      @override
      void initState() {
        super.initState();
        _tabController = new TabController(vsync: this, length: myTabs.length);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
            title: Text('Sample app'),
        ),
        body: new TabBarView(
            controller: _tabController,
            children: [
              new ListView.builder(itemBuilder: ListData().build),
              Text('Second tab'),
            ],),
          bottomNavigationBar: new TabBar(
            controller: _tabController,
            tabs: myTabs,
            labelColor: Colors.blue,
          ),
        );
      }
    }
    
    class ListData {
      static ListData _instance = ListData._internal();
    
      ListData._internal();
    
      factory ListData() {
        return _instance;
      }
    
      Random _rand = Random();
      Map<int, int> _values = new Map();
    
      Widget build (BuildContext context, int index) {
        if (!_values.containsKey(index)) {
          _values[index] = _rand.nextInt(100);
        }
    
        return Text('Random number ${_values[index]}',);
      }
    }

    Полный пример


    Результат


    Сохранение позиции скролла


    Если скрутить список из предыдущего примера вниз, потом перейти между вкладками, нетрудно заметить, что позиция прокрутки не сохраняется. Это логично, так как в нашем классе ListData она не хранится, а собственное состояние виджета не переживает переключение между табами. Реализуем хранение состояния прокрутки вручную, но для интереса сложим её не в отдельный класс и не в ListData, а в состояние более высокого уровня, чтобы показать, как с этим работать.


    Обратите внимание на виджеты ScrollController и NotificationListener (а также ранее использованный DefaultTabController). Концепция виджетов, не имеющих своего отображения должна быть знакома разработчикам, работающим с React/Redux — в этой связке активно используются компоненты-контейнеры. Во Flutter виджеты без отображения обычно используются для добавления функциональности к дочерним виджетам. Это позволяет оставить сами визуальные виджеты легковесными и не обрабатывать системные события там, где они не нужны.


    Код основан на решении, предложенном Marcin Szałek на Stakoverflow ( https://stackoverflow.com/questions/45341721/flutter-listview-inside-on-a-tabbarview-loses-its-scroll-position ). План таков:


    1. Добавляем к списку ScrollController, чтобы работать с положением прокрутки.
    2. Добавляем к списку NotificationListener, чтобы передавать состояние прокрутки.
    3. Сохраняем положение прокрутки в _MyHomePageState (которое находится по уровню выше табов) и связываем его с прокруткой списка.

    Приложение с сохранением положения прокрутки
    class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
    
      double listViewOffset=0.0;
    
      TabController _tabController;
    
      final List<Tab> myTabs = <Tab>[
        new Tab(text: 'FIRST'),
        new Tab(text: 'SECOND'),
      ];
    
      @override
      void initState() {
        super.initState();
        _tabController = new TabController(vsync: this, length: myTabs.length);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
            title: Text('Sample app'),
        ),
        body: new TabBarView(
            controller: _tabController,
            children: [new ListTab(
              getOffsetMethod: () => listViewOffset,
              setOffsetMethod: (offset) => this.listViewOffset = offset,
            ),
              Text('Second tab'),
            ],),
          bottomNavigationBar: new TabBar(
            controller: _tabController,
            tabs: myTabs,
            labelColor: Colors.blue,
          ),
        );
      }
    }
    
    class ListTab extends StatefulWidget {
    
      ListTab({Key key, this.getOffsetMethod, this.setOffsetMethod}) : super(key: key);
    
      final GetOffsetMethod getOffsetMethod;
      final SetOffsetMethod setOffsetMethod;
    
      @override
      _ListTabState createState() => _ListTabState();
    }
    
    class _ListTabState extends State<ListTab> {
    
      ScrollController scrollController;
    
      @override
      void initState() {
        super.initState();
        //Init scrolling to preserve it
        scrollController = new ScrollController(
            initialScrollOffset: widget.getOffsetMethod()
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return
          NotificationListener(
            child: new ListView.builder(
              controller: scrollController,
              itemBuilder: ListData().build,
            ),
            onNotification: (notification) {
              if (notification is ScrollNotification) {
                widget.setOffsetMethod(notification.metrics.pixels);
              }
            },
          );
      }
    }

    Полный пример


    Результат


    Переживаем выключение приложения


    Сохранение информации на время работы приложения — это хорошо, но часто хочется сохранять её и между сеансами, особенно учитывая привычку операционных систем закрывать фоновые приложения при нехватке памяти. Основные варианты постоянного хранения данных во Flutter это:


    1. Shared preferences ( https://pub.dartlang.org/packages/shared_preferences ) является обёрткой вокруг NSUserDefaults (на iOS) и SharedPreferences (на Android) и позволяет хранить небольшое количество пар ключ-значение. Отлично подходит для хранения настроек.
    2. sqflite ( https://pub.dartlang.org/packages/sqflite ) — плагин для работы с SQLite (с некоторомы ограничениями). Поддерживает как низкоуровневые запросы, так и хелперы. Кроме того, по аналогии с Room позволяет работать с версиями схемы БД и задавать код для обновления схемы при обновлении приложения.
    3. Cloud Firestore ( https://pub.dartlang.org/packages/cloud_firestore ) — часть семейства официальных плагинов для работы с FireBase.

    Для демонстрации сделаем сохранение состояния прокрутки в Shared preferences. Для этого добавим восстановление позиции скролла при инициализации состояния _MyHomePageState и сохранение при прокрутке.


    Здесь нужно немного остановиться на асинхронной модели Flutter/Dart, поскольку все внешние службы работают на асинхронных вызовах. Принцип работы этой модели сходен с node.js — есть один основной поток выполнения (thread), который прерывается на асинхронные вызовы. На каждом следующем прерывании (а UI делает их постоянно) обрабатываются результаты завершённых асинхронных операций.При этом есть возможность запускать тяжеловесные вычисления в фоновых threads (через функцию compute).


    Итак, запись и чтение в SharedPreferences делаются асинхронно (хотя библиотека позволяет синхронное чтение из кэша). Для начала разберёмся с чтением. Стандартный подход к асинхронному получению данных выглядит так — запустить асинхронный процесс, по его завершению выполнить SetState, записав полученные значения. В результате пользовательский интерфейс будет обновлён с использованием полученных данных. Однако в данном случае мы работаем не с данными, а с положением прокрутки. Нам не нужно обновлять интерфейс, нужно только вызвать метод jumpTo у ScrollController. Проблема в том, что результат обработки асинхронного запроса может вернуться в любой момент и совсем не обязательно будет что и куда прокручивать. Чтобы гарантированно выполнить операцию на полностью инициализированном интерфейсе, нам нужно … всё-таки выполнить прокрутку внутри setState.


    Получаем примерно такой код:


    Установка состояния
      @override
      void initState() {
        super.initState();
        //Init scrolling to preserve it
        scrollController = new ScrollController(
            initialScrollOffset: widget.getOffsetMethod()
        );
    
        _restoreState().then((double value) => scrollController.jumpTo(value));
      }
    
      Future<double> _restoreState() async {
        SharedPreferences prefs = await SharedPreferences.getInstance();
        return prefs.getDouble('listViewOffset');
      }
    
      void setScroll(double value) {
        setState(() {
          scrollController.jumpTo(value);
        });
    }

    С записью всё интереснее. Дело в том, что в процессе прокрутки, сообщающие об этом события приходят постоянно. Запуск асинхронной записи при каждом изменении значения может привести к ошибкам приложения. Нам нужно обрабатывать только последнее событие из цепочки. В терминах реактивного программирования это называется debounce и его мы и будем использовать. Dart поддерживает основные возможности реактивного программирования через потоки данных (stream), соответственно нам нужно будет создать поток из обновлений позиции прокрутки и подписаться на него, преобразуя его с помощью Debounce. Для преобразования нам потребуется библиотека stream_transform. В качестве альтернативного подхода, можно использовать RxDart и работать в терминах ReactiveX.


    Получается такой код:


    Запись состояния
      StreamSubscription _stream;
      StreamController<double> _controller = new StreamController<double>.broadcast();
    
      @override
      void initState() {
        super.initState();
        _tabController = new TabController(vsync: this, length: myTabs.length);
    
        _stream = _controller.stream.transform(debounce(new Duration(milliseconds: 500))).listen(_saveState);
      }
    
      void _saveState(double value) async {
        SharedPreferences prefs = await SharedPreferences.getInstance();
        await prefs.setDouble('listViewOffset', value);
    }

    Полный пример

    Поделиться публикацией

    Комментарии 14

      +1
      Вторая статья за сегодня, к чему бы это? А как кстати приложение на Флаттере переживает смену ориентации смартфона?
        0
        Тут упомянуто. Виджеты перерисовываются, состояние (включая неявное, например положение скролла) — остаётся.
          0
          Тут тоже надо сохранять данные в бандл перед тем как активность будет уничтожена и извлекать при ее новом создании, или это делается как то иначе?
            0

            Flutter не поддерживает saveInstanceState. Он все просто держит в памяти до смерти процесса. При смерти процесса в фоне состояние потеряется.

              0
              А вот к примеру для разных экранов тоже не надо создавать отдельные файлы?
                0
                По желанию. Можно создавать, можно не создавать. Есть единая структура виджетов и даже то, что считать в нём экраном — можно менять. Например в приложении из примера я могу повесить навигатор на переключатель табов и в приложении кнопка «назад» будет путешествовать по истории переключения табов.
                  0
                  Я не так выразился, для разных размеров экранов?
                    0
                    И для разных размеров экранов тоже. Корневой виджет — приложение. Дальше мы хотим, например, для планшетов слева всегда показывать меню, а для смартфонов делать его условно отдельным экраном. Мы в отображении приложения проверяем ширину экрана и, в зависимости от неё, строим разные деревья виджетов.
              0
              Во Flutter нет ничего, похожего на концепцию активности.
          0
          Ух, прям праздник вчера был, аж 2 статьи про flutter.
            0
            Пробовал начать писать на нем приложение, заткнулся на том, что он не умеет навигацию хардварной клавиатурой, от слова совсем. Переключать фокус на другие элементы управления можно либо программно, либо тыкая в них пальцем.
            Это бы ещё ничего, можно написать логику программно, но вот например для чекбокса вообще отсутствует понятие фокуса, соответственно его переключать можно только пальцем.
            Вобщем, на этом и закончилось моё с ним знакомство, т.к. в той задаче нужна была как раз возможность навигации с клавиатуры.
            Может позже сделают :)
              0
              Здесь сразу несколько мыслей:

              1. В рамках философии Flutter, пользовательский интерфейс должен извлекать максимум из платформы. Так что поддержка клавиатурного ввода — это, скорее всего, будет отдельный набор виджетов. И вряд ли от разработчиков Flutter.

              2. Сделать такой набор виджетов, на самом деле, не так трудно самому. Flutter предоставляет колоссальные возможности по их кастомизации, как в части внешнего вида, так и в части поведения.

              3. Вообще это выглядит как не очень удачный выбор фреймворка. Flutter хорош для кросс-платформенной мобильной разработки. Если нужна разработка под конкретное устройство, да ещё и с нехарактерным для мобильных способом ввода — тут просится натив.
                0
                1. Нелогично делать отдельным набором, т.к. пользователь может, например, использовать приложение на ChromeOS или просто подключить Bluetooth-клавиатуру к смартфону.
                Впрочем, там есть открытые тикеты: github.com/flutter/flutter/issues/1608 про общую навигацию, и github.com/flutter/flutter/issues/11344 про кнопки «Next» и пр. на экранной клавиатуре.
                Просто, я так понимаю, ещё не дошли до реализации.

                2. В теории да, но на практике там ещё возникает проблема с тем, чтобы перехватить, например, кнопку «Tab» в поле ввода — нужно отменить ввод символа и переместить фокус на следующее поле. С фокусом проблем никаких, а вот перехватить ввод символа на данный момент невозможно, получается что просто обернуть типовое поле ввода не получится, нужно делать своё.
                Вобщем, реально, но слишком много работы получается.

                3. Ну так в натив и ушел. Но опять-таки, как раз ничего нехарактерного как минимум для Android тут нет, всё очень даже стандартное — телефоны с аппаратными клавишами существуют, пусть и не особо популярны.
                  +1
                  Беру свои слова назад. Возможность управления приложением с клавиатуры, оказывается — часть рекомендаций Google по accessibility, а значит должна быть реализована в обозримом будущем.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое