Development for iOS
Development of mobile applications
Development for Android
Flutter
September 2018 28

Управление состоянием в приложениях на 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);
}

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


+13
5.5k 45
Comments 14