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

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


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


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

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


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




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


    Не имеет отношения к 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 для управления состояниями. См. видео

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


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


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


    class SwipesBloc {
        // some stuff
    }

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


    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MultiProvider(
          providers: [
            Provider<SwipesBloc>(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<SharedPreferences> 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<int>.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<SwipesBloc>(
            builder: (context, _swipesBloc, child) {
              return StreamBuilder<int>(
                stream: _swipesBloc.pressedCount,
                builder: (context, snapshot) {
                  String counterValue = snapshot.data.toString();
    
                  return Stack(
                    children: <Widget>[
                      Container(

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


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


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


    Рабочий пример


    Тесты


    Тестировать можно виджеты, можно делать моки, можно 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^_^);
          });
        },
      );
    }

    Код там же


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


    Задача


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


    Например:


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

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




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


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

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

      +1

      В сторону hydrated_bloc для сохранения состояния не стали смотреть?

        0
        совсем не смотрел, в планах есть, но вряд ли скоро
        пока на очереди graphQl и его кеширование
        (это мне в реальном проекте надо)
          0

          Расскажите пожалуйста прт его плюсы

            0
            В принципе всё описано здесь pub.dev/packages/hydrated_bloc
            Отличие от обычного Bloc в том, что нужно только переопределить fromJson, toJson; HydratedBloc сам выполняет сохранение состояния. Т.е. не нужно заниматься написанием «хранилищ».
              0
              это интересно, надо будет посмотреть
          0
          Где предполагается вызов dispose() для блока?
            0
            Классный вопрос, совсем это упустил. Порылся в provider, так как новая версия вышла совсем недавно, то нет готовых best practices.
            Однако разработчики сразу предусмотрели этот момент и добавили callback. Пример, который получился ниже, github обновил.

            Provider(
            create: (_) => SwipesBloc(),
            dispose: (_, SwipesBloc swipesBloc) => swipesBloc.dispose(),
            ),

            0
            Что-то мне кажется, что не круто делать такое геттером
            void get resetCount => _actionController.sink.add(null);
              0
              Ох, давно я это писал :) Я бы там еще переписал отдельно shared preference — чтобы они независимо вызывались в репозитории и быстро менялись, например, на hive.

              Про getter ока не понимаю, почему не круто, если поясните или пример дадите как лучше, то можем поправить статью и репу.
                0
                Геттер придуман как способ для доступа к полям класса. Ожидается, что он должен что-то возвращать.
                  0
                  Спасибо, согласен. Чуть позже запуллю изменения в репозиторий и статью.

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

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