Урок 14 из 25 · Месяц 5. Clean Architecture и MVVM — интернет-магазин
Пагинация в архитектуре приложения
Содержание урока
В каталоге интернет-магазина сотни товаров, и загружать их все сразу — долго и расточительно. Вместо этого приложение берёт товары порциями: первые 20, а когда пользователь долистал до конца, — следующие 20. Это и есть пагинация. В этом уроке реализуем бесконечную ленту каталога по всем слоям Clean Architecture: от запроса с параметрами до Bloc и ScrollController.
Что такое пагинация и зачем она нужна
Аналогия — книга. Вы не читаете все страницы одновременно: открываете одну, дочитываете, переворачиваете. Так и приложение: показывает «страницу» товаров и подгружает следующую, когда нужно.
Без пагинации:
- первый экран открывается медленно — ждём сотни товаров;
- тратится мобильный трафик на товары, до которых пользователь не долистает;
- растёт расход памяти.
Виды пагинации
| Вид | Как выглядит запрос | Где встречается |
|---|---|---|
| Offset / limit | ?skip=40&limit=20 — пропусти 40, дай 20 |
Каталоги, таблицы |
| Page / size | ?page=3&size=20 — третья страница по 20 |
То же самое, другая запись |
| Cursor | ?after=abc123&limit=20 — дай 20 после элемента abc123 |
Ленты, чаты, где данные часто меняются |
Offset и page — одно и то же: skip = (page - 1) * size. Cursor надёжнее, когда сверху постоянно добавляются новые элементы: при offset товары могут «сдвинуться» и повториться. Наш API dummyjson.com использует offset:
GET /products?limit=20&skip=0 → товары 1–20
GET /products?limit=20&skip=20 → товары 21–40
Ответ содержит total — общее количество товаров. По нему мы поймём, что дальше грузить нечего.
Слой domain: страница как сущность
Опишем «страницу» один раз через generic — она пригодится и для заказов, и для отзывов:
// lib/core/pagination/paginated.dart
class Paginated<T> {
const Paginated({required this.items, required this.total, required this.skip});
final List<T> items;
final int total;
final int skip;
/// Есть ли ещё данные после этой страницы
bool get hasMore => skip + items.length < total;
}
Пример: skip = 180, пришло 14 товаров, total = 194. 180 + 14 = 194, значит hasMore == false — это последняя страница.
Добавим метод в интерфейс репозитория и новый UseCase (базовый UseCase и Result — из урока про generics):
// domain/repositories/product_repository.dart
abstract interface class ProductRepository {
Future<Result<Paginated<Product>>> getProductsPage({required int skip, required int limit});
// ...остальные методы
}
// domain/usecases/get_products_page.dart
class PageParams {
const PageParams({required this.skip, this.limit = 20});
final int skip;
final int limit;
}
class GetProductsPage extends UseCase<Paginated<Product>, PageParams> {
const GetProductsPage(this._repository);
final ProductRepository _repository;
@override
Future<Result<Paginated<Product>>> call(PageParams params) =>
_repository.getProductsPage(skip: params.skip, limit: params.limit);
}
Domain по-прежнему не знает, что на сервере параметры называются skip и limit в URL. Если бэкенд перейдёт на page/size, пересчёт сделает слой data.
Слой data: запрос с параметрами
// data/datasources/product_remote_data_source.dart
@override
Future<({List<ProductModel> items, int total})> getProductsPage({
required int skip,
required int limit,
}) async {
final json = await _api.get(
'/products',
query: {'skip': skip, 'limit': limit},
);
final items = (json['products'] as List)
.map((e) => ProductModel.fromJson(e as Map<String, dynamic>))
.toList();
return (items: items, total: json['total'] as int);
}
_api — наша прослойка ApiClient над Dio. Метод возвращает record с моделями и общим количеством.
// data/repositories/product_repository_impl.dart
@override
Future<Result<Paginated<Product>>> getProductsPage({
required int skip,
required int limit,
}) =>
safeCall(() async {
final page = await _remote.getProductsPage(skip: skip, limit: limit);
return Paginated(
items: page.items.map((m) => m.toEntity()).toList(),
total: page.total,
skip: skip,
);
});
Репозиторий, как обычно, превращает модели в сущности, а ошибки — в Failed через safeCall.
Слой presentation: Bloc для ленты
Состояние
Для ленты одного статуса мало: нужно хранить уже загруженные товары, пока грузится следующая страница. Поэтому состояние — один класс с полями и copyWith:
// presentation/bloc/catalog_state.dart
// LoadStatus { initial, loading, success, failure } — enum из урока про generics
class CatalogState extends Equatable {
const CatalogState({
this.status = LoadStatus.initial,
this.products = const [],
this.hasReachedMax = false,
this.errorMessage,
});
final LoadStatus status;
final List<Product> products;
final bool hasReachedMax;
final String? errorMessage;
CatalogState copyWith({
LoadStatus? status,
List<Product>? products,
bool? hasReachedMax,
String? errorMessage,
}) =>
CatalogState(
status: status ?? this.status,
products: products ?? this.products,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
errorMessage: errorMessage,
);
@override
List<Object?> get props => [status, products, hasReachedMax, errorMessage];
}
productsрастёт с каждой страницей.hasReachedMax— «дошли до конца, больше не грузим».errorMessageвcopyWithбез??: при каждом новом состоянии ошибка сбрасывается, если её явно не передали.
События и Bloc
flutter pub add bloc_concurrency
// presentation/bloc/catalog_bloc.dart
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
sealed class CatalogEvent {}
final class CatalogNextPageRequested extends CatalogEvent {}
final class CatalogRefreshed extends CatalogEvent {}
class CatalogBloc extends Bloc<CatalogEvent, CatalogState> {
CatalogBloc(this._getPage) : super(const CatalogState()) {
on<CatalogNextPageRequested>(_onNextPage, transformer: droppable());
on<CatalogRefreshed>(_onRefresh, transformer: droppable());
}
final GetProductsPage _getPage;
static const _limit = 20;
Future<void> _onNextPage(
CatalogNextPageRequested event,
Emitter<CatalogState> emit,
) async {
if (state.hasReachedMax) return;
emit(state.copyWith(status: LoadStatus.loading));
final result = await _getPage(
PageParams(skip: state.products.length, limit: _limit),
);
switch (result) {
case Success(:final data):
emit(state.copyWith(
status: LoadStatus.success,
products: [...state.products, ...data.items],
hasReachedMax: !data.hasMore,
));
case Failed(:final failure):
emit(state.copyWith(
status: LoadStatus.failure,
errorMessage: failure.message,
));
}
}
Future<void> _onRefresh(
CatalogRefreshed event,
Emitter<CatalogState> emit,
) async {
emit(state.copyWith(status: LoadStatus.loading));
final result = await _getPage(const PageParams(skip: 0, limit: _limit));
switch (result) {
case Success(:final data):
emit(CatalogState(
status: LoadStatus.success,
products: data.items,
hasReachedMax: !data.hasMore,
));
case Failed(:final failure):
emit(state.copyWith(status: LoadStatus.failure, errorMessage: failure.message));
}
}
}
Разбор главного:
skip: state.products.length— сколько товаров уже есть, столько и пропускаем. Отдельный счётчик страниц не нужен.[...state.products, ...data.items]— создаём новый список из старых и новых товаров. Если сделатьstate.products.addAll(...), Equatable не увидит изменений (тот же объект списка), и экран не обновится.droppable()изbloc_concurrency— пока событие обрабатывается, новые такие же события отбрасываются. Пользователь быстро крутит ленту,ScrollControllerшлёт десять событий подряд, но запрос уйдёт один.- Refresh при успехе заменяет список целиком, а при ошибке оставляет старые товары на экране.
Регистрация, как всегда, фабрикой: sl.registerFactory(() => CatalogBloc(sl())); и sl.registerLazySingleton(() => GetProductsPage(sl()));.
Экран: бесконечная лента
// presentation/pages/catalog_page.dart
class CatalogPage extends StatelessWidget {
const CatalogPage({super.key});
@override
Widget build(BuildContext context) => BlocProvider(
create: (_) => sl<CatalogBloc>()..add(CatalogNextPageRequested()),
child: const CatalogView(),
);
}
class CatalogView extends StatefulWidget {
const CatalogView({super.key});
@override
State<CatalogView> createState() => _CatalogViewState();
}
class _CatalogViewState extends State<CatalogView> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final position = _scrollController.position;
// До конца осталось меньше 300 пикселей — грузим следующую страницу
if (position.pixels >= position.maxScrollExtent - 300) {
context.read<CatalogBloc>().add(CatalogNextPageRequested());
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Каталог')),
body: BlocBuilder<CatalogBloc, CatalogState>(
builder: (context, state) {
if (state.products.isEmpty) {
return switch (state.status) {
LoadStatus.failure => _ErrorView(message: state.errorMessage),
_ => const Center(child: CircularProgressIndicator()),
};
}
return RefreshIndicator(
onRefresh: () async {
final bloc = context.read<CatalogBloc>()..add(CatalogRefreshed());
await bloc.stream.firstWhere((s) => s.status != LoadStatus.loading);
},
child: ListView.builder(
controller: _scrollController,
itemCount: state.products.length + (state.hasReachedMax ? 0 : 1),
itemBuilder: (context, i) {
if (i < state.products.length) {
return ProductTile(product: state.products[i]);
}
return _BottomLoader(state: state);
},
),
);
},
),
);
}
}
class _BottomLoader extends StatelessWidget {
const _BottomLoader({required this.state});
final CatalogState state;
@override
Widget build(BuildContext context) {
if (state.status == LoadStatus.failure) {
return TextButton(
onPressed: () => context.read<CatalogBloc>().add(CatalogNextPageRequested()),
child: const Text('Ошибка загрузки. Повторить'),
);
}
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
}
Разбор:
itemCount: products.length + 1— лишний элемент в конце списка: там показываем индикатор загрузки или кнопку «Повторить». КогдаhasReachedMax, его нет._onScrollсрабатывает при каждом сдвиге ленты. Запас в 300 пикселей нужен, чтобы следующая страница начала грузиться заранее и пользователь не упирался в конец.RefreshIndicator— «потяни вниз, чтобы обновить».onRefreshдолжен вернутьFuture: индикатор крутится, пока Future не завершится. Поэтому_onRefreshсначала выдаётloading, а мы ждём первое состояние после него. Если бы Bloc сразу выдал такое же состояние, как текущее, Equatable отбросил бы его, и индикатор крутился бы вечно.- Ошибка первой страницы — на весь экран (
_ErrorViewс текстом и кнопкой, напишите сами). Ошибка следующей страницы — внизу, чтобы не терять уже загруженные товары.
Типичные ошибки
- Дубли в списке. Нет
droppable()или проверкиhasReachedMax— одна страница грузится несколько раз. - Первая страница не заполняет экран. Если
limitмаленький (например, 5), ленту нельзя прокрутить, и_onScrollникогда не сработает. Беритеlimitс запасом — 20 и больше. - Изменение списка на месте (
state.products.add(...)). Экран не перерисуется. Всегда создавайте новый список. - Забыли
dispose()уScrollController. Утечка памяти и ошибки после закрытия экрана. - Сброс списка при ошибке. Ошибка второй страницы не должна стирать первую.
Практика
- Добавьте
Paginated<T>и проверьтеhasMore: дляskip: 180, items: 14 шт., total: 194должно бытьfalse, дляskip: 0, items: 20, total: 194—true. - Реализуйте
getProductsPageв data source и репозитории и выведите в консоль названия товаров со второй страницы (skip: 20). - Соберите
CatalogBlocи экран с бесконечной лентой. Ожидаемый результат: при прокрутке подгружаются новые товары, внизу крутится индикатор, после 194-го товара он исчезает. - Отключите интернет, долистайте до конца загруженного — внизу должна появиться кнопка «Повторить», а уже загруженные товары остаться на месте.
- Добавьте пагинацию для поиска: событие
CatalogQueryChanged(String query)сбрасывает список и грузит первую страницуGET /products/search?q=...&limit=20&skip=0.
Итоги
- Пагинация — загрузка данных порциями: offset/limit, page/size или cursor.
- Generic-класс
Paginated<T>хранит элементы,totalи умеет отвечатьhasMore. - Domain описывает «дай страницу», а data превращает это в параметры запроса.
- Состояние ленты — один класс с
status,products,hasReachedMaxиcopyWith. skip = products.length, новые товары добавляем в новый список.droppable()изbloc_concurrencyзащищает от повторной загрузки одной страницы.ScrollControllerс запасом до конца ленты, нижний индикатор иRefreshIndicator— основа UI.