Как распределяется ответственность между элементами во Flutter-приложении?

Ответ

В моих проектах на Flutter я придерживаюсь четкого разделения ответственности, обычно следуя принципам Clean Architecture или ее адаптациям (например, Reso Coder's/Flutter TDD). Это позволяет поддерживать код тестируемым, масштабируемым и понятным для команды.

Слои и их зоны ответственности:

  1. Presentation Layer (Слой представления):

    • Ответственность: Отображение UI и обработка пользовательского ввода.
    • Компоненты: Виджеты (StatelessWidget, StatefulWidget), State, контроллеры (PageController, AnimationController).
    • Правило: Максимально "тупые" виджеты. Они получают данные и колбэки через параметры.

      // Пример "тупого" виджета
      class UserProfileView extends StatelessWidget {
      final User user;
      final VoidCallback onEditPressed;
      final bool isLoading;
      
      const UserProfileView({
      required this.user,
      required this.onEditPressed,
      this.isLoading = false,
      });
      
      @override
      Widget build(BuildContext context) {
      return Column(
        children: [
          CircleAvatar(url: user.avatarUrl),
          Text(user.name),
          if (isLoading) CircularProgressIndicator(),
          ElevatedButton(
            onPressed: onEditPressed,
            child: Text('Edit'),
          ),
        ],
      );
      }
      }
  2. Domain Layer (Доменный слой):

    • Ответственность: Содержит бизнес-логику и правила приложения. Не зависит от Flutter и внешних библиотек.
    • Компоненты: Entities (бизнес-объекты, например, User, Product), Use Cases (интеракторы), Repository Interfaces.

      // Use Case (интерактор) — чистая бизнес-логика
      class GetUserProfileUseCase {
      final UserRepository repository;
      GetUserProfileUseCase(this.repository);
      
      Future<Either<Failure, User>> execute(String userId) async {
      return await repository.getUser(userId);
      }
      }
  3. Data Layer (Слой данных):

    • Ответственность: Работа с данными: получение из сети, кэширование в БД, преобразование моделей.
    • Компоненты: Репозитории (имплементации), Data Sources (API, Local DB), Data Models (DTOs), Mappers.

      // Репозиторий (имплементация) соединяет домен с источниками данных
      class UserRepositoryImpl implements UserRepository {
      final UserRemoteDataSource remoteDataSource;
      final UserLocalDataSource localDataSource;
      
      @override
      Future<User> getUser(String id) async {
      try {
        final user = await remoteDataSource.fetchUser(id);
        await localDataSource.cacheUser(user);
        return user;
      } on SocketException {
        return localDataSource.getUser(id); // Fallback на кэш
      }
      }
      }

Связь слоев и управление состоянием: Для связи Presentation и Domain/Data слоев я использую паттерны управления состоянием, такие как Bloc (Cubit) или Provider (ChangeNotifier). Они выступают в роли "посредника":

  • Принимают события от UI (например, UserProfileLoadRequested).
  • Вызывают Use Cases из доменного слоя.
  • Преобразуют результат (User или Failure) в новое состояние (UserProfileLoading, UserProfileLoaded, UserProfileError).
  • Уведомляют UI об изменении состояния.

Итог: Виджеты отвечают только за UI, бизнес-логика изолирована в доменном слое, а работа с данными инкапсулирована в слое данных. Это позволяет, например, заменить GraphQL на REST API, изменив только Data Layer, или протестировать бизнес-логику без запуска Flutter.

Ответ 18+ 🔞

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

Вот смотри, я в Flutter'е обычно делю всю эту пиздопроебибну на три основных слоя. Как будто бутерброд делаешь: хлеб сверху, хлеб снизу, а посередине — самое важное, колбаса. Только тут не колбаса, а логика, которую потом не стыдно будет тестить.

Первый слой — это лицо проекта, Presentation Layer. Его задача — простая как три копейки: показывать кнопочки, текст и слушать, куда пользователь тыкает. Всё. Больше от него ничего не требуется. Делаешь виджеты максимально тупыми, чтобы они только получали готовые данные и колбэки, а сами нихуя не решали. Чистая картинка.

// Смотри, какой виджет-дурачок. Ему всё в рот положили.
class UserProfileView extends StatelessWidget {
  final User user; // Держи, рисуй
  final VoidCallback onEditPressed; // Держи, жми
  final bool isLoading; // Держи, крутись если надо

  const UserProfileView({
    required this.user,
    required this.onEditPressed,
    this.isLoading = false,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CircleAvatar(url: user.avatarUrl),
        Text(user.name),
        if (isLoading) CircularProgressIndicator(),
        ElevatedButton(
          onPressed: onEditPressed,
          child: Text('Edit'),
        ),
      ],
    );
  }
}

Вот и вся его ответственность. Никаких http.get или запросов к базе тут быть не должно, ядрёна вошь! Иначе потом разбираться — волнение ебать.

Второй слой — это святое, Domain Layer. Вот тут живёт вся бизнес-логика, мозги приложения. Самое интересное. Этот слой вообще не должен знать, что такое Flutter, Dio или SQLite. Он чистый, как слеза младенца. Тут твои Use Cases (или интеракторы, кому как удобнее) и интерфейсы репозиториев.

// Use Case — это типа команды. "Принеси-подай-положи".
class GetUserProfileUseCase {
  final UserRepository repository; // Говорит ЧТО сделать, но не КАК
  GetUserProfileUseCase(this.repository);

  Future<Either<Failure, User>> execute(String userId) async {
    return await repository.getUser(userId); // Просто делегирует
  }
}

Если завтра тебе скажут: "а давай теперь пользователя не по айдишнику, а по никнейму ищем", ты меняешь логику тут, в домене. И всё.

Ну и третий — Data Layer, общага для данных. Тут уже живут все эти штуки, которые реально лезут в сеть, пишут в базу, парсят JSON'ы. Репозитории здесь получают конкретную имплементацию. Слой, где вся грязная работа.

// А вот тут репозиторий уже знает КАК именно достать юзера.
class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource; // Сеть
  final UserLocalDataSource localDataSource;   // Локальная база

  @override
  Future<User> getUser(String id) async {
    try {
      final user = await remoteDataSource.fetchUser(id); // Пробуем с сервака
      await localDataSource.cacheUser(user); // Если получилось — кэшируем
      return user;
    } on SocketException {
      return localDataSource.getUser(id); // Если интернета нет — берём из кэша
    }
  }
}

Представляешь, завтра бэкендеры взбесятся и с REST'а переедут на GraphQL? Ты меняешь только UserRemoteDataSource, а вся остальная логика приложения даже не чихнет. Красота.

А как же это всё связать? Вот тут на сцену выходят наши спасители — Bloc (Cubit) или Provider. Это такие менеджеры-посредники, у которых доверия ебать ноль. Виджет говорит ему: "Эй, нужно загрузить профиль!" Блок говорит Use Case'у: "Слышь, выполни-ка вот это". Use Case стучится в репозиторий, репозиторий мечется между сетью и базой, и в итоге блок получает либо юзера, либо ошибку. Он эту всю историю упаковывает в новое состояние (Loading, Loaded, Error) и кричит виджету: "Эй, дурачок, обновляйся! Вот тебе данные".

Итог получается простой: виджеты — рисуют, домен — думает, данные — носятся как угорелые между сервером и телефоном. И когда через полгода придёт новый чувак в проект и спросит "а где тут запрос на апишку?", ты не будешь полчаса искать её в 3000 строк кода одного виджета, а просто пойдёшь в слой данных. И жить станет проще, ёпта.