Flutter BloC паттерн + Provider + тесты + запоминаем состояние

Эта статья выросла из публикации «BLoC паттерн на простом примере» где мы разобрались, что это за паттерн и как его применить в классическом простом примере счетчика.

По комментам и для своего лучшего понимания я решил попробовать написать приложение в котором будут получены ответы на вопросы:


  1. Как передавать состояние класса в котором находится BloC по всему приложению
  2. Как написать тесты для этого паттерна
  3. (дополнительный вопрос) Как сохранить состояние данных между запусками приложения оставаясь в рамках BLoC паттерна

Ниже анимашка получившегося примера, а под катом разбор полетов :)

И ещё в конце статьи интересная задачка — как модифицировать приложение для применения Debounce оператора из ReactiveX паттерна (если точнее, то reactiveX — расширение Observer pattern)


yi5dvd5xtr5lopselzm4tywl8ey.gif

Описание приложения и базового кода

Не имеет отношения к BLoC и Provider


  1. В приложении есть кнопочки ± и работают свайпы, которые дублируют эти кнопки
  2. Анимация сделана через встроенный во flutter mixin — TickerProviderStateMixin

Связано с BLoC и Provider


  1. Два экрана — на первом свайпаем, на втором отображаются изменения счетчика
  2. Записываем состояние в постоянное хранилище телефона (iOS & Android, пакет https://pub.dev/packages/shared_preferences)
  3. Запись и считывание информации из постоянного хранилища асинхронная, тоже делаем через BLoC


Пишем приложение

Как следует из определения паттерна BLoC наша задача убрать из виджетов всю логику и работать с данными через класс в котором все входы и выходы — Streams.

При этом, так как класс в котором находится BLoC используется на разных экранах, то нам надо передавать объект созданный из этого класса по всему приложению.

Для этого есть разные методы, а именно:


  1. Передача через конструкторы классов, так называемый lifting state up. Не будем использовать, так как очень запутанно получается, потом не отследить передачи состояний.
  2. Сделать из класса где у нас BLoC синглтон и импортировать его где нам нужно. Это просто и удобно, но, с моей сугубо личной точки зрения, усложняет конструктор класса и немного запутывает логику.
  3. Использовать пакет Provider — который рекомендуется командой Flutter для управления состояниями. См. видео https://youtu.be/d_m5csmrf7I

В данном примере мы будем использовать Provider — привести пример всех методов не хватило сил :)

Общая структура

Итак, у нас есть класс

class SwipesBloc {
    // some stuff
}

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider(create: (_) => SwipesBloc()),
      ],
      child: MaterialApp(
        title: 'Swipe BLoC + Provider',

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

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

Класс для BLoC

Для этого мы создаем класс BLoC, в котором описываем не только потоки, но и получение и запись состояния из постоянного хранилища телефона.

import 'dart:async';
import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart';

class SwipesBloc {
  Future prefs = SharedPreferences.getInstance();
  int _counter;

  SwipesBloc() {
    prefs.then((val) {
      if (val.get('count') != null) {
        _counter = val.getInt('count') ?? 1;
      } else {
        _counter = 1;
      }
      _actionController.stream.listen(_changeStream);
      _addValue.add(_counter);
    });
  }

  final _counterStream = BehaviorSubject.seeded(1);

  Stream get pressedCount => _counterStream.stream;
  void get resetCount => _actionController.sink.add(null);
  Sink get _addValue => _counterStream.sink;

  StreamController _actionController = StreamController();
  StreamSink get incrementCounter => _actionController.sink;

  void _changeStream(data) async {
    if (data == null) {
      _counter = 1;
    } else {
      _counter = _counter + data;
    }
    _addValue.add(_counter);
    prefs.then((val) {
      val.setInt('count', _counter);
    });
  }

  void dispose() {
    _counterStream.close();
    _actionController.close();
  }
}

Если мы внимательно посмотрим на этот класс, то увидим, что:


  1. Любые свойства доступные снаружи — входы и выходы в Streams.
  2. В конструкторе при первом запуске мы пытаемся получить данные из постоянного хранилища телефона.
  3. Удобно сделана запись в постоянное хранилище телефона

Маленькие задачки для лучшего понимания:


  • Вынести из конструктора кусок кода с .then — красивее сделать отдельный метод.
  • Попробовать реализовать этот класс без провайдера как Singleton

Получаем и передаем данные в приложении

Теперь нам надо передать данные в Stream при нажатии кнопочек или свайпе и получить эти данные на карточке и на отдельном экране.

Есть разные варианты как это сделать, я выбрал классический, мы оборачиваем те части дерева, где нужно получать \ передавать данные в Consumer

return Scaffold(
      body: Consumer(
        builder: (context, _swipesBloc, child) {
          return StreamBuilder(
            stream: _swipesBloc.pressedCount,
            builder: (context, snapshot) {
              String counterValue = snapshot.data.toString();

              return Stack(
                children: [
                  Container(

Ну и далее получение данных
_swipesBloc.pressedCount,

Передача данных
_swipesBloc.incrementCounter.add(1);

Вот и все, мы получили понятный и расширяемый код в правилах BLoC паттерна.

Рабочий пример https://github.com/awaik/swipe_bloc_flutter


Тесты

Тестировать можно виджеты, можно делать моки, можно e2e.

Мы протестим виджеты и запустим приложение с проверкой как сработало увеличение счетчика. Информация по тестам тут и тут.

Тестирование виджетов

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

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

Для запуска теста используем команду
flutter test

Integration tests (Интеграционные тесты)

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

Для этого мы создаем 2 файла:

test_driver/app.dart
test_driver/app_test.dart

В первом подключаем что нужно, а во втором непосредственно тесты. Для примера я сделал проверки:


  • Начального состояния
  • Увеличения счетчика после нажатия кнопочки
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  group(
    'park-flutter app',
    () {
      final counterTextFinder = find.byValueKey('counterKey');
      final buttonFinder = find.byValueKey('incrementPlusButton');

      FlutterDriver driver;
      setUpAll(() async {
        driver = await FlutterDriver.connect();
      });

      tearDownAll(() async {
        if (driver != null) {
          driver.close();
        }
      });

      test('test init value', () async {
        expect(await driver.getText(counterTextFinder), ^_^quotquot^_^);
      });

      test('test + 1 value after tapped', () async {
        await driver.tap(buttonFinder);
        // Then, verify the counter text is incremented by 1.
        expect(await driver.getText(counterTextFinder), ^_^quotquot^_^);
      });
    },
  );
}

Код там же https://github.com/awaik/swipe_bloc_flutter

Для запуска теста используем команду
flutter drive --target=test_driver/app.dart


Задача.

Просто для углубления понимания. В современных приложениях (сайтах) часто используется функция Debounce из ReactiveX.

Например:


  1. В строке поиска вводят слово и подсказка вываливается только когда зазор между набором букв более 2 секунд
  2. Когда ставятся лайки, то можно щелкать 10 раз в секунду — запись в базу произойдет если разрыв в щелканьях был более 2–3 секунд
  3. … и т.п.

Задача: сделать чтобы цифра менялась только если между нажатиями на + или — прошло более 2 секунд. Для этого править только BLoC класс, весь остальной код должен остаться тем же самым.

=========

Вот и все. Если что-то криво или неправильно, поправляйте тут или на github, попробуем достичь идеала :)

Всем хорошего кодинга!

© Habrahabr.ru