Flutter + чистая архитектура: разбираем на примере

На определённом этапе изучения новой технологии начинаешь задаваться вопросом — как правильно организовать архитектуру проекта? Мне в своё время повезло — попались опытные наставники, которые дали мудрые советы. Однако я считаю, что знания не должны лежать мёртвым грузом, поэтому пишу эту статью в помощь начинающим (и не только) flutter-разработчикам.

Чистая архитектура — это концепция построения архитектуры систем, предложенная Робертом Мартином (также известного как «дядюшка Боб»). Концепция предполагает построение приложения в виде набора независимых слоёв, что облегчает тестирование, уменьшает связность и делает приложение более простым для понимания.

Flutter — стремительно набирающий популярность фреймворк для разработки кроссплатформенных приложений. В списке поддерживаемых платформ — iOS, Android, web, в бете находится поддержка десктопа.

Под катом — рассказ о том, как построить flutter-приложение с использованием идей чистой архитектуры.

ddcac6c39c41fb948b7d470d1d74820e.png

Конкретно с этой имплементацией я познакомился, когда пришёл работать в Progressive Mobile (пользуясь случаем, хочу передать привет — ребят, вы крутые!). Она хорошо себя показала на множестве проектов, которые мы делали, при этом развивалась от проекта к проекту.

Обычно приложение состояло из четырёх слоев:

  • data — слой работы с данными. На этом уровне, например, описываем работу с внешним API.

  • domain — слой бизнес-логики.

  • internal — слой приложения. На этом уровне происходит внедрение зависимостей.

  • presentation — слой представления. На этом уровне описываем UI приложения.

Слой представления ничего не знает о том, откуда появляются данные, которые он использует, слой данных не знает, кто и как будет использовать данные, что он предоставляет. Это позволяет легко вносить изменения в проект — например, мы можем переехать с REST на GraphQL и не изменить ни строчки кода в слое представления.

Далее мы, слой за слоем, построим мобильное приложение на Flutter. Поскольку цель у нас образовательная, приложение будет достаточно простым — оно покажет нам продолжительность дня и время рассвета и захода солнца в указанной точке Земли.

Создание проекта

Я предполагаю, что у вас уже установлен Flutter, если нет — почитать о том, как это делается, можно в официальной документации.

Создать проект можно с помощью инструментов вашей любимой IDE или из командной строки. В последнем случае вы должны выполнить в терминале команду:

flutter create myapp

В результате будет создан проект со стандартным примером приложения-счетчика. Давайте немного его изменим — удалим лишний код и подготовим необходимые директории.

Как уже говорилось выше, приложение будет состоять из 4 слоёв, поэтому создадим соответствующие папки. Заодно заметим, что код из стандартного примера содержит вёрстку экрана — то есть UI, а значит, место ему в слое представления.

Получилась следующая структура каталогов:

252df70bbdb7ac08333ce637920701f2.png

Содержание файлов main.dart, application.dart и home.dart можно посмотреть под спойлерами.

main.dart

import 'package:flutter/material.dart';

import 'internal/application.dart';

void main() {
  runApp(Application());
}

application.dart

import 'package:flutter/material.dart';
import 'package:habr_flutter_clean_arch/presentation/home.dart';

class Application extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: Home(),
    );
  }
}

home.dart

import 'package:flutter/material.dart';

class Home extends StatefulWidget {
  @override
  _HomeState createState() => _HomeState();
}

class _HomeState extends State {
  @override
  Widget build(BuildContext context) {
    return Scaffold();
  }
}

На данном этапе приложение должно успешно собираться, хотя и будет выглядеть как пустой белый экран. Пожалуй, настало время для первого коммита.

Готовим domain

Кровь любого приложения — это данные, что перетекают из одной его части в другую. Данные обычно представлены в виде набора моделей.

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

Для нашего приложения мы воспользуемся бесплатным сервисом Sunrise Sunset, который позволяет получить информацию о продолжительности дня и времени восхода/захода солнца в указанной точке.

Мы будем делать запросы к этому сервису и использовать из полученной информации следующие данные:

  • время восхода

  • время захода

  • время, в которое наступает астрономический полдень

  • продолжительность дня

Теперь мы можем создать нашу первую модель. Добавим в папку domain директорию model, в которой создадим файл с именем day.dart. Опишем в этом файле нашу модель:

import 'package:meta/meta.dart';

class Day {
  final DateTime sunrise;
  final DateTime sunset;
  final DateTime solarNoon;
  final int dayLength;

  Day({
    @required this.sunrise,
    @required this.sunset,
    @required this.solarNoon,
    @required this.dayLength,
  });
}

Здесь мы определили конструктор с именованными аргументами, а аннотация @required говорит нам о том, что все аргументы являются обязательными.

Некоторые добавляют в модели специальные методы а-ля fromJson, которые используют для преобразования сырых данных из мапы в модель, однако я считаю это в корне неверным.

Дело в том, что методы наподобие fromJson не имеют отношения к бизнес-логике, это всё часть слоя данных. Если описать его здесь, то наша модель превратится в кашу из обращений к мапе, что сильно усложнит понимание структуры модели, а использоваться всё равно будет только в момент получения данных. Поэтому мы вернёмся к этому вопросу, когда займемся слоем данных.

Итак, мы описали модель, теперь нужно поговорить об источнике данных.

В общем случае этих источников может быть довольно много, поэтому выделим для них свою собственную директорию — добавим в папке domain каталог repository. На уровне бизнес-логики мы описываем не сами эти источники, а лишь интерфейс взаимодействия с ними.

Создадим в этой директории файл day_repository.dart следующего содержания:

import 'package:meta/meta.dart';
import 'package:habr_flutter_clean_arch/domain/model/day.dart';

abstract class DayRepository {
  Future getDay({
    @required double latitude,
    @required double longitude,
  });
}

Поскольку в языке Dart нет интерфейсов в явном виде, вместо них используют абстрактные классы. В данном случае, мы указали, что наследники этого репозитория должны реализовывать метод getDay, который вернёт объект Future, разрешающий нашу модель Day. Обязательные аргументы этого метода — широта и долгота интересующей нас точки.

На данный момент у нас должна получиться следующая структура каталогов:

8bd585dc84119e1b9931c3b6b7e4fdf7.png

Пожалуй, можно закоммитить изменения и переходить к слою данных.

Готовим data слой

Помните я говорил о методе создания модели из сырого json? Настало время для его реализации.

На уровне слоя данных у нас будет реализована логика получения данных от бэкенда. Кроме того, здесь же будут описаны классы-наследники репозиториев из слоя бизнес-логики. Поэтому добавим в директорию data каталоги api и repository. Начнем работать с api.

Здесь мы опишем модель ApiDay, которая будет содержать метод fromApi — получение данных из json. На этом уровне у нас все модели будут начинаться с префикса Api, чтобы отличить их от моделей слоя бизнес-логики.

Зачем нужна отдельная модель ApiDay? С одной стороны, она может содержать методы манипуляции с сырыми данными — fromApi/toApi, что соответствует как раз уровню данных, а не бизнес-логики. Кроме того, полученные с бэкенда данные могут иметь довольно сложную структуру, не всегда удобную для нашего приложения и мы можем произвести необходимую подготовку на данном уровне. С другой стороны, благодаря такому разделению, мы получаем более прозрачную структуру — на уровне бизнес-логики нам не будут мешать бесполезные там методы fromApi/toApi, плюс, если бэкенд изменит формат присылаемых данных, нам будет достаточно поправить в одном месте нашу модель ApiDay, и на уровне бизнес-логики все будет работать без изменений.

Итак, давайте создадим в папке api/model файл api_day.dart следующего содержания:

api_day.dart

class ApiDay {
  final String sunrise;
  final String sunset;
  final String solarNoon;
  final num dayLength;

  ApiDay.fromApi(Map map)
      : sunrise = map['results']['sunrise'],
        sunset = map['results']['sunset'],
        solarNoon = map['results']['solar_noon'],
        dayLength = map['results']['day_length'];
}

В данном случае мы описали именованный конструктор, который из словаря, возвращаемого бэкендом, по нужным ключам присваивает значения полям модели.

Обратите внимание: в общем случае типы полей этой модели не совпадают с типами модели из domain слоя. Они имеют именно тот тип, который возвращает бэкенд. Для преобразования api-модели в обычную модель, создадим специальный класс-маппер, который будет сопоставлять поля двух моделей и выполнять преобразования при необходимости.

Добавим в директорию data/api папку mapper, в которой создадим файл day_mapper.dart,

day_mapper.dart

import 'package:habr_flutter_clean_arch/data/api/api_day.dart';
import 'package:habr_flutter_clean_arch/domain/model/day.dart';

class DayMapper {
  static Day fromApi(ApiDay day) {
    return Day(
      sunrise: DateTime.tryParse(day.sunrise),
      sunset: DateTime.tryParse(day.sunset),
      solarNoon: DateTime.tryParse(day.solarNoon),
      dayLength: day.dayLength.toInt(),
    );
  }
}

Класс DayMapper содержит статический метод, принимающий на входе объект ApiDay и превращающий его в модель бизнес-слоя Day. Этот метод потребуется нам на следующем шаге. А пока можно зафиксировать изменения в системе контроля версий.

Работаем с API

В общем случае вашему приложению могут потребоваться данные из разных источников. Например, какие-то данные вы будете получать с использованием REST, а какие-то — с использованием GraphQL. Чтобы сделать эту логику более прозрачной, работа с API у нас будет организована в виде двух слоёв.

На верхнем, более абстрактном, мы описываем интерфейс работы с API: набор методов, которые будут использоваться репозиториями для получения данных, и которые обращаются к нижележащему слою для фактического их получения. А заодно выполняет преобразование API-моделей в модели бизнес-логики.

На нижнем слое у нас будет набор классов, которые работают с конкретными сервисами, преобразуют данные слоя бизнес-логики в необходимый для запросов вид (например, json, FormData), выполняют фактические запросы, и обрабатывают полученные результаты. Поскольку этих классов может быть несколько, разумно выделить для них отдельный каталог.

У нас пока всего один сервис (напомню, мы используем Sunrise Sunset), давайте создадим для него в data/api/service файл sunrise_service.dart.

В экосистеме Flutter существует несколько пакетов для работы с сетью, мне больше нравится dio, но вы можете использовать любой другой.

Итак, давайте добавим в зависимости проекта этот пакет и вернёмся к нашему sunrise_service.dart.

sunrise_service.dart

import 'package:dio/dio.dart';
import 'package:habr_flutter_clean_arch/data/api/model/api_day.dart';
import 'package:meta/meta.dart';

class SunriseService {
  static const _BASE_URL = 'https://api.sunrise-sunset.org';

  final Dio _dio = Dio(
    BaseOptions(baseUrl: _BASE_URL),
  );

  Future getDay({
    @required double latitude,
    @required double longitude,
  }) async {
    final query = {'lat': latitude, 'lng': longitude, 'formatted': 0};
    final response = await _dio.get(
      '/json',
      queryParameters: query,
    );
    return ApiDay.fromApi(response.data);
  }
}

Здесь мы создали объект dio и описали метод getDay, который с помощью этого объекта делает GET запрос к сервису и из полученных данных создает объект ApiDay.

В данном случае я оставил подготовку данных для запроса внутри метода getDay, что не очень хорошо: некоторым запросам требуется довольно много данных, некоторые из них потребуется предварительно преобразовать. Кроме того, этих запросов может быть большое количество, если оставить все так, то файл довольно быстро превратится в мешанину из словарей и методов.

Поэтому давайте вынесем подготовку данных для запроса в отдельный этап.

Готовим данные для запроса

Для этого в data/api создадим каталог request, в котором создадим файл get_day_body.dart, с таким содержанием:

get_day_body_dart

import 'package:meta/meta.dart';

class GetDayBody {
  final double latitude;
  final double longitude;

  GetDayBody({
    @required this.latitude,
    @required this.longitude,
  });

  Map toApi() {
    return {
      'lat': latitude,
      'lng': longitude,
      'formatted': 0,
    };
  }
}

Все наши подобные классы будут называться по шаблону <ИМЯ_МЕТОДА>Body и реализовывать метод toAPi для приведения данных к нужному виду.

В данном случае я добавил поле 'formatted': 0, потому что в этом случае сервис вернёт данные в формате ISO 8601 — фактически, это маленький костыль, который я добавил, чтобы быть уверенным, что данные всегда будут в нужном нам формате. Правильнее было бы передавать этот параметр явным образом.

Теперь мы можем изменить метод getDay в классе SunriseService:

sunrise_service.dart

import 'package:dio/dio.dart';
import 'package:habr_flutter_clean_arch/data/api/model/api_day.dart';
import 'package:habr_flutter_clean_arch/data/api/request/get_day_body.dart';

class SunriseService {
  static const _BASE_URL = 'https://api.sunrise-sunset.org';

  final Dio _dio = Dio(
    BaseOptions(baseUrl: _BASE_URL),
  );

  Future getDay(GetDayBody body) async {
    final response = await _dio.get(
      '/json',
      queryParameters: body.toApi(),
    );
    return ApiDay.fromApi(response.data);
  }
}

Все методы в этом файле будут содержать в себе всего несколько строк, что облегчит их чтение, когда этих методов станет много.

Нижний слой API реализован, переходим к верхнему. Создадим в каталоге data/api файл api_util.dart:

api_util.dart

import 'package:habr_flutter_clean_arch/data/api/request/get_day_body.dart';
import 'package:habr_flutter_clean_arch/data/mapper/day_mapper.dart';
import 'package:meta/meta.dart';
import 'package:habr_flutter_clean_arch/data/api/service/sunrise_service.dart';
import 'package:habr_flutter_clean_arch/domain/model/day.dart';

class ApiUtil {
  final SunriseService _sunriseService;

  ApiUtil(this._sunriseService);

  Future getDay({
    @required double latitude,
    @required double longitude,
  }) async {
    final body = GetDayBody(latitude: latitude, longitude: longitude);
    final result = await _sunriseService.getDay(body);
    return DayMapper.fromApi(result);
  }
}

На этом уровне мы преобразуем данные бизнес-слоя в необходимый сервису вид, выполняем фактический запрос и преобразуем полученные данные в вид, приемлемый для бизнес-слоя.

Фактически, класс ApiUtil служит единой точкой входа в мир API для всех репозиториев, самостоятельно решая, к какому сервису обращаться за данными. Если завтра нам потребуется получать координаты городов из другого сервиса, или погоду из третьего, мы будем решать это на уровнях ApiUtil-ApiServise, а для всех репозиториев это будет выглядеть как будто мы получаем все данные из одного источника. В этом и заключается преимущество такого подхода.

Итак, мы подготовили всё необходимое для работы с API, пора переходить к репозиториям.

Готовим репозитории

Ранее мы определили на уровне бизнес-логики интерфейс репозитория DayRepository, теперь мы можем описать его конкретную реализацию. Для этого в каталоге data/api создадим папку repository и добавим в неё файл day_data_repository.dart со следующим содержанием:

day_data_repository.dart

import 'package:habr_flutter_clean_arch/data/api/api_util.dart';
import 'package:habr_flutter_clean_arch/domain/model/day.dart';
import 'package:habr_flutter_clean_arch/domain/repository/day_repository.dart';

class DayDataRepository extends DayRepository {
  final ApiUtil _apiUtil;

  DayDataRepository(this._apiUtil);

  @override
  Future getDay({double latitude, double longitude}) {
    return _apiUtil.getDay(latitude: latitude, longitude: longitude);
  }
}

Как видим, всё, что нужно нашему репозиторию, чтобы реализовать абстрактные методы класса DayRepository — это объект ApiUtil, который вернёт необходимые данные.

На данном этапе у нас должна получиться такая структура файлов в директории data:

baddf72d10b68fe97e7cc2180bdf1d9e.png

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

Внедряем зависимости

Если взглянуть на наш код со стороны, то можно обратить внимание, что репозитории функционально зависят от ApiUtil, а тот, в свою очередь, от одного или нескольких ApiService (конкретно в нашем случае — от SunriseService). Начнем с ApiUtil.

В директорию internal добавим папку dependencies, в которой создадим файл api_module.dart со следующим содержанием:

api_module.dart

import 'package:habr_flutter_clean_arch/data/api/api_util.dart';
import 'package:habr_flutter_clean_arch/data/api/service/sunrise_service.dart';

class ApiModule {
  static ApiUtil _apiUtil;

  static ApiUtil apiUtil() {
    if (_apiUtil == null) {
      _apiUtil = ApiUtil(SunriseService());
    }
    return _apiUtil;
  }
}

Класс ApiModule содержит в себе статический метод apiUtil, который возвращает нам единственный экземпляр класса ApiUtil и создает его при необходимости. Используя этот модуль, мы можем поступить аналогичным образом и для репозиториев.

Добавим файл repository_module.dart и запишем в него следующий код:

repository_module.dart

import 'package:habr_flutter_clean_arch/data/repository/day_data_repository.dart';
import 'package:habr_flutter_clean_arch/domain/repository/day_repository.dart';

import 'api_module.dart';

class RepositoryModule {
  static DayRepository _dayRepository;

  static DayRepository dayRepository() {
    if (_dayRepository == null) {
      _dayRepository = DayDataRepository(
        ApiModule.apiUtil(),
      );
    }
    return _dayRepository;
  }
}

В классе RepositoryModule описываются статические методы, которые для каждого абстрактного репозитория из domain/repository создают объекты-наследники, реализующие методы этих репозиториев.

Если в каком-то месте нам потребуется репозиторий, то мы не будем создавать его сами, а обратимся за этим к RepositoryModule, который, по сути, является единственной точкой входа в каждый из репозиториев. В чем преимущество такого подхода? Если завтра нам потребуется использовать другую реализацию интерфейса репозитория, то будет достаточно изменить файл repository_module.dart, других изменений не потребуется.

Прежде чем мы начнём использовать репозиторий для получения информации о продолжительности дня в указанной точке, давайте зафиксируем изменения и подготовим простой UI нашего приложения.

Слой представления

У нас уже есть заготовка для экрана Home в папке presentation, давайте внесём в неё изменения.

Интерфейс будет очень простым: два поля ввода (для широты и долготы интересующей точки), кнопка для активации запроса и несколько строк текста для отображения результатов. Бедно, но мы ведь здесь не за этим собрались, верно? Никаких проверок валидности введённых данных тоже не будет, чтобы не усложнять код.

После внесения изменений, код экрана стал выглядеть следующим образом:

home.dart

import 'package:flutter/material.dart';
import 'package:habr_flutter_clean_arch/domain/model/day.dart';

class Home extends StatefulWidget {
  @override
  _HomeState createState() => _HomeState();
}

class _HomeState extends State {
  final _latController = TextEditingController();
  final _lngController = TextEditingController();

  Day _day;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: FocusScope.of(context).unfocus,
      child: Scaffold(
        body: _getBody(),
      ),
    );
  }

  Widget _getBody() {
    return SafeArea(
      child: Padding(
        padding: EdgeInsets.all(10),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _getRowInput(),
            SizedBox(height: 20),
            RaisedButton(
              child: Text('Получить'),
              onPressed: _getDay,
            ),
            SizedBox(height: 20),
            if (_day != null) _getDayInfo(_day),
          ],
        ),
      ),
    );
  }

  Widget _getRowInput() {
    return Row(
      children: [
        Expanded(
          child: TextField(
            controller: _latController,
            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),
            decoration: InputDecoration(hintText: 'Широта'),
          ),
        ),
        SizedBox(width: 20),
        Expanded(
          child: TextField(
            controller: _lngController,
            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),
            decoration: InputDecoration(hintText: 'Долгота'),
          ),
        ),
      ],
    );
  }

  Widget _getDayInfo(Day day) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Text('Восход: ${day.sunrise.toLocal()}'),
        Text('Заход: ${day.sunset.toLocal()}'),
        Text('Полдень: ${day.solarNoon.toLocal()}'),
        Text('Продолжительность: ${Duration(seconds: day.dayLength)}'),
      ],
    );
  }

  void _getDay() {
    // здесь получаем данные
  }
}

Обратите внимание на метод _getDay: уже сейчас мы могли бы создать там объект репозитория и получить с его помощью необходимые данные. Однако делать так не стоит: в этом случае экран, находящийся на слое представления, будет явным образом зависеть от объекта из слоя данных.

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

И тут нам приходят на помощь различные инструменты для управления состоянием приложения — такие как Redux, BLoC, MobX. Они могут довольно сильно отличаться в деталях, но идеологически суть их весьма близка: вы генерируете некое событие (например, нажатием кнопки), это событие инициирует изменение состояния (например, получив с бэкенда данные и поместив их хранилище), а изменение состояния приводит к изменению интерфейса.

Обычно для этих целей я использую BLoC, но сегодня хочу попробовать MobX — просто потому что никогда раньше его не использовал. Должна же быть польза и для меня от всей этой затеи!

Disclaimer: я не явлюсь специалистом по MobX, поэтому относитесь к моей реализации этого паттерна критически. Дайте знать, если я допустил ошибки.

Однако в рамках данной статьи это несущественно — вы можете заменить MobX на любой другой менеджер состояния, в глобальном смысле ничего не изменится.

Управление состоянием с помощью MobX

Итак, для начала нам потребуется добавить необходимые зависимости в наш проект:

dependencies:
	...
  mobx: ^1.2.1+3
  flutter_mobx: ^1.1.0+2

Также добавим в dev_dependencies зависимости для генерации файлов, добавляющих возможность использовать аннотации @observable, @computed, @action:

dev_dependencies:
	...
  mobx_codegen: ^1.1.1+1
  build_runner: ^1.10.0

Управление состоянием относится к слою бизнес-логики, поэтому давайте добавим в директорию domain папку state. В этом каталоге у нас будут классы, описывающие состояние экранов (а возможно — и других компонентов). Кажется разумным выделить для каждого из них свой подкаталог. В нашем примере экран всего один, поэтому давайте добавим подкаталог home.

Создадим в нём файл home_state.dart с таким содержанием:

home_state.dart

import 'package:mobx/mobx.dart';
import 'package:meta/meta.dart';
import 'package:habr_flutter_clean_arch/domain/repository/day_repository.dart';
import 'package:habr_flutter_clean_arch/domain/model/day.dart';

part 'home_state.g.dart';

class HomeState = HomeStateBase with _$HomeState;

abstract class HomeStateBase with Store {
  HomeStateBase(this._dayRepository);

  final DayRepository _dayRepository;

  @observable
  Day day;

  @observable
  bool isLoading = false;

  @action
  Future getDay({
    @required double latitude,
    @required double longitude,
  }) async {
    isLoading = true;
    final data = await _dayRepository.getDay(latitude: latitude, longitude: longitude);
    day = data;
    isLoading = false;
  }
}

В целом он соответствует шаблону из примера по MobX, обсудим некоторые детали.

Поле day помечено аннотацией @observable, изменение значения этого поля будет отслеживаться на уровне представления и при необходимости перерисовывать экран.

Аналогичной аннотацией помечена и переменная isLoading — её мы будем использовать для определения момента, когда выполняется асинхронная операция и необходимо показать лоадер.

Также имеется метод, помеченный аннотацией @action, то есть то самое событие, инициирующее изменение состояния. В данном случае, мы будем вызывать его для того, чтобы получить данные из репозитория.

Теперь необходимо сгенерировать файл home_state.g.dart, для этого выполните команду:

flutter packages pub run build_runner build

У меня поначалу всё пошло не очень гладко: скрипт уходил в бесконечный цикл и наотрез отказывался генерировать необходимый файл. В одном из issue к mobx порекомендовали выполнить в этом случае команды

flutter clean
flutter pub get
flutter packages upgrade

Мне это помогло, после их выполнения предыдущий скрипт завершился успехом.

Итак, у нас есть класс HomeState, управляющий состоянием экрана Home, но ему требуется DayRepository репозиторий. А значит пора снова вернуться к слою внедрения зависимостей.

Добавим в директорию internal/dependencies файл home_module.dart со следующим содержанием:

home_module.dart

import 'package:habr_flutter_clean_arch/domain/state/home/home_state.dart';
import 'package:habr_flutter_clean_arch/internal/dependencies/repository_module.dart';

class HomeModule {
  static HomeState homeState() {
    return HomeState(
      RepositoryModule.dayRepository(),
    );
  }
}

Теперь всё необходимое у нас есть, и мы можем наконец-то организовать грамотное управление состоянием экрана Home.

Внесём изменения в файл presentation/home.dart:

home.dart

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:habr_flutter_clean_arch/domain/state/home/home_state.dart';
import 'package:habr_flutter_clean_arch/internal/dependencies/home_module.dart';

class Home extends StatefulWidget {
  @override
  _HomeState createState() => _HomeState();
}

class _HomeState extends State {
  final _latController = TextEditingController();
  final _lngController = TextEditingController();

  HomeState _homeState;

  @override
  void initState() {
    super.initState();
    _homeState = HomeModule.homeState();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: FocusScope.of(context).unfocus,
      child: Scaffold(
        body: _getBody(),
      ),
    );
  }

  Widget _getBody() {
    return SafeArea(
      child: Padding(
        padding: EdgeInsets.all(10),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _getRowInput(),
            SizedBox(height: 20),
            RaisedButton(
              child: Text('Получить'),
              onPressed: _getDay,
            ),
            SizedBox(height: 20),
            _getDayInfo(),
          ],
        ),
      ),
    );
  }

  Widget _getRowInput() {
    return Row(
      children: [
        Expanded(
          child: TextField(
            controller: _latController,
            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),
            decoration: InputDecoration(hintText: 'Широта'),
          ),
        ),
        SizedBox(width: 20),
        Expanded(
          child: TextField(
            controller: _lngController,
            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),
            decoration: InputDecoration(hintText: 'Долгота'),
          ),
        ),
      ],
    );
  }

  Widget _getDayInfo() {
    return Observer(
      builder: (_) {
        if (_homeState.isLoading)
          return Center(
            child: CircularProgressIndicator(),
          );
        if (_homeState.day == null) return Container();
        return Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text('Восход: ${_homeState.day.sunrise.toLocal()}'),
            Text('Заход: ${_homeState.day.sunset.toLocal()}'),
            Text('Полдень: ${_homeState.day.solarNoon.toLocal()}'),
            Text('Продолжительность: ${Duration(seconds: _homeState.day.dayLength)}'),
          ],
        );
      },
    );
  }

  void _getDay() {
    // здесь получаем данные
    final lat = double.tryParse(_latController.text);
    final lng = double.tryParse(_lngController.text);
    _homeState.getDay(latitude: lat, longitude: lng);
  }
}

Здесь мы создаём объект класса HomeState с помощью HomeModule. Нажатие на кнопку инициирует событие getDay, а с помощью виджета Observer приложение отслеживает изменение состояния и перерисовывает экран.

Результат работы приложения представлен ниже.

053071b7b40e0112057e99dc271252fc.gif

Итак, мы разработали проект, который следует идеям чистой архитектуры. Такой проект имеет прозрачную структуру слоёв, что уменьшает когнитивную нагрузку и облегчает включение новых участников в проект.

Такой проект легче модифицировать — например, переезд с REST на GraphQL пройдёт безболезненно для слоёв бизнес-логики и представления. Вы также легко можете использовать их совместно или добавлять дополнительные сервисы. Можно вносить изменения в слой представления — например, подготовить разный дизайн UI для разных платформ и использовать единую бизнес-логику и данные.

Независимость слоёв также облегчает тестирование приложения.

Наконец, вы всегда можете заменить условный MobX на BLoC, Redux или что там вам по вкусу, и использовать эту архитектуру на полную катушку.

Исходный код проекта доступен на Github.

© Habrahabr.ru