Реализуем чистую архитектуру на Flutter с cubit

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/573848/