Соблюдать принципы чистой архитектуры – значит обеспечить удобство тестирования, поддержки и модернизации приложения. Понимание архитектуры и state management – это база, необходимая начинающему специалисту для успешной командной работы. В этой статье мы расскажем, как с помощью Cubit реализовать чистую архитектуру на примере стартового приложения Flutter – счетчика нажатий на кнопку.
Подробнее о работе с фреймворком Flutter мы рассказывали в одной из прошлых статей. На данный момент на Flutter реализуют приложения для мобильных, веб-, настольных и встроенных устройств.
Библиотека Cubit предназначена для управления состоянием экрана и позволяет реализовать шаблон проектирования BLoC. С ее помощью можно упростить отделение презентации от бизнес-логики, тестирование и переиспользование кода.
Для начала отметим, что концепция чистой архитектуры, созданная Робертом Мартином, основана на выделении независимых слоев приложения:
Обычно приложение состоит из четырех слоев:
Internal – слой приложения, в котором происходит внедрение зависимостей;
Presenters – слой, в котором описывается визуальная составляющая окна и управление его состоянием;
Domain – слой бизнес-логики;
Data – слой, в котором описывается работа с источниками данных (интернет-запрос или база данных).
Также сами слои подразделяются на элементы:
data – элемент слоя data для работы с данными. На этом уровне, например, описываем работу с внешним API;
repository – элемент слоя data, который создает и возвращает данные из Data-слоя в виде Entity-объекта;
use case – элемент слоя domain, отвечающий за детализацию, описание действия, которое может совершить пользователь системы;
presenter – элемент слоя presentation, на этом уровне описывается state management;
UI – элемент слоя presentation, на этом уровне описываются визуальные элементы окна.
Эту схему не стоит воспринимать буквально: в отдельных проектах может отсутствовать Use Case, также state managеment может переходить из presenter в Use Case. Однако, слои остаются независимыми, что помогает упростить работу программиста.
Потоком данных на схеме является набор информации, перетекающий из одной части приложения в другую.
Мы преобразуем одно из простых приложений на Flutter с использованием чистой архитектуры и с возможностью сохранения данных счетчика. Добавим функционал сохранения данных для того, чтобы наглядно показать реализацию data слоя чистой архитектуры.
Создание проекта
Если вы еще не работали с Flutter, вы можете воспользоваться инструкцией и создать проект с помощью IDE или командной строки:
flutter create myapp
Вы создаете проект с примером счетчика нажатий. После удаления комментариев и переноса части кода в home_page.screen.dart получаете проект с примерно такой структурой:
main.dart
import 'package:clean_arch_example_cubit/home_page_screen.dart';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
home_page_screen.dart
import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
Теперь необходимо написать кубит для управления состоянием home_page_screen, который будет лежать в директории domain
Перенесем _counter из home_page_screen.dart в home_page_state.dart, а функцию _incrementCounter() в home_page_cubit
Распределим файлы по директориям, и в результате наш проект будет выглядеть следующим образом:
home_page_screen.dart
import 'package:clean_arch_example_cubit/presentation/bloc/home_page/home_page_cubit.dart';
import 'package:clean_arch_example_cubit/presentation/bloc/home_page/home_page_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final HomePageCubit cubit = HomePageCubit();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: BlocBuilder<HomePageCubit, HomePageState>(
bloc: cubit,
builder: (context, state) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'${state.count}',
style: Theme.of(context).textTheme.headline4,
),
],
);
},
)),
floatingActionButton: FloatingActionButton(
onPressed: cubit.incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
home_page_cubit.dart
import 'package:clean_arch_example_cubit/presentation/bloc/home_page/home_page_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HomePageCubit extends Cubit<HomePageState> {
HomePageCubit() : super(HomePageState(count: 0));
void incrementCounter() {
emit(HomePageState(count: state.count+1));
}
}
home_page_state.dart
class HomePageState {
final int count;
const HomePageState({required this.count});
}
В home_page_state необходимо сделать все объекты final, для того чтобы не было возможности редактировать существующий стейт. В противном случае при попытке выполнить emit() с измененным стейтом в кубите не будет изменений на экране.
Теперь создадим слой для работы с данными.
Для этого необходимо создать директорию data с поддиректориями repository, который будет хранить как абстрактный класс репозитория, так и его имплементацию.
От репозитория требуется 2 действия: получить последнее сохраненное значение и записать значение в базу для последующего извлечения.
Для этого создадим 2 метода:
int getLastCount();
Future<void> saveCount(int count);
Чтобы в приложении появилась возможность сохранять количество нажатий на кнопку, воспользуемся библиотекой hive. Добавим 2 библиотеки для работы с Hive: hive и path_provider
Напишем реализацию для CounterRepositoryImpl.dart
import 'package:clean_arch_example_cubit/data/repository/interface/counter_repo.dart';
import 'package:hive/hive.dart';
class CounterRepositoryImpl extends CounterRepository {
static const boxKey = 'counter';
final Box box;
CounterRepositoryImpl(this.box);
@override
int getLastCount() => box.get(boxKey, defaultValue: 0);
@override
Future<void> saveCount(int count) => box.put(boxKey, count);
}
Теперь нужно создать слой Domain с Use Case.
Для этого необходимо создать папку domain с use_cases, в которой мы выполним абстрактную часть и ее реализацию. Архитектура domain-слоя будет выглядеть следующим образом:
counter_case.dart содержит в себе абстрактную часть use case, в котором будет 2 метода для получения и сохранения значения счетчика
abstract class CounterCase{
int getLastCount();
Future<int> saveCount(int count);
}
Реализация (counter_case_imple.dart) будет выглядеть следующим образом:
import 'package:clean_arch_example_cubit/data/repository/interface/counter_repo.dart';
import 'package:clean_arch_example_cubit/domain/use_cases/interfaces/counter_case.dart';
class CounterCaseImpl extends CounterCase {
final CounterRepository _counterRepository;
CounterCaseImpl(this._counterRepository);
@override
int getLastCount() => _counterRepository.getLastCount();
@override
Future<int> saveCount(int count) => _counterRepository.saveCount(count);
}
Теперь добавим внедрение зависимостей. Для этого создадим класс-синглтон DI, в котором метод init будет реализовывать counterRepository, там же и сделаем инициализацию hive.
В итоге DI будет выглядеть следующим образом:
import 'package:clean_arch_example_cubit/data/repository/impl/counter_repo_impl.dart';
import 'package:clean_arch_example_cubit/data/repository/interface/counter_repo.dart';
import 'package:clean_arch_example_cubit/domain/use_cases/impl/counter_case_impl.dart';
import 'package:clean_arch_example_cubit/domain/use_cases/interfaces/counter_case.dart';
import 'package:hive/hive.dart';
import 'package:path_provider/path_provider.dart';
class DI {
static DI? instance;
late CounterRepository counterRepository;
late CounterCase counterCase;
DI._();
static DI getInstance() {
return instance ?? (instance = DI._());
}
Future<void> init() async {
final directory = await getApplicationSupportDirectory();
Hive.init(directory.path);
counterRepository = CounterRepositoryImpl(await Hive.openBox('counter'));
counterCase = CounterCaseImpl(counterRepository);
}
}
Инициализацию DI можно сделать через FutureBuilder при открытии приложения.
В итоге файл main.dart будет выглядеть следующим образом:
import 'package:clean_arch_example_cubit/di.dart';
import 'package:clean_arch_example_cubit/presentation/screen/home_page_screen.dart';
import 'package:flutter/material.dart';
void main() async {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: FutureBuilder(
future: DI.getInstance().init(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return MyHomePage(title: 'Flutter Demo Home Page');
}else{
return const CircularProgressIndicator();
}
},
),
);
}
}
Теперь необходимо дописать функционал инициализации home_page_cubit и обработку нажатия на кнопку прибавления счетчика
Код
import 'package:clean_arch_example_cubit/di.dart';
import 'package:clean_arch_example_cubit/presentation/bloc/home_page/home_page_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HomePageCubit extends Cubit<HomePageState> {
final counterCases = DI.getInstance().counterCase;
HomePageCubit() : super(HomePageState(count: 0)) {
emit(HomePageState(count: counterCases.getLastCount()));
}
Future<void> incrementCounter() async {
final _savedValue = await counterCases.saveCount(state.count + 1);
emit(HomePageState(count: _savedValue));
}
}
Всё!
Теперь при запуске приложения происходит инициализация DI, в котором создается CounterRepository, CounterCase. После открывается home_page_screen, который инициализирует home_page_cubit, и тот загружает последнее сохраненное значение счетчика и показывает его на экране.
Логику работы кнопки увеличения счетчика можно представить на графике ниже:
Нажатие на кнопку вызывает incrementCounter у кубита, что приводит в действие метод saveCount у use case. Последний, в свою очередь, запускает метод saveCount у репозитория. Репозиторий сохранит в Hive значение, вернет в виде объекта Entity в home_page_cubit, который обновит стейт у home_page_screen. Так как метод put у Hive не возвращает никаких данных, поэтому в графике отсутствует стрелка от hive к counter_repo. Если бы, например, у нас был интернет-запрос, то от блока hive была бы стрелочка к counter_repo с результатом интернет-запроса. Познакомиться с проектом подробнее можно на GitHub.
Спасибо за внимание! Надеемся, что этот пример был вам полезен.
Комментарии (14)
rudinandrey
24.08.2021 16:06мне вот интересно, на cubit'ах вообще можно что нибудь более менее сложное сделать? Ну например. мне надо загрузить что-то из Интернета, мне должно прилететь два состояния, одно что началась загрузка, другое загруженное с нужными данными. в одном emit'е это сделать нельзя. в таких случаях как раз и надо использовать полноценный Bloc ?
mobileSimbirSoft Автор
24.08.2021 17:35+1Можно вызывать emit сколько угодно раз, при вызове метода из кубита первым действием уведомить UI, вызвав emit(LoadingState()), загрузить данные из интернета, после emit(LoadedState()).
rudinandrey
24.08.2021 20:02Спасибо большое, а то попробовал, один emit отправить, потом через секунду второй, и у меня то ли первый только возвращался, толи второй ( ну и я что-то приуныл :( сейчас повторил эксперимент, все работает. Спасибо большое! Теперь время читать статью.
AntonGre4ka
25.08.2021 09:15а как правильно объединять
useCase
в цепочку?mobileSimbirSoft Автор
25.08.2021 09:30Как показано на последней схеме, use case находится между presenter и repository, внедрить use case в цепочку можно через инициализацию в presenter'е, такой способ реализован в примере.
Raz-Mik
25.08.2021 10:24а что лучше: в слое domain описывать интерфейс для use_case или для repository? не правильней ли в domain создать интерфейс repository, а в data слое уже repository_impl?
и разве use_case не должен делать что-то одно (либо получить данные, либо сохранить)?mobileSimbirSoft Автор
25.08.2021 11:43use_case может как получать, так и сохранять данные. Что касается описания интерфейса, предложенный вами способ тоже выглядит логичным, при этом в примере мы для удобства выбрали другой способ - держать абстракцию и реализацию рядом друг с другом.
Raz-Mik
25.08.2021 12:14не увидел сразу. у вас получается в data слое есть так же описание интерфейс для репозитория и там же его реализация. а зачем тогда делать описание интерфейса для use_case если он по факту зависит от интерфейса репозитория? т.е. мы в конструктор usa_case можем передать репозиторий к примеру CounterRepositoryImpl, CounterTestRepositoryImpl, CounterApiRepositoryImpl , CounterHiveRepositoryImpl и т.д.
а методах usa_case уже вызывать методы переданного репозитория, как это у вас и реализовано.mobileSimbirSoft Автор
25.08.2021 12:52Мы выбрали этот способ для того, чтобы опираться на абстракцию, а не реализацию при продвижении от "глупых" сущностей (data) к "умным" (ui). Это соответствует составляющей D (dependency inversion) в принципе SOLID. Практическая цель - повышение тестируемости компонентов
Antol
25.08.2021 11:20Хорошо бы еще не путать понятия DI и паттерн Service Locator (который на самом деле используется в данном примере)
pretorean
Мне кажется что пример со счетчиком не иллюстрирует каких либо преимуществ использования архитектуры в силу своей простоты. Какой то пример посложнее нужно для демонстрации архитектурного подхода.
mobileSimbirSoft Автор
Согласны, архитектуру в флаттере можно рассмотреть на примере многих приложений, как простых, так и более сложных. Руководство при этом может превратиться в настоящую книгу) Однако, наша основная задача заключалась в том, чтобы познакомить новичков с реализацией чистой архитектуры на практике. А какие приложения выбрали бы вы?
alexandrim
Хорошо бы в примере было два взаимодействующих домена, иначе не очевидна польза от такого бойлерплейта, особенно, в сравнении с Provider.
Пример с counter вообще подходит лишь для StatefulWidget и setState(), а другие подходы на этом примере лишь выглядят как бессмысленное усложнение.
Официальный пример для Provider на примере корзины решает задачу демонстрации удобства и преимущества предложенного подхода.
Если вы понимаете кейс, в котором cubit даст ощутимую пользу, опишите его.
Иначе, подобные статьи бесполезны и лишь сбивают с толку ищущих толковое объяснение.
Да хотя бы пример с корзиной, дополненный объяснением смысла и пользы использования bloc\cubit уже может быть полезным.