Урок 16 из 25 · Месяц 5. Clean Architecture и MVVM — интернет-магазин
Riverpod на примерах и анимации
Содержание урока
В этом уроке две темы. Первая — Riverpod, популярная альтернатива связке Bloc + GetIt: он одновременно и внедряет зависимости, и хранит состояние. Перепишем на нём каталог и корзину магазина, чтобы вы могли сравнить подходы. Вторая — анимации: неявные, явные через AnimationController и переходы Hero, которые делают магазин живым.
Riverpod: что это и зачем
Riverpod — библиотека управления состоянием от автора пакета provider. Главная идея — провайдер: глобально объявленный «рецепт», как получить значение. Значением может быть что угодно: Dio, репозиторий, список товаров, корзина.
Аналогия: провайдер — это кран с водой. Вы не думаете, откуда вода приходит и как её очищают, — просто открываете кран (ref.watch) там, где она нужна. Если вода «поменялась», все, кто пьёт из крана, сразу получают новую.
Чем Riverpod отличается от того, что мы делали:
| Задача | Bloc + GetIt | Riverpod |
|---|---|---|
| Внедрение зависимостей | sl.registerLazySingleton(...) |
Provider((ref) => ...) |
| Асинхронная загрузка | Bloc + состояния loading/loaded/error | FutureProvider + AsyncValue |
| Изменяемое состояние | Cubit / Bloc |
Notifier |
| Доступ в виджете | BlocBuilder, context.read |
ConsumerWidget, ref.watch, ref.read |
Оба подхода хороши и встречаются в вакансиях. Bloc строже (события, явные состояния), Riverpod — короче.
Установка
flutter pub add flutter_riverpod
Всё приложение оборачивается в ProviderScope — это «хранилище», где живут значения провайдеров:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: ShopApp()));
}
Provider: зависимости
Обычный Provider отдаёт значение, которое не меняется, — идеально для DI:
// lib/core/di/providers.dart
final dioProvider = Provider<Dio>(
(ref) => Dio(BaseOptions(baseUrl: 'https://dummyjson.com')),
);
final apiClientProvider = Provider((ref) => ApiClient(ref.watch(dioProvider)));
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => ProductRepositoryImpl(
ProductRemoteDataSourceImpl(ref.watch(apiClientProvider)),
),
);
ref— «пульт», через который провайдер обращается к другим провайдерам.ref.watch(dioProvider)— получитьDio. Тип указывать не нужно: он известен из объявления.- Все слои Clean Architecture остаются прежними — меняется только способ их собрать.
FutureProvider: загрузка каталога
// lib/features/products/presentation/providers/products_providers.dart
final productsProvider = FutureProvider<List<Product>>((ref) async {
final result = await ref.watch(productRepositoryProvider).getProducts();
return switch (result) {
Success(:final data) => data,
Failed(:final failure) => throw failure,
};
});
FutureProvider сам следит за состоянием загрузки и отдаёт AsyncValue — значение в одном из трёх видов: данные, загрузка или ошибка. Именно то, что мы вручную описывали состояниями Bloc.
Экран:
class ProductsPage extends ConsumerWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final products = ref.watch(productsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Каталог')),
body: products.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text(error is Failure ? error.message : 'Ошибка'),
),
data: (items) => RefreshIndicator(
onRefresh: () => ref.refresh(productsProvider.future),
child: ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => ProductTile(product: items[i]),
),
),
),
);
}
}
ConsumerWidget— какStatelessWidget, но вbuildприходит ещёWidgetRef ref.ref.watch(productsProvider)— подписаться: при изменении значения виджет перерисуется..when(loading:, error:, data:)— нарисовать каждый из трёх вариантов.ref.refresh(productsProvider.future)— перезапустить загрузку и вернутьFutureдляRefreshIndicator.
family: провайдер с параметром
Для карточки товара нужен id. Модификатор .family добавляет провайдеру параметр:
final productProvider = FutureProvider.family<Product, int>((ref, id) async {
final result = await ref.watch(productRepositoryProvider).getProductById(id);
return switch (result) {
Success(:final data) => data,
Failed(:final failure) => throw failure,
};
});
// в виджете:
final product = ref.watch(productProvider(42));
Для каждого id создаётся своё значение: productProvider(1) и productProvider(2) не мешают друг другу.
Notifier: корзина
Когда состояние меняется по действиям пользователя, используют Notifier — аналог Cubit:
// lib/features/cart/presentation/providers/cart_provider.dart
class CartNotifier extends Notifier<List<CartItem>> {
@override
List<CartItem> build() => const []; // начальное состояние
void add(Product product) {
final index = state.indexWhere((i) => i.product.id == product.id);
if (index == -1) {
state = [...state, CartItem(product: product, quantity: 1)];
} else {
final item = state[index];
state = [
for (final i in state)
if (i == item) CartItem(product: i.product, quantity: i.quantity + 1) else i,
];
}
}
void remove(int productId) =>
state = state.where((i) => i.product.id != productId).toList();
}
final cartProvider =
NotifierProvider<CartNotifier, List<CartItem>>(CartNotifier.new);
build()возвращает начальное состояние.state = ...— то же, чтоemitв Cubit. Присваиваем новый список, а не меняем старый.CartNotifier.new— ссылка на конструктор.
Использование:
class AddToCartButton extends ConsumerWidget {
const AddToCartButton({super.key, required this.product});
final Product product;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Перерисуемся, только если изменилось количество позиций
final count = ref.watch(cartProvider.select((items) => items.length));
return FilledButton(
onPressed: () => ref.read(cartProvider.notifier).add(product),
child: Text('В корзину ($count)'),
);
}
}
| Метод | Где использовать | Что делает |
|---|---|---|
ref.watch(p) |
В build |
Получить значение и перерисоваться при изменении |
ref.watch(p.select(...)) |
В build |
Перерисоваться только при изменении части — как BlocSelector |
ref.read(p) |
В обработчиках нажатий | Получить значение один раз, без подписки |
ref.listen(p, ...) |
В build |
Побочные действия — как BlocListener |
Анимации: неявные
Неявная (implicit) анимация — виджет сам плавно анимирует переход, когда вы меняете его свойство. Вы говорите «стань зелёным», он плавно зеленеет. Такие виджеты начинаются со слова Animated.
class FavoriteButton extends StatefulWidget {
const FavoriteButton({super.key});
@override
State<FavoriteButton> createState() => _FavoriteButtonState();
}
class _FavoriteButtonState extends State<FavoriteButton> {
bool _isFavorite = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _isFavorite = !_isFavorite),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
padding: EdgeInsets.all(_isFavorite ? 12 : 8),
decoration: BoxDecoration(
color: _isFavorite ? Colors.red : Colors.grey.shade300,
shape: BoxShape.circle,
),
child: Icon(Icons.favorite, color: _isFavorite ? Colors.white : Colors.grey),
),
);
}
}
AnimatedContainer при каждом setState сравнивает старые и новые padding и color и плавно переходит между ними за duration. curve — характер движения: easeOut быстро стартует и плавно тормозит.
Другие полезные неявные виджеты:
// Плавное появление/исчезание
AnimatedOpacity(opacity: inStock ? 1 : 0.4, duration: const Duration(milliseconds: 200), child: card);
// Анимированная смена содержимого: число в корзине «перелистывается»
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, animation) => ScaleTransition(scale: animation, child: child),
child: Text('$count', key: ValueKey(count)),
);
// Анимация любого значения от begin до end
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: product.rating),
duration: const Duration(seconds: 1),
builder: (context, value, _) => Text('★ ${value.toStringAsFixed(1)}'),
);
У AnimatedSwitcher важен key: по нему виджет понимает, что содержимое сменилось. Без ValueKey(count) анимации не будет.
Анимации: явные
Явная (explicit) анимация — вы сами управляете ею через AnimationController: запускаете, останавливаете, повторяете. Нужна, когда анимация должна стартовать по событию, а не по изменению свойства, или повторяться.
Пример: значок корзины «подпрыгивает», когда добавили товар.
class CartIcon extends StatefulWidget {
const CartIcon({super.key, required this.count});
final int count;
@override
State<CartIcon> createState() => _CartIconState();
}
class _CartIconState extends State<CartIcon> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
late final Animation<double> _scale = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1, end: 1.4), weight: 50),
TweenSequenceItem(tween: Tween(begin: 1.4, end: 1), weight: 50),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
@override
void didUpdateWidget(CartIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.count > oldWidget.count) _controller.forward(from: 0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scale,
child: Badge(label: Text('${widget.count}'), child: const Icon(Icons.shopping_cart)),
);
}
}
Разбор:
AnimationController— «мотор» анимации. Он выдаёт числа от 0 до 1 заduration.vsync: thisиSingleTickerProviderStateMixin— подключают контроллер к частоте обновления экрана и ставят анимацию на паузу, когда виджет не виден.Tweenпревращает 0…1 в нужный диапазон (1 → 1.4),TweenSequenceсклеивает несколько отрезков,CurvedAnimationзадаёт характер движения.didUpdateWidgetвызывается, когда родитель передал новыйcount. Если товаров стало больше — запускаем с начала:forward(from: 0).ScaleTransition— готовый виджет, который масштабирует ребёнка по анимации. Есть такжеFadeTransition,SlideTransition,RotationTransition, а для своих эффектов —AnimatedBuilder.dispose()обязателен: иначе контроллер продолжит работать после закрытия экрана.
Методы контроллера: forward() — вперёд, reverse() — назад, repeat() — по кругу (например, для скелетона загрузки), stop() — остановить.
| Неявные | Явные | |
|---|---|---|
| Как запускаются | Изменением свойства в setState |
Методами контроллера |
| Код | 3–5 строк | StatefulWidget + контроллер + dispose |
| Повтор, пауза, реверс | Нет | Да |
| Когда брать | Почти всегда | Сложные, повторяющиеся, по событию |
Hero: полёт картинки между экранами
Hero — анимация, при которой виджет «перелетает» с одного экрана на другой. В магазине так обычно летит фото товара из списка в карточку.
// В списке
Hero(
tag: 'product-image-${product.id}',
child: Image.network(product.imageUrl, width: 56, height: 56, fit: BoxFit.cover),
)
// На экране товара
Hero(
tag: 'product-image-${product.id}',
child: Image.network(product.imageUrl, height: 300, fit: BoxFit.cover),
)
Flutter находит на старом и новом экранах Hero с одинаковым tag и при обычном Navigator.push плавно переносит картинку, меняя размер и положение. Больше ничего писать не нужно.
Практика
- Подключите Riverpod и выведите каталог через
FutureProviderиConsumerWidgetсRefreshIndicator. - Сделайте экран товара на
FutureProvider.family— по нажатию на элемент списка открывается подробная карточка по id. - Перепишите корзину на
CartNotifier. Кнопка «В корзину» должна использоватьref.read(...notifier), а значок вAppBar—ref.watch(cartProvider.select(...)). - Добавьте
FavoriteButtonнаAnimatedContainerи счётчик корзины наAnimatedSwitcher. - Сделайте «прыгающий»
CartIconнаAnimationControllerи Hero-переход картинки из списка в карточку товара.
Итоги
- Riverpod объединяет DI и состояние: провайдеры — глобальные «рецепты» значений внутри
ProviderScope. Provider— для зависимостей,FutureProvider— для загрузки сAsyncValue.when,Notifier— для изменяемого состояния.- В
buildиспользуемref.watch(иselect), в обработчиках —ref.read, для побочных действий —ref.listen. - Неявные анимации (
AnimatedContainer,AnimatedSwitcher,TweenAnimationBuilder) запускаются изменением свойства. - Явные анимации управляются
AnimationControllerсvsyncи обязательно освобождаются вdispose(). Heroс одинаковымtagна двух экранах даёт анимацию перехода без лишнего кода.