Архитектура приложений Flutter и лучшие практики

Архитектура приложений 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 делит приложение на три основных слоя:

  1. Слой представления: Содержит интерфейс пользователя (виджеты Flutter) и логику управления состоянием (провайдеры Riverpod). Он взаимодействует со слоем домена через кейсы использования.
  2. Слой домена: Основная бизнес-логика, включая сущности и кейсы использования. Он независим от фреймворков и внешних систем.
  3. Слой данных: Обрабатывает операции с данными (вызовы 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.

Report Page