Kodokon kodokon.com

Архитектура приложения: интерфейс, логика, данные

Раздели приложение Flutter на герметичные слои с помощью паттерна repository и типизированных sealed-результатов.

11 мин · 3 вопросов

Открыть этот урок в Kodokon

Приложение Flutter, построенное надолго, разделяет три зоны ответственности: представление (виджеты, BuildContext), логику (состояние, сценарии использования) и данные (API, локальная база). Главное правило - направление зависимостей: представление зависит от логики, логика зависит от абстракций данных, и никогда наоборот. Конкретно: ни BuildContext, ни импорт material.dart не должны появляться ниже слоя представления - контроллер, который сам показывает свой SnackBar, невозможно протестировать. Ещё один момент, о котором часто забывают: ошибки - часть контракта между слоями. Вместо того чтобы позволять нетипизированным исключениям утекать наружу, опиши результат через sealed-класс - компилятор заставит тебя обработать каждый случай.

DART
sealed class Result<T> {
  const Result();
}

class Success<T> extends Result<T> {
  const Success(this.value);
  final T value;
}

class Failure<T> extends Result<T> {
  const Failure(this.error);
  final Object error;
}

class Course {
  const Course({required this.id, required this.title});

  final String id;
  final String title;
}
Sealed-тип делает отказ явным прямо в сигнатуре.

Repository - это граница между доменом и внешним миром. Его точная роль: отдавать доменные сущности (никогда не сырые DTO из API), скрывать стратегию доступа (сеть, кеш, локальная база) и держать в одном месте политику свежести данных. В Dart 3 объявляй контракт через abstract interface class: этот модификатор разрешает извне библиотеки только implements - случайно унаследовать кусок реализации не получится, и контракт остаётся чистым. Преобразование JSON в сущности живёт в реализации: если завтра API переименует поле, изменится только слой данных.

DART
abstract interface class CourseApi {
  Future<List<Map<String, Object?>>> fetchRaw();
}

abstract interface class CourseRepository {
  Future<Result<List<Course>>> getAll();
  void invalidate();
}

class ApiCourseRepository implements CourseRepository {
  ApiCourseRepository(this._api);

  final CourseApi _api;
  List<Course>? _cache;

  @override
  void invalidate() => _cache = null;

  @override
  Future<Result<List<Course>>> getAll() async {
    final cached = _cache;
    if (cached != null) {
      return Success(cached);
    }
    try {
      final rows = await _api.fetchRaw();
      final courses = [
        for (final row in rows)
          Course(
            id: row['id'] as String,
            title: row['title'] as String,
          ),
      ];
      _cache = courses;
      return Success(courses);
    } on Exception catch (error) {
      return Failure(error);
    }
  }
}
Repository преобразует, кеширует и превращает ошибки в Result.

На стороне представления ChangeNotifier, переданный через конструктор, очень часто уже достаточен - тянуть целый фреймворк не нужно. Избегай глобальных синглтонов: они замораживают граф зависимостей и превращают подмену в тестах в мучение. ListenableBuilder перестраивает только свой builder, а исчерпывающий switch по Result фиксирует интерфейс: добавь третье состояние в sealed-тип, и компиляция упадёт везде, где оно не обработано. В этом вся ценность паттерна - забывчивость превращается в ошибку компиляции, а не в баг на проде.

DART
import 'package:flutter/material.dart';

class CourseListController extends ChangeNotifier {
  CourseListController(this._repository);

  final CourseRepository _repository;
  Result<List<Course>>? state;

  Future<void> load() async {
    state = null;
    notifyListeners();
    state = await _repository.getAll();
    notifyListeners();
  }
}

class CourseListScreen extends StatelessWidget {
  const CourseListScreen({
    super.key,
    required this.controller,
  });

  final CourseListController controller;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: controller,
      builder: (context, _) {
        return switch (controller.state) {
          null => const Center(
              child: CircularProgressIndicator(),
            ),
          Failure() => const Center(
              child: Text('Loading error'),
            ),
          Success(value: final courses) =>
            ListView.builder(
              itemCount: courses.length,
              itemBuilder: (context, i) => ListTile(
                title: Text(courses[i].title),
              ),
            ),
        };
      },
    );
  }
}
null = загрузка, а switch покрывает все случаи sealed-типа.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Почему интерфейс зависит от контракта CourseRepository, а не от ApiCourseRepository?
    • Чтобы подставить фальшивую реализацию в тестах и сменить источник данных, не трогая виджеты
    • Потому что абстрактные классы быстрее создаются
    • Потому что Dart запрещает создавать конкретный класс из виджета
  2. Какое преимущество даёт sealed-тип вроде Result по сравнению с исключением?
    • Компилятор проверяет, что switch исчерпывающий: забыть случай ошибки невозможно
    • Он ускоряет асинхронный код
    • Он избавляет от любого выделения объектов в куче
    • Он также ловит ошибки из изолятов
  3. Где должно жить преобразование JSON в сущность Course?
    • В слое данных, чтобы домен ничего не знал о формате API
    • В виджете, как можно ближе к отображению
    • В main(), перед runApp