Pull to refresh

Value: библиотека реактивного программирования для Dart

Level of difficultyMedium
Reading time9 min
Views2.2K

Введение

Статья посвящена моей реализации идей реактивного программирования в Dart и Flutter. Рассказываю про разработанную мною библиотеку Value, которая в ряде случаев хорошо подходит для замены Stream, ValueNotifier и rxdart.

Основной фокус - на базовой сущности реактивного программирования: объекте, способном асинхронным образом сообщать своим подписчикам об изменении своего состояния.

Что не так со Stream?

Stream так или иначе используют все Dart программисты. Именно Stream первым приходит в голову, когда думаешь как добавить реактивности в Dart. Он настолько распространён, что даже получил поддержку со стороны синтаксиса языка: ключевые слова async*, yield.

Однако по-хорошему Stream предназначен немного для другой цели: быть транспортом для потока событий. «Из коробки» он не очень-то подходит для организации реактивного поведения объекта:

  1. Внешний потребитель, имея один только Stream, не может синхронно прочитать его текущее состояние. Нужно либо обращаться к асинхронному полю last, либо подписываться на Stream и ждать, когда придёт обновление.

  2. StreamController, через который предполагается посылать обновления в Stream, имеет переусложнённый синтаксис: Stream и StreamController это два разных объекта.

Казалось бы решение: rxdart

Проблему с чтением текущего состояния частично решает библиотека rxdart с классом BehaviorSubject, который, кстати, наследуется от Stream и тянет за собой многие его не всегда приятные особенности.

Функциональность библиотеки rxdart на первый взгляд обширная. Вот только на практике оказалось, что в реальной жизни нашей командой используется только малая её часть. Какой-то функциональности не хватает, но это ещё относительно просто исправить дописыванием своих классов поверх rxdart (но зачем тогда rxdart?). Где-то реализация далека от удобной для практического использования. Что опять же решается переписыванием под себя, но... ну вы поняли.

Rxdart предоставляет богатые возможности для трансформации потока (например, debounce), комбинации нескольких потоков (например, withLatestFromN) и много чего ещё. Но даже если на входе подать BehaviorSubject, то на выходе всё равно будет обычный Stream. В принципе этот подход работает, но на практике неудобен, можно сделать лучше и проще.

Не то. Что ещё есть?

ValueNotifier

ValueNotifier обширно используется внутри Flutter. Он простой и лёгкий внутри. Но как же так вышло, что обработчик в подписке на ValueNotifier не содержит текущего значения в аргументах метода? (там простой void Function())

А нужна ли вообще совместимость по принципу наследования или композиции с чем-либо уже существующим? Что если от неё отказаться и написать всё самостоятельно с чистого листа?

Так появилась библиотека Value.

Value

В основе лежит простая идея об объекте, на обновления состояния которого можно подписаться.

final x = Value(42); 
// Underlying тип определяется как int. 
// Ниже типы буду прописывать вручную для наглядности. 
// На практике их смело можно опускать.

final subscription = x.listen(print);
x.value = 137;
await Future.delayed(Duration.zero); // выведет "137"
subscription.cancel();

При инициализации обязательно задавать значение. Оно может быть в т.ч. и null, если underlying тип nullable. Это нужно, чтобы всегда иметь синхронный доступ к чтению текущего значения.

Обновлённое значение Value сразу доступно для чтения через геттер .value. Однако подписчики вызываются асинхронным образом. В подписчик передаётся значение на момент обновления Value через сеттер, а не на момент вызова подписчика, в ряде случаев это важно.

В приведённых примерах везде присутствуют кусочки асинхронного кода – чтобы дать Дарту поработать с очередью задач и, соответственно, фактически запустить обработчики подписок.

В случае возникновения исключений в обработчиках подписок они ловятся, и информация о них выводится в консоль. Но, конечно, пользовательский код надо строить таким образом, чтобы из обработчиков исключения не бросать.

Часто возникает необходимость передать право на чтение Value внешнему коду, но не право записи в него. На помощь приходит ReadonlyValue: родительский класс для Value. В нём, собственно, и реализована вся механика подписок, а Value лишь добавляет возможность записывать новое значение.

Часто встречается ситуация, когда в классе нужно объявить публичное свойство только для чтения, оставив внутри класса возможность для записи в него. Вот как это реализовать:

ReadonlyValue<int> get property => _property;
final _property = Value<int>(137);

По-умолчанию при попытке записи в Value значения, равного тому, что там уже содержится, обработчики подписок не будут вызваны, ведь фактически значение не изменилось. Для мутабельных типов, а также List и Map, такое поведение можно отключить, задав параметр distinctMode = false:

final x = Value<int>(42, distinctMode: false);

Подписки Value можно поставить на паузу, а затем запустить снова. Если во время паузы значение было обновлено один или более раз, обработчики подписок будут вызваны только единожды, а в аргументе будет передано значение, установленное последним.

final x = Value<int>(0);
final subscription = x.listen((update) => print(update));
x.value = 1; 
await Future.delayed(Duration.zero); // выведет "1"
x.pauseListeners();
x.value = 2; // не выведет ничего
x.value = 3; // не выведет ничего
x.value = 4; // не выведет ничего
x.resumeListeners();
await Future.delayed(Duration.zero); // выведет "4"

/// ...
subscription.cancel();

Не стоит ожидать от Value повторения функциональности Stream, это разные концепции. Оставим Stream для ситуаций, когда нужно воспроизвести историю обновлений. Value больше подходит для ситуаций, когда в первую очередь имеет значение последнее обновление и реакции не него. Конечно же обработчики всех обновлений гарантированно будут вызваны, причём строго в порядке добавления подписок и прихода обновлений. Но возможность постановки подписок на паузу, трансформации и distinctMode могут сделать логику вызова обработчиков менее очевидной.

Функциональностью Value можно пользоваться и без использования подписок. Полноценно эти возможности раскрываются при использовании трансформаций, но об этом ниже.

Хорошая практика использовать Value только с иммутабельными типами. Если undelying тип мутабельный, то появляется неприятная возможность тихонько изменить его состояние, и никто об этом не узнает. Но иногда без мутабельного типа не обойтись, и тогда об обновлении состояния объекта следует проинформировать подписки Value, явно вызвав для него notifyListeners().

ListValue и MapValue

ListValue<T> и MapValue<K, V> ни что иное как обёртки над Value<List<T>> и Value<Map<K, V>> с дополнительной функциональностью.

На практике оказалось удобнее работать именно с List и Map, а не c UnmodifiableListView и UnmodifiableMapView, т.е. получается, что underlying типы мутабельны. Большинство методов ListValue и MapValue просто добавляет вызовы notifyListeners() к стандартным операциям над List и Map. Если приучить себя не изменять состояние List и Map напрямую, а пользоваться соответствующими операторами/методами ListValue и MapValue, это своего рода гарантия, что подписки сработают как надо.

Трансформации Value

Рассмотрим пример.

class MyObject {
  MyObject(this.x, this.y);
  final int x;
  final String y;
}

class MyReadonlyService {
  MyReadonlyService(this.number);
  final ReadonlyValue<int> number;
}

final myValue = Value<MyObject>(MyObject(137, "text"));
final transformedValue = myValue.transform<int>((obj) => obj.x);
final readonlyService = MyReadonlyService(transformedValue);

MyReadonlyService требует в качестве параметра ReadonlyValue<int> а у нас есть только Value<MyObject>. Мы можем преобразовать одно к другому при помощи метода расширения transform() над ReadonlyValue. Трансформация получается односторонней и результат доступен только для чтения.

Помимо всего прочего, такой подход позволяет обойтись без зависимости MyReadonlyService от MyObject, если они объявлены в разных областях видимости.

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

class MyService {
  MyService(this.n);
  final Value<int> n;
}

final service = MyService(myValue.twoWayTransform<int>(
  (obj) => obj.x,
  (update) => MyObject(update, myValue.value.y),
));

Бывает, что в распоряжении есть только ReadonlyValue, а код, куда мы его хотим передать, требует полноценного Value, потому что мы там собирается его изменять. При том мы знаем, как корректно обрабатывать обновления в этом Value. Или, например, при изменении значения Value нужно произвести дополнительные действия, не ограничивающиеся простым преобразованием. В таком случае можно воспользоваться методом расширения transformWithMethodUpdate(). Он превращает ReadonlyValue в Value, делегируя операцию обновления пользователю.

final serviceWithMethodUpdate = MyService(myValue.transformWithMethodUpdate<int>(
  (obj) => obj.x,
  (update) {
    myValue.value = MyObject(update, myValue.value.y);
    // + что-нибудь ещё, например сохранение myValue в файл
  },
));

Похожего эффекта можно добиться и просто на подписках, но с transformWithMethodUpdate() код получается проще и короче.

Трансформации сами по себе не создают дополнительных подписок и, соответственно, необходимости их отменять.

TimeoutValue

Идея простая: если Value долго не обновлялся, записать в него null.

TimeoutValue - приватный класс, доступный только через метод расширения timeout() над ReadonlyValue. Так сделано, потому что создавать новый экземпляр TimeoutValue без прототипа смысла нет.

final timeoutValue = myValue.timeout(Duration(seconds: 10));

ThrottleValue

Если обновления Value приходят слишком часто, часть из них можно проигнорировать и запускать обработчики подписок не чаще чем 2 раза в секунду:

final throttleValue = myValue.throttle(Duration(milliseconds: 500));

DebounceValue

final debounceValue = myValue.debounce(Duration(seconds: 1));

Подписчики debounceValue будут вызваны только когда после последнего обновления пройдёт более 1 секунды.

В отличие от многочисленных трансформаций в rxdart, созданных "на всякий случай", вышеперечисленные трансформации в Value выросли из практической необходимости и по факту перекрывают большинство реальных потребностей.

Совмещение нескольких Value

Часто возникает необходимость реагировать на изменения более чем в одном Value. На два и более Value можно специальным образом подписаться:

final intValue = Value<int>(42);
final doubleValue = Value<double>(137.036);

final subscription = combine2(
  intValue, 
  doubleValue, 
  action: (i, d) {
    print(i + d);
  },
  // sendNow: true,
  // triggeredBy: [intValue],
);

// ...
subscription.cancel();

Обработчик вызывается каждый раз, когда один из исходных Value обновляется.

Если передать параметр sendNow = true, обработчик также будет вызван сразу после создания подписки.

Полем triggeredBy можно регулировать, изменение значения каких именно Value приводят к вызову обработчика.

Методы combineN() возвращают объект подписки на Value, в таком виде совмещение Value используется чаще всего. Однако иногда требуется на выходе получить именно ReadonlyValue, например для дальнейшей трансформации, тогда на помощь приходит семейство классов CombinedValueN.

final ReadonlyValue<String> combinedValue = CombinedValue2<int, double, String>(
  intValue,
  doubleValue,
  (i, d) => "$i $d", // NB: i и d строго типизированы
);

Последний, в данном случае третий, параметр шаблона класса задаёт целевой тип, в который происходит преобразование при помощи заданного метода. На практике типы можно не указывать, тип combinedValue определяется автоматически по возвращаемому типу преобразования.

StreamValue

При необходимости Stream можно сконвертировать в ReadonlyValue.

final streamValue = StreamValue<String>(stream, initialValue: "");

При этом теряется возможность обработки ошибки в Stream (в Value эта концепция отсутствует), но можно через параметр errorBuilder задать преобразование ошибки в значение Value (например в null, если underlying тип nullable).

Конвертация из Stream в Value не 100% корректная, но и моделируемые процессы разные.

Rebuilder

Библиотека Value не зависит от Flutter и может работать в чисто Dart проектах. Однако странно было бы не использовать её и для UI тоже.

UI-часть представлена семейством классов Rebuilder и RebuilderN, выделенных в отдельный пакет rebuilder. Под капотом RebuilderN это ни что иное как StatefulWidget, который перерисовывается каждый раз, когда один из зависимых Value обновляется.

Rebuilder3(
  value1: stringValue,
  value2: intValue,
  value3: doubleValue,
  builder: (context, str, i, d) => Text("$str $i $d"),
  // noDataBuilder: (context) => Text("no data"),
),

При необходимости с помощью noDataBuilder можно задать заглушку, которая будет отображаться, если в любом из Value окажется значение null. По умолчанию же в таком случае возвращается пустой SizedBox.

По сравнению с StreamBuilder код для UI с Rebuilder получается более чистым и коротким.

// Вариант на основе StreamBuilder
StreamBuilder<String>(
  stream: myStream, // myStream is Stream<String>
  builder: (context, snapshot) {
    if (!snapshot.hasData)
      return SizedBox();

    final text = snapshot.data;
    return Text(text);
  }
),

// сравните с вариантом на основе Rebuilder
Rebuilder(
  value: strValue, // stringValue is ReadonlyValue<String>
  builder: (context, text) => Text(text),
),

Опыт практического использования

Приведённой простой функциональности оказалось достаточно, чтобы на её основе построить в приложении довольно сложную систему взаимодействий в реактивном стиле: как в UI, так и в бизнес-логике. Помимо широкого непосредственного применения, у нас на основе Value также написаны абстракции для работы с настройками приложения и для сложного взаимодействия между изолятами (RPC + двусторонний проброс состояния реактивных переменных). Структурная простота Value позволяет писать простой и понятный код.

Библиотека является частью моего рабочего проекта уже три года и сэкономила прилично времени и ресурсов. За это время были вычищены баги и оптимизирована логика работы.

Использование готовых пакетов с pub.dev сильно ускорило разработку нашего продукта и повысило качество его реализации. Мы передаём в open source библиотеку Value в качестве благодарности сообществу разработчиков, и с надеждой на дальнейшее успешное развитие экосистемы Flutter.

Как попробовать?

Добавьте в pubspec.yaml вашего проекта:

dependencies:
  value:
    git: https://github.com/naviter/value

и начните эксперименты с кода, взятого из тестов Value.

Публикацию в pub.dev планирую, но попозже.

Tags:
Hubs:
Total votes 3: ↑3 and ↓0+3
Comments10

Articles