Архитектура приложений Flutter и лучшие практики
FlutterPulseЭта статья переведена специально для канала FlutterPulse. В этом канале вы найдёте много интересных вещей, связанных с Flutter. Не забывайте подписываться! 🚀

Эта статья описывает архитектурный подход к созданию приложения на Flutter с использованием Clean Architecture, принципов SOLID и Riverpod для управления состоянием. В качестве примера рассматривается функция Crypto Watch-list для иллюстрации реализации. Кроме того, рассматриваются ключевые нефункциональные требования (NFRs) как лучшие практики для обеспечения надежного, масштабируемого и поддерживаемого приложения.
Часть 1: Clean Architecture с Riverpod для функции Crypto Watchlist
Обзор
Функция Crypto Watchlist позволяет пользователям просматривать список криптовалют, отслеживать их цены и добавлять/удалять монеты из списка наблюдения. Реализация соответствует Clean Architecture и принципам SOLID, используя Riverpod для управления состоянием, чтобы обеспечить разделение ответственности, тестируемость и масштабируемость.
Слои Clean Architecture
Clean Architecture делит приложение на три основных слоя:
- Слой представления: Содержит интерфейс пользователя (виджеты Flutter) и логику управления состоянием (провайдеры Riverpod). Он взаимодействует со слоем домена через кейсы использования.
- Слой домена: Основная бизнес-логика, включая сущности и кейсы использования. Он независим от фреймворков и внешних систем.
- Слой данных: Обрабатывает операции с данными (вызовы API, локальное хранилище) и отображает внешние данные на сущности домена.
1. Слой домена
Слой домена определяет бизнес-логику и сущности, независимые от Flutter или внешних систем.
Сущности:
class Crypto {
final String id;
final String name;
final double price;
final double change24h;
Crypto({required this.id, required this.name, required this.price, required this.change24h});
}Кейсы использования:
Кейсы использования инкапсулируют бизнес-правила. Для списка наблюдения:
- GetWatchlist: Получает список отслеживаемых криптовалют.
- AddToWatchlist: Добавляет криптовалюту в список наблюдения.
- RemoveFromWatchlist: Удаляет криптовалюту из списка наблюдения.
class GetWatchlist {
final CryptoRepository repository;
GetWatchlist(this.repository);
Future<List<Crypto>> execute() async {
return await repository.getWatchlist();
}
}
class AddToWatchlist {
final CryptoRepository repository;
AddToWatchlist(this.repository);
Future<void> execute(Crypto crypto) async {
await repository.addToWatchlist(crypto);
}
}Интерфейс репозитория:
Определяет контракт для операций с данными, сохраняя независимость слоя домена.
abstract class CryptoRepository {
Future<List<Crypto>> getWatchlist();
Future<void> addToWatchlist(Crypto crypto);
Future<void> removeFromWatchlist(String cryptoId);
}Принципы SOLID:
- Single Responsibility: Каждый кейс использования обрабатывает одно действие (например, GetWatchlist только получает данные).
- Open/Closed: Кейсы использования расширяемы через реализации репозитория.
- Interface Segregation: Интерфейс репозитория предоставляет только релевантные методы.
2. Слой данных
Слой данных реализует интерфейс репозитория, обрабатывая вызовы API и локальное хранилище.
Модели:
Расширьте сущности для включения сериализации JSON.
class CryptoModel extends Crypto {
CryptoModel({required String id, required String name, required double price, required double change24h})
: super(id: id, name: name, price: price, change24h: change24h);
factory CryptoModel.fromJson(Map<String, dynamic> json) {
return CryptoModel(
id: json['id'],
name: json['name'],
price: json['price'].toDouble(),
change24h: json['change_24h'].toDouble(),
);
}
}Источники данных:
- Удалённый (API): Получает актуальные данные о криптовалюте.
- Локальный (Hive/SharedPreferences): Кэширует данные списка наблюдения.
class CryptoRemoteDataSource {
Future<List<CryptoModel>> getCryptoPrices() async {
final response = await http.get(Uri.parse('https://api.example.com/coins'));
return (jsonDecode(response.body) as List).map((e) => CryptoModel.fromJson(e)).toList();
}
}
class CryptoLocalDataSource {
Future<List<Crypto>> getWatchlist() async {
// Получение из Hive/SharedPreferences
}
Future<void> saveWatchlist(List<Crypto> cryptos) async {
// Сохранение в Hive/SharedPreferences
}
}Реализация репозитория:
Объединяет удалённые и локальные источники данных.
class CryptoRepositoryImpl implements CryptoRepository {
final CryptoRemoteDataSource remoteDataSource;
final CryptoLocalDataSource localDataSource;
CryptoRepositoryImpl(this.remoteDataSource, this.localDataSource);
@override
Future<List<Crypto>> getWatchlist() async {
final cached = await localDataSource.getWatchlist();
if (cached.isNotEmpty) return cached;
final remoteData = await remoteDataSource.getCryptoPrices();
await localDataSource.saveWatchlist(remoteData);
return remoteData;
}
}Принципы SOLID:
- Инверсия зависимостей: Репозиторий зависит от абстракций (CryptoRemoteDataSource, CryptoLocalDataSource).
- Принцип подстановки Лискова: Источники данных могут быть заменены без влияния на репозиторий.
3. Слой представления
Слой представления использует Riverpod для управления состоянием и виджеты Flutter для интерфейса.
Провайдеры Riverpod:
Провайдер репозитория: Предоставляет экземпляр репозитория.
final cryptoRepositoryProvider = Provider<CryptoRepository>((ref) {
return CryptoRepositoryImpl(CryptoRemoteDataSource(), CryptoLocalDataSource());
});Провайдеры кейсов использования: Предоставляют кейсы использования.
final getWatchlistProvider = Provider<GetWatchlist>((ref) {
return GetWatchlist(ref.read(cryptoRepositoryProvider));
});Уведомление о состоянии списка наблюдения:
Управляет состоянием списка наблюдения и обрабатывает асинхронные операции.
class WatchlistNotifier extends StateNotifier<AsyncValue<List<Crypto>>> {
final GetWatchlist _getWatchlist;
final AddToWatchlist _addToWatchlist;
WatchlistNotifier(this._getWatchlist, this._addToWatchlist) : super(const AsyncValue.loading()) {
loadWatchlist();
}
Future<void> loadWatchlist() async {
state = const AsyncValue.loading();
try {
final cryptos = await _getWatchlist.execute();
state = AsyncValue.data(cryptos);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> addCrypto(Crypto crypto) async {
await _addToWatchlist.execute(crypto);
loadWatchlist(); // Обновить список
}
}
final watchlistProvider = StateNotifierProvider<WatchlistNotifier, AsyncValue<List<Crypto>>>((ref) {
return WatchlistNotifier(
ref.read(getWatchlistProvider),
ref.read(addToWatchlistProvider),
);
});Виджеты интерфейса:
Используйте watchlistProvider для отображения данных.
class WatchlistScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final watchlistState = ref.watch(watchlistProvider);
return Scaffold(
appBar: AppBar(title: Text('Crypto Watchlist')),
body: watchlistState.when(
data: (cryptos) => ListView.builder(
itemCount: cryptos.length,
itemBuilder: (context, index) {
final crypto = cryptos[index];
return ListTile(
title: Text(crypto.name),
subtitle: Text('\$${crypto.price} (${crypto.change24h}%)'),
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(watchlistProvider.notifier).addCrypto(Crypto(id: 'btc', name: 'Bitcoin', price: 60000, change24h: 2.5));
},
child: Icon(Icons.add),
),
);
}
}Принципы SOLID:
- Единая ответственность: Виджеты обрабатывают интерфейс, WatchlistNotifier управляет состоянием.
- Открытость/закрытость: Провайдеры модульны и расширяемы.
Часть 2: Нефункциональные требования как лучшие практики
1. Сохранение или сброс данных на основе контекста страницы
Практика:
Определите, должно ли состояние страницы (например, данные списка наблюдения) сохраняться или сбрасываться при переходе пользователя. Например:
- Сохраняйте данные списка наблюдения для быстрого доступа.
- Сбрасывайте результаты поиска, чтобы избежать устаревших данных.
Реализация:
Используйте модификатор autoDispose от Riverpod для провайдеров, которые должны сбрасываться при удалении виджета:
final searchProvider = StateProvider.autoDispose<String>((ref) => '');
Для постоянного состояния используйте провайдеры без autoDispose или локальное хранилище:
final watchlistProvider = StateNotifierProvider<WatchlistNotifier, AsyncValue<List<Crypto>>>((ref) => ...);
Преимущество:
Согласование жизненного цикла состояния с требованиями функционала, улучшение пользовательского опыта и использования памяти.
2. Хранение данных в течение 10 минут
Практика:
Кэшируйте часто запрашиваемые данные на 10 минут, сбрасывая их после этого, чтобы обеспечить актуальность.
Реализация:
Используйте таймер в репозитории для инвалидации кэша:
class CryptoRepositoryImpl implements CryptoRepository {
DateTime? _lastFetched;
List<Crypto>? _cachedData;
@override
Future<List<Crypto>> getWatchlist() async {
if (_lastFetched != null &&
DateTime.now().difference(_lastFetched!).inMinutes < 10 &&
_cachedData != null) {
return _cachedData!;
}
final data = await remoteDataSource.getCryptoPrices();
_cachedData = data;
_lastFetched = DateTime.now();
return data;
}
}Преимущество:
Снижает количество вызовов API, обеспечивая при этом актуальность данных.
3. Нет вызовов API из UI
Практика:
Виджеты пользовательского интерфейса никогда не должны напрямую вызывать API. К API обращаются через слой данных через use cases.
Реализация:
Виджеты взаимодействуют с StateNotifier или провайдерами, которые запускают use cases:
ref.read(watchlistProvider.notifier).loadWatchlist();
Use cases вызывают методы репозитория, сохраняя при этом UI отвязанным.
Преимущество:
Обеспечивает соблюдение принципов Clean Architecture, улучшая тестируемость и поддерживаемость.
4. UI отслеживает изменения данных и перерисовывается
Практика:
Пользовательский интерфейс должен реагировать и обновляться, когда изменяются исходные данные.
Реализация:
Используйте ConsumerWidget из Riverpod или ref.watch для наблюдения за провайдерами:
final watchlist = ref.watch(watchlistProvider);
Riverpod обеспечивает перерисовку виджетов только при изменении наблюдаемого состояния.
Преимущество:
Упрощает управление состоянием и обеспечивает отзывчивый интерфейс.
5. Единая загрузка данных и обработка ошибок
Практика:
Стандартизируйте обработку состояний загрузки и ошибок для всех API-запросов.
Реализация:
Используйте AsyncValue в Riverpod для управления состояниями загрузки, данных и ошибок:
class WatchlistNotifier extends StateNotifier<AsyncValue<List<Crypto>>> {
WatchlistNotifier() : super(const AsyncValue.loading());
Future<void> loadWatchlist() async {
state = const AsyncValue.loading();
try {
final data = await repository.getWatchlist();
state = AsyncValue.data(data);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
}Создайте переиспользуемый виджет для состояний загрузки/ошибок:
class AsyncHandler<T> extends StatelessWidget {
final AsyncValue<T> value;
final Widget Function(T) builder;
AsyncHandler({required this.value, required this.builder});
@override
Widget build(BuildContext context) {
return value.when(
data: builder,
loading: () => Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Ошибка: $e')),
);
}
}Преимущество:
Снижает дублирование кода и обеспечивает согласованный пользовательский опыт.
6. Отмена запросов данных при выходе с страницы
Практика:
Отменяйте активные API-запросы, если пользователь переходит на другую страницу до завершения загрузки данных.
Реализация:
Используйте CancelToken из пакета dio или пользовательский механизм отмены:
class CryptoRemoteDataSource {
final Dio _dio = Dio();
Future<List<CryptoModel>> getCryptoPrices({CancelToken? cancelToken}) async {
final response = await _dio.get('https://api.example.com/coins', cancelToken: cancelToken);
return (response.data as List).map((e) => CryptoModel.fromJson(e)).toList();
}
}В провайдере отмените токен при удалении виджета:
final watchlistProvider = StateNotifierProvider<WatchlistNotifier, AsyncValue<List<Crypto>>>((ref) {
final cancelToken = CancelToken();
ref.onDispose(() => cancelToken.cancel());
return WatchlistNotifier(cancelToken: cancelToken);
});Преимущество:
Предотвращает ненужное использование сети и устаревшие обновления данных.
7. Показывайте ранее полученные данные офлайн
Практика:
Отображайте кэшированные данные при отсутствии сети.
Реализация:
Проверьте подключение и перейдите к локальному хранилищу:
class CryptoRepositoryImpl implements CryptoRepository {
@override
Future<List<Crypto>> getWatchlist() async {
try {
final data = await remoteDataSource.getCryptoPrices();
await localDataSource.saveWatchlist(data);
return data;
} catch (e) {
final cached = await localDataSource.getWatchlist();
if (cached.isNotEmpty) return cached;
rethrow;
}
}
}Преимущество:
Улучшает офлайн-UX и устойчивость.
8. Автоматический повтор при восстановлении сети
Практика:
Автоматически повторяйте неудачные вызовы API при восстановлении сети.
Реализация:
Используйте слушатель подключения (например, connectivity_plus) в провайдере:
class WatchlistNotifier extends StateNotifier<AsyncValue<List<Crypto>>> {
WatchlistNotifier(this.repository) : super(const AsyncValue.loading()) {
Connectivity().onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
loadWatchlist();
}
});
}
}Преимущество:
Улучшает надежность без вмешательства пользователя.
9. Минимизация перестроения интерфейса для списков данных
Практика:
Оптимизируйте обновления списков, чтобы избежать ненужных перестроений при частых изменениях данных.
Реализация:
Используйте ListView.builder с ключами для минимизации перестроения виджетов:
ListView.builder(
itemCount: cryptos.length,
itemBuilder: (context, index) {
final crypto = cryptos[index];
return CryptoTile(key: ValueKey(crypto.id), crypto: crypto);
},
);
Используйте неизменяемые структуры данных или Riverpod's keepAlive для предотвращения ненужных обновлений.
Преимущество:
Улучшает производительность для динамических списков.
10. Гибкое объявление состояния
Практика:
Разрешайте объявление и доступ к состоянию в любом месте приложения.
Реализация:
Riverpod's provider scope позволяет глобальное или специфичное для функций состояние:
final globalCounterProvider = StateProvider<int>((ref) => 0);
Используйте ProviderScope для переопределения провайдеров для конкретных функций:
runApp(ProviderScope(
overrides: [
cryptoRepositoryProvider.overrideWithValue(MockCryptoRepository()),
],
child: MyApp(),
));
Преимущество:
Упрощает управление состоянием и поддерживает модульную архитектуру.
Заключение
Эта статья предоставляет всестороннее руководство по созданию приложения Flutter с использованием Clean Architecture, принципов SOLID и Riverpod, с функцией Crypto Watch-list в качестве практического примера. Соблюдая предложенную структуру и лучшие практики, разработчики могут создавать масштабируемое, поддерживаемое и производительное приложение. Невозможные требования обеспечивают надежный пользовательский опыт, учитывая такие аспекты, как поддержка оффлайн-режима, оптимизация производительности и гибкость управления состоянием.
Эта архитектура разработана для эволюции вместе с приложением, поддерживая новые функции при сохранении качества и согласованности кода. Разработчикам следует регулярно пересматривать и уточнять эти практики в соответствии с потребностями проекта и новыми достижениями в экосистеме Flutter.