Эта статья выросла из публикации “BLoC паттерн на простом примере” где мы разобрались, что это за паттерн и как его применить в классическом простом примере счетчика.
По комментам и для своего лучшего понимания я решил попробовать написать приложение в котором будут получены ответы на вопросы:
- Как передавать состояние класса в котором находится BloC по всему приложению
- Как написать тесты для этого паттерна
- (дополнительный вопрос) Как сохранить состояние данных между запусками приложения оставаясь в рамках BLoC паттерна
Ниже анимашка получившегося примера, а под катом разбор полетов :)
И ещё в конце статьи интересная задачка — как модифицировать приложение для применения Debounce оператора из ReactiveX паттерна (если точнее, то reactiveX — расширение Observer pattern)
Описание приложения и базового кода
Не имеет отношения к BLoC и Provider
- В приложении есть кнопочки +- и работают свайпы, которые дублируют эти кнопки
- Анимация сделана через встроенный во flutter mixin — TickerProviderStateMixin
Связано с BLoC и Provider
- Два экрана — на первом свайпаем, на втором отображаются изменения счетчика
- Записываем состояние в постоянное хранилище телефона (iOS & Android, пакет https://pub.dev/packages/shared_preferences)
- Запись и считывание информации из постоянного хранилища асинхронная, тоже делаем через BLoC
Пишем приложение
Как следует из определения паттерна BLoC наша задача убрать из виджетов всю логику и работать с данными через класс в котором все входы и выходы — Streams.
При этом, так как класс в котором находится BLoC используется на разных экранах, то нам надо передавать объект созданный из этого класса по всему приложению.
Для этого есть разные методы, а именно:
- Передача через конструкторы классов, так называемый lifting state up. Не будем использовать, так как очень запутанно получается, потом не отследить передачи состояний.
- Сделать из класса где у нас BLoC синглтон и импортировать его где нам нужно. Это просто и удобно, но, с моей сугубо личной точки зрения, усложняет конструктор класса и немного запутывает логику.
- Использовать пакет 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();
}
}
Если мы внимательно посмотрим на этот класс, то увидим, что:
- Любые свойства доступные снаружи — входы и выходы в Streams.
- В конструкторе при первом запуске мы пытаемся получить данные из постоянного хранилища телефона.
- Удобно сделана запись в постоянное хранилище телефона
Маленькие задачки для лучшего понимания:
- Вынести из конструктора кусок кода с .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.
Например:
- В строке поиска вводят слово и подсказка вываливается только когда зазор между набором букв более 2 секунд
- Когда ставятся лайки, то можно щелкать 10 раз в секунду — запись в базу произойдет если разрыв в щелканьях был более 2-3 секунд
- … и т.п.
Задача: сделать чтобы цифра менялась только если между нажатиями на + или — прошло более 2 секунд. Для этого править только BLoC класс, весь остальной код должен остаться тем же самым.
Вот и все. Если что-то криво или неправильно, поправляйте тут или на github, попробуем достичь идеала :)
Всем хорошего кодинга!