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


  1. pretorean
    24.08.2021 15:19
    +1

    Мне кажется что пример со счетчиком не иллюстрирует каких либо преимуществ использования архитектуры в силу своей простоты. Какой то пример посложнее нужно для демонстрации архитектурного подхода.


    1. mobileSimbirSoft Автор
      24.08.2021 17:35

      Согласны, архитектуру в флаттере можно рассмотреть на примере многих приложений, как простых, так и более сложных. Руководство при этом может превратиться в настоящую книгу) Однако, наша основная задача заключалась в том, чтобы познакомить новичков с реализацией чистой архитектуры на практике. А какие приложения выбрали бы вы?


      1. alexandrim
        25.08.2021 08:31

        Хорошо бы в примере было два взаимодействующих домена, иначе не очевидна польза от такого бойлерплейта, особенно, в сравнении с Provider.
        Пример с counter вообще подходит лишь для StatefulWidget и setState(), а другие подходы на этом примере лишь выглядят как бессмысленное усложнение.

        Официальный пример для Provider на примере корзины решает задачу демонстрации удобства и преимущества предложенного подхода.
        Если вы понимаете кейс, в котором cubit даст ощутимую пользу, опишите его.
        Иначе, подобные статьи бесполезны и лишь сбивают с толку ищущих толковое объяснение.
        Да хотя бы пример с корзиной, дополненный объяснением смысла и пользы использования bloc\cubit уже может быть полезным.


  1. rudinandrey
    24.08.2021 16:06

    мне вот интересно, на cubit'ах вообще можно что нибудь более менее сложное сделать? Ну например. мне надо загрузить что-то из Интернета, мне должно прилететь два состояния, одно что началась загрузка, другое загруженное с нужными данными. в одном emit'е это сделать нельзя. в таких случаях как раз и надо использовать полноценный Bloc ?


    1. mobileSimbirSoft Автор
      24.08.2021 17:35
      +1

      Можно вызывать emit сколько угодно раз, при вызове метода из кубита первым действием уведомить UI, вызвав emit(LoadingState()), загрузить данные из интернета, после emit(LoadedState()).


      1. rudinandrey
        24.08.2021 20:02

        Спасибо большое, а то попробовал, один emit отправить, потом через секунду второй, и у меня то ли первый только возвращался, толи второй ( ну и я что-то приуныл :( сейчас повторил эксперимент, все работает. Спасибо большое! Теперь время читать статью.


  1. AntonGre4ka
    25.08.2021 09:15

    а как правильно объединять useCase в цепочку?


    1. mobileSimbirSoft Автор
      25.08.2021 09:30

      Как показано на последней схеме, use case находится между presenter и repository, внедрить use case в цепочку можно через инициализацию в presenter'е, такой способ реализован в примере.


  1. Raz-Mik
    25.08.2021 10:24

    а что лучше: в слое domain описывать интерфейс для use_case или для repository? не правильней ли в domain создать интерфейс repository, а в data слое уже repository_impl?

    и разве use_case не должен делать что-то одно (либо получить данные, либо сохранить)?


    1. mobileSimbirSoft Автор
      25.08.2021 11:43

      use_case может как получать, так и сохранять данные. Что касается описания интерфейса, предложенный вами способ тоже выглядит логичным, при этом в примере мы для удобства выбрали другой способ - держать абстракцию и реализацию рядом друг с другом.


      1. Raz-Mik
        25.08.2021 12:14

        не увидел сразу. у вас получается в data слое есть так же описание интерфейс для репозитория и там же его реализация. а зачем тогда делать описание интерфейса для use_case если он по факту зависит от интерфейса репозитория? т.е. мы в конструктор usa_case можем передать репозиторий к примеру CounterRepositoryImpl, CounterTestRepositoryImpl, CounterApiRepositoryImpl , CounterHiveRepositoryImpl и т.д.
        а методах usa_case уже вызывать методы переданного репозитория, как это у вас и реализовано.


        1. mobileSimbirSoft Автор
          25.08.2021 12:52

          Мы выбрали этот способ для того, чтобы опираться на абстракцию, а не реализацию при продвижении от "глупых" сущностей (data) к "умным" (ui). Это соответствует составляющей D (dependency inversion) в принципе SOLID. Практическая цель - повышение тестируемости компонентов


  1. Antol
    25.08.2021 11:20

    Хорошо бы еще не путать понятия DI и паттерн Service Locator (который на самом деле используется в данном примере)


  1. Joldersman
    26.08.2021 08:09

    Спасибо, очень полезно.