Урок 12 из 25 · Месяц 5. Clean Architecture и MVVM — интернет-магазин
Generics, базовые классы, enum, ScreenUtil и extensions
Содержание урока
- Generics: коробка с этикеткой
- Generic-функции и ограничения
- Result: generic вместо исключений
- Базовые классы
- Базовый UseCase
- Базовый репозиторий
- Прослойки: не пускаем библиотеки в весь код
- Enum: перечисления
- Enhanced enum: enum с полями
- Enum и switch
- Extensions: новые методы для чужих классов
- ScreenUtil: адаптивные размеры
- Подключение
- Единицы измерения
- Типичные ошибки со ScreenUtil
- Практика
- Итоги
Когда в магазине появляются десятки UseCase и репозиториев, код начинает повторяться: одинаковые try/catch, одинаковые проверки статуса, одинаковые отступы. В этом уроке разберём инструменты, которые убирают повторы: generics, базовые классы, enum, прослойки между библиотеками и вашим кодом, extensions и пакет ScreenUtil для адаптивной вёрстки под разные экраны.
Generics: коробка с этикеткой
Generic (обобщённый тип) — это класс или функция, которые работают с любым типом, но при этом помнят, с каким именно. Вы уже пользовались ими: List<Product>, Future<String>, Bloc<ProductsEvent, ProductsState>.
Аналогия: коробка с этикеткой. Коробка одна и та же, но на этикетке написано «Обувь» или «Книги», и вы точно знаете, что достанете.
class Box<T> {
Box(this.value);
final T value;
}
void main() {
final a = Box<int>(42);
final b = Box('кроссовки'); // T = String, Dart вывел сам
int number = a.value; // ок, тип известен
String text = b.value; // ок
// int wrong = b.value; // ошибка компиляции: String нельзя присвоить int
}
<T>— параметр типа. ВместоTпри использовании подставляется конкретный тип.- Без generics пришлось бы писать
dynamic, и ошибки всплывали бы только при запуске.
Generic-функции и ограничения
Функция тоже может иметь параметр типа:
T? firstWhereOrNull<T>(List<T> items, bool Function(T) test) {
for (final item in items) {
if (test(item)) return item;
}
return null;
}
final cheap = firstWhereOrNull(products, (p) => p.price < 10); // Product?
Ограничить допустимые типы можно через extends: в <T extends Equatable> подойдёт только наследник Equatable.
Result: generic вместо исключений
В прошлых уроках репозиторий бросал Failure, а Bloc ловил его через try/catch. Это работает, но легко забыть catch. Популярный подход — возвращать результат, в котором либо данные, либо ошибка:
// lib/core/result/result.dart
import '../error/failures.dart';
sealed class Result<T> {
const Result();
}
final class Success<T> extends Result<T> {
const Success(this.data);
final T data;
}
final class Failed<T> extends Result<T> {
const Failed(this.failure);
final Failure failure;
}
Использование в Bloc:
final result = await _getProducts(const NoParams());
switch (result) {
case Success(:final data):
emit(ProductsLoaded(data));
case Failed(:final failure):
emit(ProductsError(failure.message));
}
Result<List<Product>>прямо в сигнатуре говорит: «здесь может быть ошибка, обработай её».sealed+switch— компилятор не даст забыть веткуFailed.- Мы не назвали класс
Error, потому что в Dart уже есть встроенныйError— совпадение имён только запутает.
Базовые классы
Базовый класс — это общий «шаблон», от которого наследуются похожие классы. Он фиксирует единый контракт и убирает копипаст.
Базовый UseCase
// lib/core/usecase/usecase.dart
import '../result/result.dart';
abstract class UseCase<Type, Params> {
const UseCase();
Future<Result<Type>> call(Params params);
}
/// Для UseCase, которым не нужны параметры
class NoParams {
const NoParams();
}
Теперь все UseCase магазина выглядят одинаково:
class GetProducts extends UseCase<List<Product>, NoParams> {
const GetProducts(this._repository);
final ProductRepository _repository;
@override
Future<Result<List<Product>>> call(NoParams params) => _repository.getProducts();
}
class GetProductById extends UseCase<Product, int> {
const GetProductById(this._repository);
final ProductRepository _repository;
@override
Future<Result<Product>> call(int id) => _repository.getProductById(id);
}
В UseCase<Type, Params> первый параметр — что UseCase возвращает, второй — что принимает. Интерфейс ProductRepository теперь тоже возвращает Result<...>.
Если параметров несколько, заведите для них маленький класс, например SearchParams(query: 'phone', limit: 20), и передайте его вторым типом: UseCase<List<Product>, SearchParams>.
Базовый репозиторий
В прошлом уроке у ProductRepositoryImpl был метод _guard, который ловил DioException. Такой же нужен корзине, заказам, профилю. Вынесем его в базовый класс:
// lib/core/data/base_repository.dart
import 'package:dio/dio.dart';
import '../error/failures.dart';
import '../result/result.dart';
abstract class BaseRepository {
Future<Result<T>> safeCall<T>(Future<T> Function() request) async {
try {
return Success(await request());
} on DioException catch (e) {
return Failed(_mapDioError(e));
} on FormatException {
return const Failed(ServerFailure('Сервер прислал неверные данные'));
}
}
Failure _mapDioError(DioException e) => switch (e.type) {
DioExceptionType.connectionError ||
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout =>
const NetworkFailure(),
_ when e.response?.statusCode == 401 => const UnauthorizedFailure(),
_ => ServerFailure('Ошибка сервера: ${e.response?.statusCode}'),
};
}
class ProductRepositoryImpl extends BaseRepository implements ProductRepository {
ProductRepositoryImpl(this._remote);
final ProductRemoteDataSource _remote;
@override
Future<Result<List<Product>>> getProducts() => safeCall(() async {
final models = await _remote.getProducts();
return models.map((m) => m.toEntity()).toList();
});
// getProductById и searchProducts — так же через safeCall
}
extends BaseRepository— получаемsafeCallпо наследству.implements ProductRepository— выполняем контракт domain. Одно другому не мешает.safeCall<T>— generic: типTберётся из того, что вернула лямбда (List<Product>)._ when ...— guard в switch-выражении: ветка сработает, только если условие истинно.
Прослойки: не пускаем библиотеки в весь код
Прослойка (обёртка, wrapper) — ваш класс между сторонней библиотекой и остальным приложением. Аналогия — переходник для розетки: техника подключается к переходнику, и при поездке в другую страну меняете только переходник.
Например, обёртка над Dio:
// lib/core/network/api_client.dart
import 'package:dio/dio.dart';
class ApiClient {
ApiClient(this._dio);
final Dio _dio;
Future<Map<String, dynamic>> get(
String path, {
Map<String, dynamic>? query,
}) async {
final response = await _dio.get<Map<String, dynamic>>(path, queryParameters: query);
return response.data ?? {};
}
Future<Map<String, dynamic>> post(String path, {Object? body}) async {
final response = await _dio.post<Map<String, dynamic>>(path, data: body);
return response.data ?? {};
}
}
Теперь data sources зависят от ApiClient, а не от Dio. Захотите добавить логирование всех запросов, общий заголовок языка или сменить Dio на http — правите один файл. Так же оборачивают хранилища (TokenStorage над FlutterSecureStorage в уроке про авторизацию) и аналитику.
Enum: перечисления
Enum — тип с фиксированным набором значений. Вместо «магических строк» вроде 'pending' получаем значения, которые проверяет компилятор.
enum LoadStatus { initial, loading, success, failure }
final status = LoadStatus.loading;
print(status.name); // loading
print(LoadStatus.values); // все значения
print(LoadStatus.values.byName('success')); // LoadStatus.success
Enhanced enum: enum с полями
В Dart 3 у enum могут быть поля, конструктор и методы. Статусы заказа в магазине:
import 'package:flutter/material.dart';
enum OrderStatus {
pending('Ожидает оплаты', Colors.orange),
paid('Оплачен', Colors.blue),
shipped('В пути', Colors.purple),
delivered('Доставлен', Colors.green),
cancelled('Отменён', Colors.red);
const OrderStatus(this.label, this.color);
final String label;
final Color color;
bool get canCancel => this == pending || this == paid;
static OrderStatus fromApi(String value) => OrderStatus.values.firstWhere(
(s) => s.name == value,
orElse: () => OrderStatus.pending,
);
}
Widget statusChip(OrderStatus status) => Chip(
label: Text(status.label),
backgroundColor: status.color.withValues(alpha: 0.15),
);
- Каждое значение вызывает конструктор:
pending('Ожидает оплаты', Colors.orange). - Поля enum всегда
final, а конструкторconst. fromApiпревращает строку с сервера в enum;orElseзащищает от неизвестного значения.
Enum и switch
String deliveryHint(OrderStatus status) => switch (status) {
OrderStatus.pending => 'Оплатите заказ в течение часа',
OrderStatus.paid => 'Скоро передадим в доставку',
OrderStatus.shipped => 'Курьер уже едет',
OrderStatus.delivered => 'Спасибо за покупку!',
OrderStatus.cancelled => 'Заказ отменён',
};
Добавите статус returned и забудете его здесь — проект не скомпилируется. Это плюс.
Extensions: новые методы для чужих классов
Extension (расширение) добавляет методы к существующему классу, не меняя его и не наследуясь. Вы уже видели toEntity() в уроке про data-слой.
// lib/core/extensions/extensions.dart
import 'package:flutter/material.dart';
extension PriceX on num {
String get asPrice => '\$${toStringAsFixed(2)}';
}
extension StringX on String {
String get capitalized =>
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
}
extension ContextX on BuildContext {
ThemeData get theme => Theme.of(this);
TextTheme get textTheme => Theme.of(this).textTheme;
Size get screenSize => MediaQuery.sizeOf(this);
void showSnack(String message) => ScaffoldMessenger.of(this)
.showSnackBar(SnackBar(content: Text(message)));
}
Text(product.price.asPrice, style: context.textTheme.titleMedium); // $9.99
Text('beauty'.capitalized); // Beauty
context.showSnack('Товар добавлен в корзину');
extension PriceX on num— «добавить кnumгеттерasPrice». Работает и дляint, и дляdouble.- Внутри расширения
this— это сам объект (число, строка, контекст). - Расширение видно только там, где импортирован его файл.
ScreenUtil: адаптивные размеры
Дизайнер рисует макет под один экран, например 375×812. Если писать width: 160, на узком телефоне карточки не влезут, а на широком будут пустоты.
Пакет flutter_screenutil пересчитывает размеры из макета пропорционально реальному экрану.
flutter pub add flutter_screenutil
Подключение
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812), // размер макета из Figma
minTextAdapt: true,
splitScreenMode: true,
builder: (context, child) => MaterialApp(
title: 'Shop',
home: child,
),
child: const ProductsPage(),
);
}
}
Единицы измерения
| Расширение | Что значит | Пример |
|---|---|---|
.w |
ширина пропорционально ширине экрана | width: 160.w |
.h |
высота пропорционально высоте экрана | SizedBox(height: 16.h) |
.r |
по меньшей стороне — для квадратов и радиусов | BorderRadius.circular(12.r) |
.sp |
размер шрифта | fontSize: 14.sp |
Карточка товара с ScreenUtil:
class ProductCard extends StatelessWidget {
const ProductCard({super.key, required this.product});
final Product product;
@override
Widget build(BuildContext context) {
return Container(
width: 160.w,
padding: EdgeInsets.all(8.r),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
color: context.theme.colorScheme.surfaceContainerHighest,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.network(product.imageUrl, height: 120.r, fit: BoxFit.cover),
SizedBox(height: 8.h),
Text(product.title, maxLines: 2, style: TextStyle(fontSize: 14.sp)),
Text(product.price.asPrice, style: TextStyle(fontSize: 16.sp)),
],
),
);
}
}
Типичные ошибки со ScreenUtil
constс.w.const SizedBox(height: 16.h)не скомпилируется:16.hвычисляется во время работы. Уберитеconst..hдля квадратов. Картинкаwidth: 100.w, height: 100.hстанет прямоугольной на длинных экранах. Для квадратов и кругов используйте.r.- ScreenUtil без
ScreenUtilInit. Размеры будут неверными или приложение упадёт —ScreenUtilInitдолжен оборачиватьMaterialApp. - Масштабирование всего подряд. На планшете всё станет огромным. Для планшетов часто делают отдельную раскладку через
LayoutBuilder, а ScreenUtil оставляют для телефонов.
Практика
- Напишите generic-класс
Pair<A, B>с полямиfirstиsecond. СоздайтеPair<String, int>('Кроссовки', 2)и выведите поля. - Добавьте в проект
Result<T>,UseCase<Type, Params>иNoParams. ПереведитеGetProductsиProductsBlocнаResult—try/catchв Bloc должен исчезнуть. - Вынесите обработку ошибок в
BaseRepository.safeCallи унаследуйте от негоProductRepositoryImpl. - Создайте enhanced enum
SortOption(по цене ↑, по цене ↓, по рейтингу) с полемlabelи методомList<Product> apply(List<Product> items). Покажите выбор сортировки вDropdownButton. - Подключите ScreenUtil с макетом 375×812 и переверстайте карточку товара через
.w,.h,.r,.sp. Запустите на маленьком и большом эмуляторе — пропорции должны сохраниться.
Итоги
- Generics (
<T>) дают переиспользуемый код без потери типов;extendsограничивает допустимые типы. Result<T>сSuccessиFailedделает ошибки частью сигнатуры, аswitchне даст их забыть.- Базовые классы (
UseCase<Type, Params>,BaseRepository) убирают копипаст и задают единый стиль. - Прослойки (
ApiClient,TokenStorage) изолируют сторонние библиотеки в одном месте. - Enhanced enum хранит поля и методы, а
switchпо enum проверяется на полноту. - Extensions добавляют удобные методы к
num,String,BuildContext. - ScreenUtil масштабирует размеры из макета:
.w,.h,.r,.sp, но безconst.