Раздели приложение Flutter на герметичные слои с помощью паттерна repository и типизированных sealed-результатов.
Открыть этот урок в KodokonПриложение Flutter, построенное надолго, разделяет три зоны ответственности: представление (виджеты, BuildContext), логику (состояние, сценарии использования) и данные (API, локальная база). Главное правило - направление зависимостей: представление зависит от логики, логика зависит от абстракций данных, и никогда наоборот. Конкретно: ни BuildContext, ни импорт material.dart не должны появляться ниже слоя представления - контроллер, который сам показывает свой SnackBar, невозможно протестировать. Ещё один момент, о котором часто забывают: ошибки - часть контракта между слоями. Вместо того чтобы позволять нетипизированным исключениям утекать наружу, опиши результат через sealed-класс - компилятор заставит тебя обработать каждый случай.
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;
}Repository - это граница между доменом и внешним миром. Его точная роль: отдавать доменные сущности (никогда не сырые DTO из API), скрывать стратегию доступа (сеть, кеш, локальная база) и держать в одном месте политику свежести данных. В Dart 3 объявляй контракт через abstract interface class: этот модификатор разрешает извне библиотеки только implements - случайно унаследовать кусок реализации не получится, и контракт остаётся чистым. Преобразование JSON в сущности живёт в реализации: если завтра API переименует поле, изменится только слой данных.
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);
}
}
}На стороне представления ChangeNotifier, переданный через конструктор, очень часто уже достаточен - тянуть целый фреймворк не нужно. Избегай глобальных синглтонов: они замораживают граф зависимостей и превращают подмену в тестах в мучение. ListenableBuilder перестраивает только свой builder, а исчерпывающий switch по Result фиксирует интерфейс: добавь третье состояние в sealed-тип, и компиляция упадёт везде, где оно не обработано. В этом вся ценность паттерна - забывчивость превращается в ошибку компиляции, а не в баг на проде.
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),
),
),
};
},
);
}
}