Соблюдать принципы чистой архитектуры – значит обеспечить удобство тестирования, поддержки и модернизации приложения. Понимание архитектуры и 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.
Спасибо за внимание! Надеемся, что этот пример был вам полезен.
Авторские материалы для mobile-разработчиков мы также публикуем в наших соцсетях – ВКонтакте и Telegram.