Close

31.10.2018

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

Наша компания работает с новой технологией Flutter для создания мобильных приложений.

Мы одни из первых на рынке предлагаем эту кросс-платформенную технологию разработки мобильных приложений сразу под Andoid и под iOS.

  • Скорость работы приложения,
  • высокий темп разработки,
  • высокая скорость анимации,
  • качество (типизированный язык защищает от многих ошибок)

— вот основные черты которые характеризуют эту технологию. Она развивается и поддерживается компанией Google и будет одна из первых поддержана в их новой мобильной ОС (хотелось бы верить).

Здесь мы хотим рассказать о технических приемах, возможно универсальных — как мы улучшили работу с состоянием, повысили скорость и управляемость разработки.

Что такое Flutter?

Flutter — это мобильный SDK от Google для создания пользовательского интерфейса. На github у него уже больше 39 тысяч звёздочек. Первый релиз состоялся 12 мая 2017 года. Сейчас фреймворк в бете с версией 0.10.1 и на нём уже пишут приложения: Hamilton, Alibaba, Google AdWords.

Уже существуют решения для написания кроссплатформенных мобильных интерфейсов: React Native, Ionic. Все они взаимодействуют либо с нативными компонентами, либо с DOM элементами в браузере, что вызывает задержки в отрисовке — сложную анимацию руками не реализовать. Flutter сам рисует весь интерфейс на canvas, что уменьшает задержки и позволяет получить стабильные 60 FPS даже на сложных анимированных экранах. У такого подхода есть и большой минус — нативные элементы встроить в разметку сложно или даже невозможно.

Flutter написан на Dart — языке программирования от Google. Используется вторая версия языка, в которой добавлена статическая типизация и выведение типов компилятором.

Flutter использует функциональное реактивное программирование (FRP) для связывания данных и представления, ближайший аналог — React Native. Компоненты здесь называются виджетами. Для получения более сложных виджетов используется композиция.

Работать с локальным состоянием в виджетах вполне удобно, но когда приложение становится большим, приходится выносить состояние вверх, и тут начинаются проблемы.

Проблемы работы с состоянием

Лучше всего сейчас для работы с состоянием подходит библиотека scoped_model, но у неё есть много недостатков.

Гранулярность уведомлений об изменениях

Нужно реализовать список комментариев. Отдельный комментарий описан классом Comment, список комментариев — Comments.

class Comment {
  String id;
  String text;
  String author;
  ...
}

class Comments extends Model {
  final items = List<Comment>();
  Comment get(String id) => items
    .firstWhere((comment) => comment.id == id);
  void edit(String id, String text) {
    get(id).text = text;
    notifyListeners();
  }
  ...
}

Отрисовка списка комментариев:

// CommentListView
Widget build(BuildContext context) {
  return ScopedModelDescendant<Comments>(
    builder: (context, child, model) => ListView(
      children: model.items
        .map((comment) => CommentView(comment.id))
        .toList()
    )
  );
}

// CommentView(String id)
Widget build(BuildContext context) {
  return ScopedModelDescendant<Comments>(
    builder: (context, child, model) {
      final comment = model.get(id);
      return ListView(
        title: Text(comment.text),
        subtitle: Text(comment.author),
      );
    }
  );
}

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

Чтобы устранить проблему, нужно выделить комментарии в отдельные модели и подписываться на каждый в отдельности по мере необходимости, не забывать отписываться и обновлять подписки при изменении виджетов. Этот подход быстро становится неудобным и может выйти из под контроля.

Производное состояние

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

Чтобы реализовать такое, есть три варианта:

  1. При каждой отрисовке пересчитывать всё заново. Это удобно, но не эффективно, а вместе с предыдущим недостатком может окончательно убить производительность приложения.
  2. Пересчитывать всё при каждом обновлении. Меньше влияет на производительность, чем первый способ, но менее удобен — приходится помнить, что и когда нужно обновить.
  3. Подписываться на необходимые кусочки модели и минимально обновлять производные данные. Самый неудобный и максимально эффективный способ. Часто приводит к багам вроде несуществующего непрочитанного сообщения.

Хочется всегда использовать третий способ, чтобы сделать жизнь пользователей лучше, но в реальности используется первый, потому что его проще реализовать и поддерживать.

Решение проблем

Чтобы решить описанные проблемы, мы  написали  библиотеку flutter_rxtable (/Александр Шилов). Пока она далека от релиза, но ей уже можно пользоваться.

Библиотека основана на идеях реляционных баз данных, реактивных потоков, как в RxJS, и неявных подписок на элементы модели, как в Vue.js.

Модель делится на таблицы, которые содержат исходное состояние, и отображения, которые содержат производные данные. Подписаться можно на отдельную строку таблицы, всю таблицу или на результат какого-то преобразования.

Связь с Flutter максимально упрощена, чтобы писать как можно меньше повторяющегося кода: не нужны виджеты-обёртки наподобие ScopedModelDescendant и явные уведомления об изменениях.

Гранулярность уведомлений об изменениях

Для решения той же задачи с комментариями нужно описать базу данных приложения:

class Comment {
  String id;
  String text;
  String author;
  ...
}

class AppDatabase extends Database {
 // Таблица комментариев. 
  // Первый аргумент - тип идентификатора строки.
  // Второй - тип самой строки.
  RxTable<String, Comment> comments;

  AppDatabase() {
    // Создание таблицы.
    // Первый аргумент - ссылка на базу данных для работы внутренних механизмов
    // Второй - функция, которая возвращает идентификатор для строки
    comments = RxTable(this, (r) => r.id);
  }

  // Этот метод позволяет упростить получение базы в виджетах.
  // После его вызова база начинает запоминать, какие данные были из неё получены.
  static AppDatabase of(BuildContext context) { 
    return Database.of<AppDatabase>(context);
  }
}

И использовать её в виджетах:

// CommentListView
Widget build(BuildContext context) {
  final db = AppDatabase.of(context);
  return ListView(
   children: db.comments.ids
      .map((id) => CommentView(id))
      .toList()
  );
}

// CommentView(String id)
Widget build(BuildContext context) {
  final db = AppDatabase.of(context);
  final comment = db.comments[id];
  return ListView(
    title: Text(comment.text),
    subtitle: Text(comment.author),
  );
}

После вызова AppDatabase.of(context) база начинает запоминать, какие данные были из неё получены. Когда прочитанные данные изменятся, БД вызовет метод BuildContext.scheduleRebuild(), который заставит виджет перерисоваться. При перерисовке виджет прочитает новые данные из базы, и цикл начнётся сначала.

Таблицы и отображения БД содержат методы для преобразования данных: map, filter, group, associate и терминальные методы: toList, toMap, toSet. Когда нужно не просто выбрать данные, а как-то их преобразовать, использование этих функций подскажет базе, при каких изменениях нужно вызвать перерисовку:

final userComments = db.comments
  .filter((comment) => comment.author == "user-1")
  .map((comment) => comment.id)
  .toList()

Такой запрос к базе создаст цепочку подписок: для filter, map и toList. Каждая будет уведомлять следующую, только если данные действительно изменились.

Пример 1:

  1. добавился комментарий Comment(“123”, “foobar”, “user-1”).
  2. filter проверил, что comment.user == “user-1” и пропустил изменение дальше.
  3. map увидел новый комментарий, выбрал его id и пропустил дальше.
  4. toList уведомил виджет о необходимости перерисовки.

Пример 2:

  1. текст комментария Comment(“123”, “foobar”, “user-1”) поменялся на “baz”.
  2. filter проверил, что comment.user == “user-1” и пропустил изменение дальше.

map снова выбрал id комментария, но он не изменился, так что дальше это уведомление не прошло.

Производное состояние

Решение задачи с агрегацией комментариев выглядит так:

final commentsByUser = comments
  .group((comment) => comment.author)
  .materialize(
    (group) => group.id, 
    sortedBy: (group) => group.values.length
  )

materialize — ещё один терминальный метод, который создаёт отображение данных из цепочки трансформаций. Все изменения в исходной таблице будут применяться к отображению, проходя через преобразования. Если продолжать аналогию с БД, то их можно назвать materialized view или индексами.

Отображения можно использовать точно так же как и таблицы:

Widget build(BuildContext context) {
  final db = AppDatabase.of(context);
  return ListView(
    children: db.commentsByUser.map((comments) {
      return ListTile(
        // Выводит: “Имя пользователя: количество комментариев”
        title: Text("${comments.id}: ${comments.values.length}")
      );
    }).toList()
  );
}

Запись данных

Добавлять, обновлять или удалять данные можно только из таблиц:

// создание нового комментария
comments.save(Comment("123", "foobar", "user-1"))

// обновление комментария с id == "123"
comments.save(Comment("123", "baz", "user-1"))

// удаление комментария по id
comments.deleteById("123")

Любые изменения в таблицах вызывают пересчёт производных данных и перерисовку виджетов. Библиотека содержит множество методов для удобного обновления состояния.

Особенности библиотеки

Синхронность подписок

За удобство использования подписок приходится платить цену.  Обращение к базе данных становится более неявным: просто обращаемся к БД подписки появляются сами. Но самая главная проблема — автоматические подписки работают только синхронно:

Widget build(BuildContext context) {
  final db = AppDatabase.of(context);
  // здесь подписки работают
  Final comments = db.comments.ids.toList();
  return SomeWidgetWithLambda(
    builder: (context) {
      // а здесь уже нет
      return db.comments.count;
    }
  )
}

Функция переданная в SomeWidgetWithLambda будет вызвана после завершения функции build, когда обращения к БД уже не будут фиксироваться. Если внимательно следить, как используется БД, то никаких проблем не будет.

Обновление состояния

Библиотеки вроде redux и scoped_model обычно предлагают какое-то архитектурное решение, где в коде нужно обновлять состояние. flutter_rxtable такого решения не даёт — данные можно обновлять где угодно, решать должен сам программист.

Заключение

Библиотека flutter_rxtable позволяет сильно сократить количество кода, необходимое для работы с состоянием приложения и при этом повышает производительность за счёт эффективных подписок и преобразований данных.

Эта библиотека сильно помогла при написании мобильной версии RedForester. В нём довольно сложная система сущностей: карты, узлы, типы узлов, типы свойств, комментарии. Данные сильно взаимосвязаны между собой: для редактирования узла может понадобиться карта, список её пользователей, список типов узлов. Без хорошей библиотеки можно довольно быстро запутаться в подписках, ручном индексировании и структуре приложения. От всего этого спасают идеи заложенные в библиотеке.

Ссылки:

 

Уже сейчас это позволяет делать быстро сложные, громоздкие по моделям приложения довольно быстро,  с малым числом глупых ошибок.
Достаточно описать экраны, модель, связать воедино, а рутинные операции по обновлению нужных частей интерфейса работают сами по себе.

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

Какие примеры с распределением состояния по компонентам и модели имеются у вас, как вы их решаете? 

 

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *