Как стать автором
Обновить

Комментарии 36

Спасибо большое!
Только вчера задумался поискать хорошую статью про Flutter и чистую архитектуру!

Хорошая статья! Несколько замечаний / рац. предложений из опыта нашей команды, если позволите.


Во-первых, я бы не стал, пожалуй, выделять ApiUtil как отдельный класс. На мой взгляд, эти детали реализации вполне могут быть ответственностью DayDataRepository


Во-вторых, функциональность мапперов хорошо ложится на методы-расширения.


В-третьих, стоит упомянуть о таких мега-полезных библиотеках, как retrofit и json_annotation. Они убирают тонну бойлерплейта.


С учетом сказанного, слой API я бы реализовал примерно так (конечно, это надо раскидать по разным файлам, но для простоты я выложу одной портянкой):


API
// DTOs

@JsonSerializable()
class DayDto {
  DayDto({this.sunrise, this.sunset, this.solar_noon, this.day_length});

  final String sunrise;
  final String sunset;
  final String solar_noon;
  final num day_length;

  factory DayDto.fromJson(Map<String, dynamic> json) => _$DayDtoFromJson(json);
}

@JsonSerializable()
class DayResponseDto {
  DayResponseDto({this.results});

  final DayDto results;

  factory DayResponseDto.fromJson(Map<String, dynamic> json) =>
      _$DayResponseDtoFromJson(json);
}

@JsonSerializable()
class DayRequestDto {
  DayRequestDto({this.lat, this.lng, this.formatted});

  final double lat;
  final double lng;
  final int formatted;

  Map<String, dynamic> toJson() => _$DayRequestDtoToJson(this);
}

// Backend API interface

@RestApi(baseUrl: 'https://api.sunrise-sunset.org/')
abstract class SunriseApi {
  factory SunriseApi(Dio dio, {String baseUrl}) = _SunriseApi;

  @GET('/json')
  Future<DayResponseDto> getDay(@Queries() DayRequestDto request);
}

// Mappers

extension DayMapper on DayDto {
  Day toModel() => Day(
        sunrise: DateTime.tryParse(sunrise),
        sunset: DateTime.tryParse(sunset),
        solarNoon: DateTime.tryParse(solar_noon),
        dayLength: day_length.toInt(),
      );
}

// Repository implementation

class DayDataRepository extends DayRepository {
  final SunriseApi _api;

  DayDataRepository(this._api);

  @override
  Future<Day> getDay({double latitude, double longitude}) async {
    final request = DayRequestDto(
      lat: latitude,
      lng: longitude,
      formatted: 0,
    );
    final response = await _api.getDay(request);
    return response.results.toModel();
  }
}

Что-то в этом духе мы в проекте и используем.

Благодарю за замечание! Об этом надо подумать.
НЛО прилетело и опубликовало эту надпись здесь

Почему бы не использовать для di — GetIt ?

Не работал с этим пакетом, ничего не могу сказать.
53, 83 — Новосиб???
Новосибирск севернее:)
Спасибо за хорошую статью, но есть пару замечаний/улучшений.

1. Такие методы лучше выделять в отдельные виджеты (это связано с оптимизацией):
Widget _getBody() {...}


2. Как по мне, данный метод должен существовать в отдельном файле (а еще лучше, полностью в слое бизнес-логики):
void _getDay() {...}


3. Вместо SizedBox нужно использовать Padding

4. Не уверен в том, di будет что-либо кэшировать (максимум в рамках где он используется):
if (_dayRepository == null)
1) С этим согласен, но не хотелось усложнять код — здесь же больше не про UI шла речь, поэтому опустил эти нюансы.
2) Мысль интересная, надо подумать.
3) Почему так? Мне просто больше нравится плоская структура, а не вложенная. Но если есть какие-то нюансы, буду рад узнать.
4) У нас же все репозитории, по сути, являются синглтонами. А это нужно чтобы не создавать их все сразу, а только в момент, когда он впервые понадобился.
2) Padding добавляет пустое пространство к краям child'а, не растягивая его. А SizedBox создает коробку фиксированного размера, и его child будет растягиваться на указанную ширину и высоту. Вы также можете использовать оба виджета без child, но Padding дает больше возможностей для дальнейшего изменения ui. А также Padding лучше чем SizedBox по производительности (хоть разница в наносекундах, но чем больше таких элементов тем больше таких наносекунд)

4) Это уже холиварная тема из разряда загрузить все разом или загружать по необходимости. У обеих подходов есть свои плюсы и минусы. Помимо di есть также Service locator и Factory pattern
Как ведут себя в данном случае Padding и SizedBox я знаю, а вот про разницу в производительности не знал. А есть какие-нибудь бенчмарки? Откуда информация, что Padding быстрее?
Бенчмарков нет, данную информацию вы можете проверить сами, достаточно посмотреть из чего состоят эти виджеты и оценить их. RenderPadding (RenderShiftedBox) vs RenderConstrainedBox (RenderProxyBox)
Мне нужен был пример работы с сетью, поэтому и воспользовался тем сервисом.
Как будете прокидывать ошибку от Dio?
Можно отлавливать такие ошибки на уровне SunriseService и на их основе генерировать более высокоуровневые ошибки для слоя ApiUtil.
Это полезно еще вот с какой стороны: в общем случае у вас может быть несколько сервисов, которые возвращают разные ошибки (разный код, разное тело ответа), но имеющие один смысл (например, что вы не авторизованы). Все такие ошибки будут иметь один тип на уровне ApiUtil.
Может кто написать про связку shelf + angulardart + postgres + gRPC(protobuf), ммм?))
может быть бложек там какой, ну чисто CRUD показать)) ну можно еще Oauth2 туда же)))

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


Пытаться все затянуть на слой api, но у меня вся авторизация — генерация хэша по логину-паролю и методы для сохранения и чтения этого хэша из SecureStorage, и дальнейшее внедрение в headers всех запросов к серверу. Не очень то похоже на API.


Если выносить в отдельный модуль, то получается его нужно внедрять в сервис, чтобы подмешивать в headers в dio. Проблема усугубляется тем, что чтение из SecureStorage асинхронное, а это значит нужно как-то синхронизировать момент инициализации модуля авторизации и модуля API.


Как вы решаете эту проблему в своих проектах?


П.С.: В dart и flutter я новичок и еще не вник в то как принято строить архитектуру приложений, поэтому мой вопрос может выглядеть глупо! =)

Внедрение заголовков я бы сделал на уровне service.
В общем случае у вас может быть несколько сервисов, у которых может отличаться механизм авторизации. На уровне ApiUtils вам не нужно знать, как устроена авторизация в том или ином сервисе, вам просто нужен метод для работы с этим сервисом. Поэтому детали реализации работы с конкретным бэком, на мой взгляд, должны оставаться на уровне сервиса.
НЛО прилетело и опубликовало эту надпись здесь
НЛО прилетело и опубликовало эту надпись здесь
НЛО прилетело и опубликовало эту надпись здесь
Частенько бывает, что modelView собирается из нескольких запросов dto, а бывает наоборот с бэка в dto приходит данных больше чем нужно для modelView и тогда вычищается дабы не тащить ворох данных на эран.

И это не из-за криворукости бэкендера, просто на каждое предстваление одних и тех же данных глупо создавать десяток однотипных api.
*простите за некропостинг
Если есть swagger то можно применить пакет swagger_dart_code_generator и сгенерировать api со всеми его dto и пользовать готовые классы с методами
НЛО прилетело и опубликовало эту надпись здесь
DTO оправданы только если бэкендер криворук, и с сервера приходит что-то ну совсем не совпадающее с моделью

К сожалению, на моей практике такое происходило чаще, чем хотелось бы.

Использование нафиг ненужных DTO-шек — только нагромождение лишнего бойлерплейта

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

если тебе понадобились DTO-шки, и без них никак — статик маппер не нужен

На мой взгляд, это детали реализации. Выше в комментах предлагали использовать экстеншены для мапперов и это действительно может быть удобно. Там же указали, что с использованием пакет jsonable можно сократить количество бойлерплейта за счёт кодогенерации. Так что это вопрос вкуса — выбирайте, какой по душе!:)

рекомендую обратить внимание на Retrofit

Благодарю за наводку, раньше действительно с ним не сталкивался.
Хотя не могу не заметить, что не всем доступна такая роскошь как сваггер:) Но это так, поворчать.

Отличная статья! С радостью бы посмотрел на разбор ещё парочки интересных архитектурных решений.

Спасибо большое, все ещё актуально. Третий день работаю с flutter, архитектура - наше все!

Удивительно... 2.5 года, и уже ни черта не работает)) И ладно бы синтаксис стал лучше, так ведь нет! Просто он немного изменился и сиди гадай в чем дело (у меня конкретно проблема - Classes can only mix in mixins and classes.). Код, который я написал на котлине три года назад все так же работает. Мдаа, ну флаттер и флаттер

Три года для дарта и три года для котлина сейчас – это две большие разницы. Но конкретно в вашем случае, я подозреваю, что корень проблемы в чем-то другом, миксить всегда можно было только миксины и классы.

Язык активно развивается, ничего удивительного — и хорошо, что развивается.

Благодарю за подробный материал - крайне интересно пожонглировать в воображении архитектурой! Некоторые дополнения/замечания (с учётом времени рождения статьи):

  1. Текстовые контроллеры и прочие контроллеры рекомендуется инициализировать в initState , потому что это может быть удобным по ряду причин; одна из таких - вы можете присвоить какое-либо начальное значение на основе других данных. Затем, любые контроллеры необходимо утилизировать в методе dispose виджета (контроллеры могут быть сложней/тяжелей - привет утечка памяти).

class _HomeState extends State<Home> {
  late final TextEditingController _latController;
  late final TextEditingController _lngController;

  HomeState _homeState;

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

  @override
  void dispose() {
    super.dispose();

    _latController.dispose();
    _lngController.dispose();
}
  1. Нераскрыта история про обработку ошибок/исключений. Сделали бы вы разделение по слоям, когда на каждом уровне был бы свой тип ошибки? Сделали бы это в алгебраическом стиле или ином?

  2. Вероятно, реализация папки data чрезмерно сложная для простых проектов. 3 уровня вложенности можно смело заменить одним-двумя максимум + как и было указано, воспользоваться кодогенерацией и расширениями классов. И опять же, мы в мире dart - никто не отменял factory, чтобы жонглировать ModelDTO, Model, ModelUI. Тут это by design

  3. Реализация вместо наследования:

    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 {...}
    
    // может всё-таки реализуем контракт?
    class DayDataRepository implements DayRepository {...}
  4. Конечно, сейчас для внедрения зависимостей стоит воспользоваться provider, getIt, injectable или что-то в этом духе. Если научитесь делать чистый Inherited тоже будет удобно. Опять же, of(context) - это сок дерева flutter

  5. Часто приходится использовать что-то в духе mvvс и иметь view-model для управления некоторым (сложным и не очень) состоянием view. В данном случае так же встаёт вопрос о роутинге, который может быть сильно подвязан к ? чему угодно в некоторых проектах, что я видывал. Где он будет размещён, какую роль играет?

  6. Вопросы тестирования данных сервисов/репозиториев/бизнес-логики. В долгоживущих проектах это критически важно. Как это можно реализовать и в том числе с учетом такого кода?:

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

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории