Рубрики
Flutter

Выбор хостинга для Flutter-проектов

Разработка Flutter на хостинге Beget позволяет создавать приложения для мобильных платформ, веба и настольных устройств, используя один код. Однако выбор подходящего хостинга для Flutter-проектов может быть вызовом, особенно если проект ориентирован на веб или имеет backend-составляющую. В этой статье рассмотрим ключевые моменты, которые помогут определиться с хостингом.

1. Типы Flutter-проектов и их требования

Flutter-проекты можно условно разделить на два типа:

  • Веб-приложения: Требуют хостинга для статических файлов (HTML, CSS, JS).
  • Мобильные и десктопные приложения с backend-частью: Нуждаются в серверной инфраструктуре для API, баз данных и других служб.

В зависимости от типа вашего проекта, выбор хостинга будет различаться.

2. Хостинг для Flutter Web

Flutter Web генерирует статические файлы, которые легко размещать на любом хостинге для статического контента. Вот популярные решения:

  • Хостинг Beget: Российский хостинг-провайдер с поддержкой размещения статических сайтов, который предлагает доступные тарифы, надёжность и удобный интерфейс. Beget отлично подходит для Flutter Web, так как предоставляет бесплатные SSL-сертификаты и высокую скорость загрузки страниц. Узнать больше: https://beget.com/.
  • GitHub Pages: Бесплатное решение для небольших проектов. Простая настройка и поддержка доменных имен.
  • Netlify: Интуитивно понятная платформа с автоматическими деплоями и поддержкой CI/CD.
  • Vercel: Подходит для проектов с требованием высокой производительности и интеграции с системами контроля версий.
  • Firebase Hosting: Идеальный выбор для тех, кто уже использует Firebase. Поддерживает HTTPS, кастомные домены и интеграцию с другими продуктами Google.

3. Хостинг для backend Flutter-приложений

Если вашему приложению требуется серверная часть, необходимо выбрать хостинг, поддерживающий backend-технологии:

  • Firebase: Предоставляет BaaS (Backend-as-a-Service), включая базу данных, авторизацию и хостинг. Идеально для проектов, где нет необходимости в сложной серверной логике.
  • Heroku: Подходит для небольших и средних проектов. Поддерживает множество языков и фреймворков, включая Node.js, Python и Ruby.
  • AWS: Обеспечивает масштабируемость и гибкость, но требует знаний DevOps.
  • DigitalOcean: Простая настройка виртуальных серверов для любых задач.
  • Render: Удобный выбор для размещения как веб-приложений, так и серверной части.

Не стоит забывать о российском хостинге Beget, который также может использоваться для размещения серверных приложений с помощью виртуальных серверов. Это позволяет настроить полностью кастомизированное окружение для вашего backend.

4. Критерии выбора хостинга

При выборе хостинга обратите внимание на:

  • Производительность: Скорость загрузки и стабильность работы.
  • Масштабируемость: Возможность увеличения ресурсов при росте нагрузки.
  • Поддержку технологий: Совместимость с языком и фреймворком, используемым в backend-части.
  • Стоимость: Сравните тарифы и учтите возможные скрытые платежи.
  • Простоту настройки: Некоторые платформы предоставляют готовые инструменты для развёртывания.
  • Документацию и сообщество: Наличие подробных инструкций и активной поддержки может сильно упростить процесс.

Beget в данном контексте выделяется своим сочетанием доступных цен, простоты настройки и качественной технической поддержки, что делает его отличным выбором для разработчиков Flutter-проектов.

5. Лучшие практики для Flutter-проектов

  • Используйте CI/CD для автоматизации деплоя.
  • Регулярно обновляйте зависимости проекта.
  • Настраивайте мониторинг и логи для выявления проблем.
  • Оптимизируйте размер скомпилированных файлов.
  • Тестируйте проект в различных браузерах и устройствах для Flutter Web.

Заключение

Правильный выбор хостинга для Flutter-проекта зависит от его типа, функциональности и бюджета. Для статических веб-приложений подходят платформы вроде Netlify или Firebase Hosting. Для проектов с серверной частью лучше обратить внимание на Firebase, Heroku или AWS.

Если вы ищете надёжный, доступный и простой в использовании хостинг с поддержкой всех необходимых технологий, обязательно обратите внимание на VPS хостинг провайдера Beget. Этот провайдер предлагает всё необходимое для успешного размещения вашего Flutter-проекта. Узнайте больше и начните с Beget уже сегодня: https://beget.com/.

Рубрики
Flutter

Оптимизация производительности Flutter приложений 2025

Производительность критически важна для пользовательского опыта. Медленное приложение с лагами frustrates пользователей и приводит к потере аудитории. В этом руководстве мы разберём методы оптимизации Flutter приложений.

Performance Tools

Flutter предоставляет множество инструментов для анализа производительности.

Performance Overlay

MaterialApp(
  showPerformanceOverlay: true,
  home: MyApp(),
)

Performance Overlay показывает: — GPU — время рендеринга графики — UI — время выполнения Dart кода

Зелёный — хорошо (<= 16ms на 60fps), красный — плохо.

Dart DevTools

DevTools — мощный инструмент для profiling.

# Запуск DevTools
flutter pub global run devtools

# Или при запуске приложения
flutter run --profile

DevTools предоставляет: — Flutter Inspector — исследование виджетов — Performance — анализ кадров — Memory — профилирование памяти — Network — анализ запросов

Оптимизация рендеринга

Const конструкторы

Const конструкторы позволяют Flutter переиспользовать виджеты вместо пересоздания.

// Плохо
class BadExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Title'),
        Text('Subtitle'),
        Icon(Icons.star),
      ],
    );
  }
}

// Хорошо
class GoodExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: const [
        Text('Title'),
        Text('Subtitle'),
        Icon(Icons.star),
      ],
    );
  }
}

Ключевое правило: используйте const везде, где это возможно.

RepaintBoundary

RepaintBoundary изолирует expensive операции рендеринга.

class ExpensiveWidget extends StatelessWidget {
  const ExpensiveWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: CustomPaint(
        painter: ExpensivePainter(),
        size: Size(200, 200),
      ),
    );
  }
}

Когда часть виджета пересобирается, только изменённая часть перерисовывается.

ListView.builder вместо ListView

// Плохо — все элементы создаются сразу
class BadListView extends StatelessWidget {
  final items = List.generate(10000, (i) => 'Item $i');

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        for (var item in items)
          ListTile(title: Text(item)),
      ],
    );
  }
}

// Хорошо — только видимые элементы создаются
class GoodListView extends StatelessWidget {
  final items = List.generate(10000, (i) => 'Item $i');

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return ListTile(title: Text(items[index]));
      },
    );
  }
}

addAutomaticKeepAlives

Для больших списков отключите автоматическое сохранение состояния:

ListView.builder(
  addAutomaticKeepAlives: false,
  addRepaintBoundaries: false,
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemWidget(items[index]);
  },
)

Keys для оптимизации

Keys помогают Flutter identify виджеты и оптимизировать обновления.

class KeyExample extends StatefulWidget {
  const KeyExample({super.key});

  @override
  State<KeyExample> createState() => _KeyExampleState();
}

class _KeyExampleState extends State<KeyExample> {
  final List<Item> items = [];

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        // Используйте ValueKey для уникальной идентификации
        return ListTile(
          key: ValueKey(items[index].id),
          title: Text(items[index].title),
        );
      },
    );
  }
}

itemBuilder для повторяющихся виджетов

// Плохо
ListView.builder(
  itemBuilder: (context, index) {
    return Container(
      child: Row(
        children: [
          Icon(Icons.star),
          Text('Item $index'),
        ],
      ),
    );
  },
)

// Хорошо
const itemIcon = Icon(Icons.star);

ListView.builder(
  itemBuilder: (context, index) {
    return Container(
      child: Row(
        children: const [
          itemIcon,
          // Text не может быть const
        ],
      ),
    );
  },
)

Оптимизация состояния

StatefulWidget правильно

// Плохо — весь UI пересобирается при изменении counter
class BadExample extends StatefulWidget {
  @override
  State<BadExample> createState() => _BadExampleState();
}

class _BadExampleState extends State<BadExample> {
  int counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Counter: $counter'),
        Expanded(
          // ListView будет пересобираться при каждом изменении counter!
          child: ListView.builder(
            itemCount: 1000,
            itemBuilder: (context, index) {
              return ListTile(title: Text('Item $index'));
            },
          ),
        ),
        ElevatedButton(
          onPressed: () => setState(() => counter++),
          child: Text('Increment'),
        ),
      ],
    );
  }
}

// Хорошо — только counter пересобирается
class GoodExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: const [
        CounterWidget(),
        Expanded(
          child: StaticListView(),
        ),
      ],
    );
  }
}

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int counter = 0;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Text('Counter: $counter'),
        ElevatedButton(
          onPressed: () => setState(() => counter++),
          child: const Icon(Icons.add),
        ),
      ],
    );
  }
}

class StaticListView extends StatelessWidget {
  const StaticListView({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 1000,
      itemBuilder: (context, index) {
        return ListTile(title: Text('Item $index'));
      },
    );
  }
}

Избегайте setState в build

// Плохо — setState вызывается внутри build
class BadExample extends StatefulWidget {
  @override
  State<BadExample> createState() => _BadExampleState();
}

class _BadExampleState extends State<BadExample> {
  int counter = 0;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        setState(() {
          counter++; // Хорошо
        });

        // Плохо — setState внутри build
        if (counter > 10) {
          setState(() {
            counter = 0;
          });
        }
      },
      child: Text('Click'),
    );
  }
}

ValueNotifier для локального состояния

Для простого состояния используйте ValueNotifier:

class CounterWidget extends StatelessWidget {
  final ValueNotifier<int> counter = ValueNotifier<int>(0);

  CounterWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ValueListenableBuilder(
          valueListenable: counter,
          builder: (context, value, child) {
            return Text('Counter: $value');
          },
        ),
        ElevatedButton(
          onPressed: () => counter.value++,
          child: const Icon(Icons.add),
        ),
      ],
    );
  }
}

Оптимизация изображений

Кэширование изображений

dependencies:
  cached_network_image: ^3.3.0
CachedNetworkImage(
  imageUrl: 'https://example.com/image.jpg',
  placeholder: (context, url) => CircularProgressIndicator(),
  errorWidget: (context, url, error) => Icon(Icons.error),
  maxWidthDiskCache: 600,
  maxHeightDiskCache: 600,
)

Оптимизация размера

Image.network(
  'https://example.com/image.jpg',
  width: 100,
  height: 100,
  fit: BoxFit.cover,
  // Укажите cacheWidth/cacheHeight для уменьшения размера в памяти
  cacheWidth: 200,
  cacheHeight: 200,
)

Lazy loading

Загружайте изображения по мере необходимости:

class ImageLazyLoader extends StatelessWidget {
  const ImageLazyLoader({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        return CachedNetworkImage(
          imageUrl: 'https://example.com/image$index.jpg',
          width: 100,
          height: 100,
          fit: BoxFit.cover,
          memCacheWidth: 200,
          memCacheHeight: 200,
        );
      },
    );
  }
}

Оптимизация памяти

Dispose ресурсов

class ProperDispose extends StatefulWidget {
  const ProperDispose({super.key});

  @override
  State<ProperDispose> createState() => _ProperDisposeState();
}

class _ProperDisposeState extends State<ProperDispose> {
  late AnimationController _controller;
  late TextEditingController _textController;
  late ScrollController _scrollController;
  StreamSubscription? _subscription;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    );

    _textController = TextEditingController();
    _scrollController = ScrollController();

    _subscription = someStream.listen((data) {
      // Обработка данных
    });
  }

  @override
  void dispose() {
    // ВСЕГДА вызывайте dispose для контроллеров и подписок
    _controller.dispose();
    _textController.dispose();
    _scrollController.dispose();
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

Lazy loading для больших списков

class LazyList extends StatefulWidget {
  const LazyList({super.key});

  @override
  State<LazyList> createState() => _LazyListState();
}

class _LazyListState extends State<LazyList> {
  final List<Item> _items = [];
  final ScrollController _controller = ScrollController();
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _loadMore();
    _controller.addListener(_scrollListener);
  }

  void _scrollListener() {
    if (_controller.position.pixels >=
        _controller.position.maxScrollExtent - 200) {
      _loadMore();
    }
  }

  Future<void> _loadMore() async {
    if (_isLoading) return;

    setState(() => _isLoading = true);

    final newItems = await api.fetch(offset: _items.length);

    setState(() {
      _items.addAll(newItems);
      _isLoading = false;
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _controller,
      itemCount: _items.length + (_isLoading ? 1 : 0),
      itemBuilder: (context, index) {
        if (index == _items.length) {
          return const Center(child: CircularProgressIndicator());
        }
        return ItemWidget(_items[index]);
      },
    );
  }
}

Оптимизация памяти для изображений

// Загрузка изображения с оптимизацией памяти
Future<ui.Image> loadOptimizedImage(String url) async {
  final completer = Completer<ui.Image>();

  final imageStream = Image.network(
    url,
    // Ограничиваем размер загружаемого изображения
    cacheWidth: 1024,
    cacheHeight: 1024,
  ).image.resolve(const ImageConfiguration());

  imageStream.addListener(
    ImageStreamListener((ImageInfo info, _) {
      completer.complete(info.image);
    }),
  );

  return completer.future;
}

Оптимизация сети

Batch запросы

// Плохо — множественные запросы
Future<void> badExample(List<String> ids) async {
  for (final id in ids) {
    await api.getItem(id);
  }
}

// Хорошо — один batch запрос
Future<void> goodExample(List<String> ids) async {
  await api.getItems(ids);
}

Кэширование

class CachedApiService {
  final Map<String, CachedData> _cache = {};
  final ApiService _api;

  CachedApiService(this._api);

  Future<T> get<T>(
    String key,
    Future<T> Function() fetch, {
    Duration duration = const Duration(minutes: 5),
  }) async {
    // Проверка кэша
    if (_cache.containsKey(key)) {
      final cached = _cache[key]!;
      if (!cached.isExpired) {
        return cached.data as T;
      }
      _cache.remove(key);
    }

    // Загрузка данных
    final data = await fetch();

    // Сохранение в кэш
    _cache[key] = CachedData(
      data: data,
      expiry: DateTime.now().add(duration),
    );

    return data;
  }

  void invalidate(String key) {
    _cache.remove(key);
  }

  void clear() {
    _cache.clear();
  }
}

class CachedData<T> {
  final T data;
  final DateTime expiry;

  CachedData({required this.data, required this.expiry});

  bool get isExpired => DateTime.now().isAfter(expiry);
}

Сжатие данных

final response = await http.get(
  Uri.parse('https://api.example.com/data'),
  headers: {
    'Accept-Encoding': 'gzip, deflate',
  },
);

Отмена запросов

class CancelableRequest extends StatefulWidget {
  const CancelableRequest({super.key});

  @override
  State<CancelableRequest> createState() => _CancelableRequestState();
}

class _CancelableRequestState extends State<CancelableRequest> {
  late Future<void> _requestFuture;

  @override
  void initState() {
    super.initState();
    _requestFuture = _fetchData();
  }

  Future<void> _fetchData() async {
    try {
      final data = await api.fetchData();
      setState(() {
        // Обновление UI
      });
    } catch (e) {
      if (!mounted) return; // Проверяем, если widget dispose
      // Обработка ошибки
    }
  }

  @override
  void dispose() {
    // Отмена запроса при dispose
    // api.cancelRequest();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _requestFuture,
      builder: (context, snapshot) {
        // UI
      },
    );
  }
}

Isolates для тяжёлых задач

Compute функция

// Изолированная функция (top-level)
int heavyComputation(int n) {
  int result = 0;
  for (int i = 0; i < n; i++) {
    result += i * i;
  }
  return result;
}

// Использование в UI
class ComputeWidget extends StatefulWidget {
  const ComputeWidget({super.key});

  @override
  State<ComputeWidget> createState() => _ComputeWidgetState();
}

class _ComputeWidgetState extends State<ComputeWidget> {
  int? result;

  Future<void> _runComputation() async {
    final value = await compute(heavyComputation, 1000000);
    setState(() => result = value);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Result: ${result ?? "Not computed"}'),
        ElevatedButton(
          onPressed: _runComputation,
          child: const Text('Compute'),
        ),
      ],
    );
  }
}

Isolate для JSON parsing

String parseJsonInBackground(String jsonString) {
  final decoded = json.decode(jsonString);
  return decoded.toString();
}

// Использование
final parsed = await compute(parseJsonInBackground, jsonString);

Performance Best Practices

1. Используйте const конструкторы

const SizedBox(height: 16)
const Text('Hello')
const Icon(Icons.star)

2. Минимизируйте rebuild

class OptimizedWidget extends StatelessWidget {
  const OptimizedWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: const [
        StaticPart(), // const — не пересобирается
        DynamicPart(), // Может меняться
      ],
    );
  }
}

3. Кэшируйте данные

// Используйте cached_network_image для изображений
// Кэшируйте API ответы
// Сохраняйте данные локально

4. Ленивая загрузка

// ListView.builder вместо ListView
// FutureBuilder для асинхронных данных
// Lazy loading для больших списков

5. Оптимизируйте build метод

class OptimizedBuild extends StatelessWidget {
  const OptimizedBuild({required this.items, super.key});

  final List<Item> items;

  @override
  Widget build(BuildContext context) {
    // Выносим тяжёлые вычисления из build
    final filteredItems = _filterItems(items);

    return ListView.builder(
      itemCount: filteredItems.length,
      itemBuilder: (context, index) {
        return ItemWidget(filteredItems[index]);
      },
    );
  }

  List<Item> _filterItems(List<Item> items) {
    return items.where((item) => item.isActive).toList();
  }
}

Заключение

Оптимизация производительности Flutter приложений — это постоянный процесс. Используйте DevTools для профилирования и исправляйте узкие места. Помните о главных принципах:

  • Const где возможно
  • ListView.builder для больших списков
  • RepaintBoundary для expensive виджетов
  • Кэширование для данных и изображений
  • Isolates для тяжёлых вычислений
  • Правильная очистка ресурсов в dispose
Рубрики
Flutter

Архитектура Flutter приложений: Clean Architecture 2025

Хорошая архитектура — фундамент успешного проекта. Правильно спроектированная архитектура делает код поддерживаемым, тестируемым и масштабируемым. В этом руководстве мы разберём Clean Architecture для Flutter приложений.

Что такое Clean Architecture?

Clean Architecture — это подход к проектированию программных систем, предложенный Robert C. Martin (Uncle Bob). Основная идея — разделение системы на слои с зависимостями, направленными внутрь.

Ключевые принципы

  1. Independence of Frameworks — бизнес-логика не зависит от фреймворка
  2. Testability — бизнес-правила можно тестировать без UI, базы данных, веб-сервера
  3. Independence of UI — UI можно легко менять без изменения бизнес-логики
  4. Independence of Database — бизнес-правила не привязаны к конкретной базе данных
  5. Independence of External Services — бизнес-правила не знают о внешнем мире

Правило зависимостей

Внешние слои ← Внутренние слои

Источник зависимости всегда указывает внутрь.
Внутренние слои ничего не знают о внешних.

Слои архитектуры

Clean Architecture разделяет приложение на concentric слои:

┌─────────────────────────────────┐
│   Presentation (UI)             │
│   - Widgets                     │
│   - Controllers/BLoC            │
│   - State Management            │
├─────────────────────────────────┤
│   Domain (Business Logic)       │
│   - Entities                    │
│   - Use Cases                   │
│   - Repository Interfaces       │
├─────────────────────────────────┤
│   Data (Data Sources)           │
│   - Repository Implementation   │
│   - DTOs                        │
│   - Data Sources                │
└─────────────────────────────────┘

Domain Layer (внутренний слой)

Самый внутренний слой, содержащий бизнес-логику.

Компоненты:Entities — core business объекты — Use Cases — application-specific бизнес-правила — Repository Interfaces — абстракции для данных

Правило: Не имеет зависимостей от других слоев. Не знает о Flutter, UI, базах данных.

Data Layer (средний слой)

Реализует доступ к данным.

Компоненты:Repository Implementation — реализация интерфейсов из Domain — DTOs — Data Transfer Objects для парсинга JSON/API — Data Sources — API, Database, Cache

Правило: Знает о Domain, но Presentation не знает о нём.

Presentation Layer (внешний слой)

UI и state management.

Компоненты:Widgets — Flutter UI компоненты — Controllers — BLoC, Cubit, Provider — State — UI состояние

Правило: Знает о Domain, использует Use Cases.

Структура проекта

Рекомендуемая структура проекта для Clean Architecture:

lib/
├── core/
│   ├── error/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/
│   │   └── network_info.dart
│   ├── usecases/
│   │   └── usecase.dart
│   └── constants.dart
├── features/
│   └── auth/
│       ├── data/
│       │   ├── models/
│       │   │   └── user_model.dart
│       │   ├── repositories/
│       │   │   └── auth_repository_impl.dart
│       │   └── datasources/
│       │       ├── auth_remote_datasource.dart
│       │       └── auth_local_datasource.dart
│       ├── domain/
│       │   ├── entities/
│       │   │   └── user.dart
│       │   ├── repositories/
│       │   │   └── auth_repository.dart
│       │   └── usecases/
│       │       ├── login_usecase.dart
│       │       ├── register_usecase.dart
│       │       └── logout_usecase.dart
│       └── presentation/
│           ├── bloc/
│           │   ├── auth_bloc.dart
│           │   ├── auth_event.dart
│           │   └── auth_state.dart
│           ├── pages/
│           │   ├── login_page.dart
│           │   └── register_page.dart
│           └── widgets/
│               └── login_form.dart
├── config/
│   ├── routes.dart
│   └── theme.dart
└── main.dart

Domain Layer

Entities (Сущности)

Entities — это core business объекты приложения.

// features/auth/domain/entities/user.dart
class User {
  final String id;
  final String name;
  final String email;
  final DateTime createdAt;

  User({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });

  // Business logic
  bool get isActive => email.isNotEmpty;

  User copyWith({
    String? id,
    String? name,
    String? email,
    DateTime? createdAt,
  }) {
    return User(
      id: id ?? this.id,
      name: name ?? this.name,
      email: email ?? this.email,
      createdAt: createdAt ?? this.createdAt,
    );
  }
}

Repository Interfaces

Интерфейсы репозиториев определяют контракт для получения данных.

// features/auth/domain/repositories/auth_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/user.dart';

abstract class AuthRepository {
  Future<Either<Failure, User>> login(String email, String password);
  Future<Either<Failure, User>> register(String email, String password);
  Future<Either<Failure, User>> getCurrentUser();
  Future<Either<Failure, void>> logout();
  Stream<Either<Failure, User>> get authStateChanges;
}

Use Cases

Use Cases (Interactors) содержат application-specific бизнес-правила.

// core/usecases/usecase.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';

abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

class NoParams extends Equatable {
  @override
  List<Object?> get props => [];
}

// features/auth/domain/usecases/login_usecase.dart
class LoginUseCase implements UseCase<User, LoginParams> {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  @override
  Future<Either<Failure, User>> call(LoginParams params) async {
    // Валидация
    if (!params.email.contains('@')) {
      return Left(InvalidEmailFailure());
    }

    if (params.password.length < 6) {
      return Left(InvalidPasswordFailure());
    }

    // Выполнение
    return await repository.login(params.email, params.password);
  }
}

class LoginParams extends Equatable {
  final String email;
  final String password;

  const LoginParams({
    required this.email,
    required this.password,
  });

  @override
  List<Object?> get props => [email, password];
}

Failures

Определите common failure типы:

// core/error/failures.dart
import 'package:equatable/equatable.dart';

abstract class Failure extends Equatable {
  final String message;
  const Failure(this.message);

  @override
  List<Object?> get props => [message];
}

class ServerFailure extends Failure {
  const ServerFailure(String message) : super(message);
}

class NetworkFailure extends Failure {
  const NetworkFailure(String message) : super(message);
}

class CacheFailure extends Failure {
  const CacheFailure(String message) : super(message);
}

class InvalidEmailFailure extends Failure {
  const InvalidEmailFailure([String message = 'Invalid email format'])
      : super(message);
}

class InvalidPasswordFailure extends Failure {
  const InvalidPasswordFailure([String message = 'Password too short'])
      : super(message);
}

class UnauthorizedFailure extends Failure {
  const UnauthorizedFailure([String message = 'Unauthorized'])
      : super(message);
}

class NotFoundFailure extends Failure {
  const NotFoundFailure([String message = 'Resource not found'])
      : super(message);
}

Data Layer

DTOs (Data Transfer Objects)

DTOs используются для парсинга внешних данных (JSON, API).

// features/auth/data/models/user_model.dart
import '../../domain/entities/user.dart';

class UserModel extends User {
  const UserModel({
    required String id,
    required String name,
    required String email,
    required DateTime createdAt,
  }) : super(
          id: id,
          name: name,
          email: email,
          createdAt: createdAt,
        );

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'created_at': createdAt.toIso8601String(),
    };
  }

  // Конвертация в Entity
  User toEntity() {
    return User(
      id: id,
      name: name,
      email: email,
      createdAt: createdAt,
    );
  }
}

// Entity extension для создания UserModel
extension UserExtension on User {
  UserModel toModel() {
    return UserModel(
      id: id,
      name: name,
      email: email,
      createdAt: createdAt,
    );
  }
}

Data Sources

Data Sources обеспечивают доступ к источникам данных.

// features/auth/data/datasources/auth_remote_datasource.dart
import '../../../../core/error/exceptions.dart';
import '../models/user_model.dart';

abstract class AuthRemoteDataSource {
  Future<UserModel> login(String email, String password);
  Future<UserModel> register(String email, String password);
  Future<UserModel> getCurrentUser(String token);
  Future<void> logout();
}

class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  final http.Client client;

  AuthRemoteDataSourceImpl({required this.client});

  @override
  Future<UserModel> login(String email, String password) async {
    final response = await client.post(
      Uri.parse('https://api.example.com/auth/login'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email, 'password': password}),
    );

    if (response.statusCode == 200) {
      return UserModel.fromJson(json.decode(response.body));
    } else if (response.statusCode == 401) {
      throw UnauthorizedException();
    } else {
      throw ServerException();
    }
  }

  @override
  Future<UserModel> register(String email, String password) async {
    final response = await client.post(
      Uri.parse('https://api.example.com/auth/register'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email, 'password': password}),
    );

    if (response.statusCode == 201) {
      return UserModel.fromJson(json.decode(response.body));
    } else {
      throw ServerException();
    }
  }

  @override
  Future<UserModel> getCurrentUser(String token) async {
    final response = await client.get(
      Uri.parse('https://api.example.com/auth/me'),
      headers: {'Authorization': 'Bearer $token'},
    );

    if (response.statusCode == 200) {
      return UserModel.fromJson(json.decode(response.body));
    } else {
      throw ServerException();
    }
  }

  @override
  Future<void> logout() async {
    await client.post(Uri.parse('https://api.example.com/auth/logout'));
  }
}

Exceptions

Определите custom exceptions:

// core/error/exceptions.dart
class ServerException implements Exception {
  final String message;
  ServerException([this.message = 'Server error']);

  @override
  String toString() => message;
}

class NetworkException implements Exception {
  final String message;
  NetworkException([this.message = 'Network error']);

  @override
  String toString() => message;
}

class UnauthorizedException implements Exception {
  const UnauthorizedException();
}

class CacheException implements Exception {
  final String message;
  CacheException([this.message = 'Cache error']);

  @override
  String toString() => message;
}

Repository Implementation

// features/auth/data/repositories/auth_repository_impl.dart
import 'package:dartz/dartz.dart';

import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';
import '../models/user_model.dart';

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final NetworkInfo networkInfo;

  AuthRepositoryImpl({
    required this.remoteDataSource,
    required this.networkInfo,
  });

  @override
  Future<Either<Failure, User>> login(String email, String password) async {
    if (await networkInfo.isConnected) {
      try {
        final user = await remoteDataSource.login(email, password);
        return Right(user.toEntity());
      } on UnauthorizedException {
        return Left(UnauthorizedFailure());
      } on ServerException {
        return Left(ServerFailure('Server error'));
      }
    } else {
      return Left(NetworkFailure('No internet connection'));
    }
  }

  @override
  Future<Either<Failure, User>> register(String email, String password) async {
    if (await networkInfo.isConnected) {
      try {
        final user = await remoteDataSource.register(email, password);
        return Right(user.toEntity());
      } on ServerException {
        return Left(ServerFailure('Registration failed'));
      }
    } else {
      return Left(NetworkFailure('No internet connection'));
    }
  }

  @override
  Future<Either<Failure, User>> getCurrentUser() async {
    // Реализация с токеном из локального хранилища
    return Left(NotImplementedFailure());
  }

  @override
  Future<Either<Failure, void>> logout() async {
    try {
      await remoteDataSource.logout();
      return const Right(null);
    } on ServerException {
      return Left(ServerFailure('Logout failed'));
    }
  }

  @override
  Stream<Either<Failure, User>> get authStateChanges {
    // Реализация stream для auth state changes
    return const Stream.empty();
  }
}

Presentation Layer

BLoC (Business Logic Component)

// features/auth/presentation/bloc/auth_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/error/failures.dart';
import '../../../domain/entities/user.dart';
import '../../../domain/usecases/login_usecase.dart';
import '../../../domain/usecases/logout_usecase.dart';

// Events
abstract class AuthEvent extends Equatable {
  const AuthEvent();

  @override
  List<Object?> get props => [];
}

class LoginEvent extends AuthEvent {
  final String email;
  final String password;

  const LoginEvent({required this.email, required this.password});

  @override
  List<Object?> get props => [email, password];
}

class LogoutEvent extends AuthEvent {}

class CheckAuthEvent extends AuthEvent {}

// States
abstract class AuthState extends Equatable {
  const AuthState();

  @override
  List<Object?> get props => [];
}

class AuthInitial extends AuthState {}

class AuthLoading extends AuthState {}

class AuthAuthenticated extends AuthState {
  final User user;

  const AuthAuthenticated(this.user);

  @override
  List<Object?> get props => [user];
}

class AuthUnauthenticated extends AuthState {}

class AuthError extends AuthState {
  final String message;

  const AuthError(this.message);

  @override
  List<Object?> get props => [message];
}

// BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;
  final LogoutUseCase logoutUseCase;
  final GetCurrentUserUseCase getCurrentUserUseCase;

  AuthBloc({
    required this.loginUseCase,
    required this.logoutUseCase,
    required this.getCurrentUserUseCase,
  }) : super(AuthInitial()) {
    on<LoginEvent>(_onLogin);
    on<LogoutEvent>(_onLogout);
    on<CheckAuthEvent>(_onCheckAuth);
  }

  Future<void> _onLogin(LoginEvent event, Emitter<AuthState> emit) async {
    emit(AuthLoading());

    final result = await loginUseCase(
      LoginParams(email: event.email, password: event.password),
    );

    result.fold(
      (failure) => emit(AuthError(failure.message)),
      (user) => emit(AuthAuthenticated(user)),
    );
  }

  Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
    emit(AuthLoading());

    final result = await logoutUseCase(const NoParams());

    result.fold(
      (failure) => emit(AuthError(failure.message)),
      (_) => emit(AuthUnauthenticated()),
    );
  }

  Future<void> _onCheckAuth(
    CheckAuthEvent event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthLoading());

    final result = await getCurrentUserUseCase(const NoParams());

    result.fold(
      (failure) => emit(AuthUnauthenticated()),
      (user) => emit(AuthAuthenticated(user)),
    );
  }
}

Pages (Screens)

// features/auth/presentation/pages/login_page.dart
class LoginPage extends StatelessWidget {
  const LoginPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: BlocListener<AuthBloc, AuthState>(
        listener: (context, state) {
          if (state is AuthError) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text(state.message)),
            );
          }
        },
        child: BlocBuilder<AuthBloc, AuthState>(
          builder: (context, state) {
            if (state is AuthLoading) {
              return const Center(child: CircularProgressIndicator());
            }

            return const LoginForm();
          },
        ),
      ),
    );
  }
}

Widgets

// features/auth/presentation/widgets/login_form.dart
class LoginForm extends StatelessWidget {
  const LoginForm({super.key});

  @override
  Widget build(BuildContext context) {
    final formKey = GlobalKey<FormState>();
    final emailController = TextEditingController();
    final passwordController = TextEditingController();

    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Form(
        key: formKey,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextFormField(
              controller: emailController,
              decoration: const InputDecoration(
                labelText: 'Email',
                border: OutlineInputBorder(),
              ),
              validator: (value) {
                if (value == null || !value.contains('@')) {
                  return 'Invalid email';
                }
                return null;
              },
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: passwordController,
              decoration: const InputDecoration(
                labelText: 'Password',
                border: OutlineInputBorder(),
              ),
              obscureText: true,
              validator: (value) {
                if (value == null || value.length < 6) {
                  return 'Password too short';
                }
                return null;
              },
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                if (formKey.currentState!.validate()) {
                  context.read<AuthBloc>().add(
                        LoginEvent(
                          email: emailController.text,
                          password: passwordController.text,
                        ),
                      );
                }
              },
              child: const Text('Login'),
            ),
          ],
        ),
      ),
    );
  }
}

Dependency Injection

Используйте GetIt для DI:

// core/locator/locator.dart
import 'package:get/get.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;

import '../features/auth/data/datasources/auth_remote_datasource.dart';
import '../features/auth/data/repositories/auth_repository_impl.dart';
import '../features/auth/domain/repositories/auth_repository.dart';
import '../features/auth/domain/usecases/login_usecase.dart';
import '../features/auth/domain/usecases/logout_usecase.dart';
import '../features/auth/presentation/bloc/auth_bloc.dart';
import 'network/network_info.dart';

final sl = GetIt.instance;

Future<void> init() async {
  // External
  final sharedPreferences = await SharedPreferences.getInstance();
  sl.registerLazySingleton(() => sharedPreferences);
  sl.registerLazySingleton(() => http.Client());
  sl.registerLazySingleton(() => NetworkInfoImpl());

  // Data sources
  sl.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(client: sl()),
  );

  // Repositories
  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(
      remoteDataSource: sl(),
      networkInfo: sl(),
    ),
  );

  // Use cases
  sl.registerLazySingleton(() => LoginUseCase(sl()));
  sl.registerLazySingleton(() => LogoutUseCase(sl()));

  // BLoC
  sl.registerFactory(
    () => AuthBloc(
      loginUseCase: sl(),
      logoutUseCase: sl(),
      getCurrentUserUseCase: sl(),
    ),
  );
}

Заключение

Clean Architecture помогает создавать поддерживаемые и тестируемые приложения. Используйте её для средних и больших проектов. Для маленьких проектов можно упростить архитектуру, но сохраните разделение слоёв.

Ключевые преимущества

  • Тестируемость — каждый слой тестируется независимо
  • Масштабируемость — легко добавлять новые features
  • Поддерживаемость — изменения изолированы в слоях
  • Переиспользуемость — Use Cases переиспользуются в разных частях приложения
Рубрики
Flutter

Анимации во Flutter: Полное руководство 2025

Анимации — это ключевой элемент пользовательского опыта. Правильно реализованные анимации делают приложение живым, интуитивным и приятным в использовании. В этом руководстве мы разберём все типы анимаций во Flutter, от простых до продвинутых.

Виды анимаций во Flutter

Flutter поддерживает два основных подхода к анимациям:

  • Implicit animations — простые анимации, которые Flutter автоматически анимирует при изменении свойств виджета
  • Explicit animations — сложные анимации с полным контролем над процессом анимации

Когда использовать какой тип

Используйте implicit animations когда: — Нужно простое изменение свойства (цвет, размер, прозрачность) — Не нужен точный контроль над процессом анимации — Хотите минимизировать код

Используйте explicit animations когда: — Нужен точный контроль над анимацией — Требуется сложная последовательность анимаций — Нужно взаимодействовать с анимацией (пауза, реверс)

Implicit анимации

Implicit анимации — самый простой способ добавить анимацию во Flutter. Flutter автоматически анимирует переход между значениями свойства.

AnimatedContainer

AnimatedContainer анимирует изменения своих свойств: цвета, размера, позиции, выравнивания и других.

class AnimatedContainerExample extends StatefulWidget {
  const AnimatedContainerExample({super.key});

  @override
  State<AnimatedContainerExample> createState() =>
      _AnimatedContainerExampleState();
}

class _AnimatedContainerExampleState extends State<AnimatedContainerExample> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _expanded = !_expanded),
      child: AnimatedContainer(
        duration: Duration(milliseconds: 300),
        curve: Curves.easeInOut,
        width: _expanded ? 200 : 100,
        height: _expanded ? 200 : 100,
        decoration: BoxDecoration(
          color: _expanded ? Colors.blue : Colors.red,
          borderRadius: _expanded ? BorderRadius.circular(100) : BorderRadius.circular(8),
        ),
        child: Center(
          child: Text(
            _expanded ? 'Expanded' : 'Collapsed',
            style: TextStyle(color: Colors.white, fontSize: 16),
          ),
        ),
      ),
    );
  }
}

Ключевые моменты: — duration определяет длительность анимации — curve задаёт timing function (как быстро протекает анимация) — Flutter автоматически анимирует все изменённые свойства

AnimatedOpacity

Для анимации прозрачности используйте AnimatedOpacity:

class FadeExample extends StatefulWidget {
  const FadeExample({super.key});

  @override
  State<FadeExample> createState() => _FadeExampleState();
}

class _FadeExampleState extends State<FadeExample> {
  double _opacity = 1.0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedOpacity(
          opacity: _opacity,
          duration: Duration(milliseconds: 500),
          curve: Curves.easeInOut,
          child: FlutterLogo(size: 100),
        ),
        SizedBox(height: 20),
        Slider(
          value: _opacity,
          onChanged: (value) => setState(() => _opacity = value),
        ),
      ],
    );
  }
}

AnimatedPositioned

Для анимации позиции в Stack используется AnimatedPositioned:

class PositionedAnimationExample extends StatefulWidget {
  const PositionedAnimationExample({super.key});

  @override
  State<PositionedAnimationExample> createState() =>
      _PositionedAnimationExampleState();
}

class _PositionedAnimationExampleState extends State<PositionedAnimationExample> {
  bool _selected = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          AnimatedPositioned(
            duration: Duration(milliseconds: 300),
            curve: Curves.easeInOut,
            top: _selected ? 100 : 50,
            left: _selected ? 100 : 50,
            child: GestureDetector(
              onTap: () => setState(() => _selected = !_selected),
              child: AnimatedContainer(
                duration: Duration(milliseconds: 300),
                width: 100,
                height: 100,
                decoration: BoxDecoration(
                  color: _selected ? Colors.blue : Colors.red,
                  borderRadius: BorderRadius.circular(12),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black26,
                      blurRadius: 10,
                      spreadRadius: 2,
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

AnimatedCrossFade

Для плавного перехода между двумя виджетами:

class CrossFadeExample extends StatefulWidget {
  const CrossFadeExample({super.key});

  @override
  State<CrossFadeExample> createState() => _CrossFadeExampleState();
}

class _CrossFadeExampleState extends State<CrossFadeExample> {
  bool _showFirst = true;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedCrossFade(
          firstChild: FlutterLogo(size: 100),
          secondChild: Icon(Icons.star, size: 100, color: Colors.amber),
          crossFadeState: _showFirst
              ? CrossFadeState.showFirst
              : CrossFadeState.showSecond,
          duration: Duration(milliseconds: 500),
          firstCurve: Curves.easeIn,
          secondCurve: Curves.easeOut,
        ),
        SizedBox(height: 20),
        ElevatedButton(
          onPressed: () => setState(() => _showFirst = !_showFirst),
          child: Text('Toggle'),
        ),
      ],
    );
  }
}

Другие implicit анимации

Flutter предоставляет множество готовых анимированных виджетов:

  • AnimatedAlign — анимация выравнивания
  • AnimatedPadding — анимация отступов
  • AnimatedScale — анимация масштаба
  • AnimatedRotation — анимация вращения
  • AnimatedDefaultTextStyle — анимация стиля текста
  • AnimatedPhysicalModel — анимация карточки с elevation
  • AnimatedTheme — анимация темы

Explicit анимации

Explicit анимации дают полный контроль над процессом анимации. Они требуют больше кода, но предоставляют больше гибкости.

AnimationController

ОсExplicit анимаций — AnimationController. Он управляет жизненным циклом анимации.

import 'package:flutter/material.dart';

class ExplicitAnimationExample extends StatefulWidget {
  const ExplicitAnimationExample({super.key});

  @override
  State<ExplicitAnimationExample> createState() =>
      _ExplicitAnimationExampleState();
}

class _ExplicitAnimationExampleState extends State<ExplicitAnimationExample>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();

    // Создаём контроллер
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );

    // Создаём анимацию с кривой
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );

    // Запускаем анимацию
    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: _animation,
        builder: (context, child) {
          return Transform.scale(
            scale: _animation.value,
            child: FlutterLogo(size: 100),
          );
        },
      ),
    );
  }
}

Ключевые компоненты: — SingleTickerProviderStateMixin — предоставляет vsync для оптимизации — AnimationController — управляет анимацией — CurvedAnimation — задаёт timing curve — AnimatedBuilder — перестраивает UI при изменении анимации

Tween анимации

Tween (abbreviation for in-between) определяет диапазон значений анимации.

class TweenAnimationExample extends StatefulWidget {
  const TweenAnimationExample({super.key});

  @override
  State<TweenAnimationExample> createState() => _TweenAnimationExampleState();
}

class _TweenAnimationExampleState extends State<TweenAnimationExample>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Color?> _colorAnimation;
  late Animation<double> _sizeAnimation;
  late Animation<double> _rotationAnimation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    )..repeat(reverse: true);

    // Tween для цвета
    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.red,
    ).animate(_controller);

    // Tween для размера
    _sizeAnimation = Tween<double>(
      begin: 50.0,
      end: 150.0,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Curves.easeInOut,
      ),
    );

    // Tween для вращения
    _rotationAnimation = Tween<double>(
      begin: 0.0,
      end: 2 * pi,
    ).animate(_controller);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.rotate(
          angle: _rotationAnimation.value,
          child: Container(
            width: _sizeAnimation.value,
            height: _sizeAnimation.value,
            decoration: BoxDecoration(
              color: _colorAnimation.value,
              shape: BoxShape.circle,
            ),
          ),
        );
      },
    );
  }
}

Stacked анимации

Для одновременной анимации нескольких свойств используйте несколько Tween:

class StackedAnimationExample extends StatefulWidget {
  const StackedAnimationExample({super.key});

  @override
  State<StackedAnimationExample> createState() =>
      _StackedAnimationExampleState();
}

class _StackedAnimationExampleState extends State<StackedAnimationExample>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _opacity;
  late Animation<double> _scale;
  late Animation<double> _translation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(milliseconds: 1500),
      vsync: this,
    );

    _opacity = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.0, 0.5, curve: Curves.easeIn),
      ),
    );

    _scale = Tween<double>(begin: 0.5, end: 1.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.0, 0.5, curve: Curves.elasticOut),
      ),
    );

    _translation = Tween<double>(begin: 100.0, end: 0.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.3, 1.0, curve: Curves.easeOut),
      ),
    );

    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Opacity(
          opacity: _opacity.value,
          child: Transform.translate(
            offset: Offset(0, _translation.value),
            child: Transform.scale(
              scale: _scale.value,
              child: Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                  color: Colors.blue,
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
            ),
          ),
        );
      },
    );
  }
}

Hero анимации

Hero анимации создают плавный переход при навигации между экранами. Виджет с одинаковым tag плавно переходит из одной позиции в другую.

Базовая Hero анимация

class FirstScreen extends StatelessWidget {
  const FirstScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('First Screen')),
      body: GridView.count(
        crossAxisCount: 2,
        children: List.generate(10, (index) {
          return GestureDetector(
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => SecondScreen(imageIndex: index),
              ),
            ),
            child: Hero(
              tag: 'image-$index',
              child: Image.network(
                'https://picsum.photos/200?random=$index',
                fit: BoxFit.cover,
              ),
            ),
          );
        }),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  final int imageIndex;

  const SecondScreen({required this.imageIndex, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Second Screen')),
      body: Center(
        child: Hero(
          tag: 'image-$imageIndex',
          child: Image.network(
            'https://picsum.photos/400?random=$imageIndex',
            fit: BoxFit.contain,
          ),
        ),
      ),
    );
  }
}

Hero с placeholder

Для загрузки изображения во время анимации:

Hero(
  tag: 'image-$index',
  placeholderBuilder: (context, heroSize, child) {
    return Container(
      width: heroSize.width,
      height: heroSize.height,
      color: Colors.grey[300],
      child: Center(child: CircularProgressIndicator()),
    );
  },
  child: Image.network(imageUrl),
)

CustomPainter анимации

Для создания кастомных анимированных графики используйте CustomPainter.

Анимированный круг

import 'dart:ui' as ui;
import 'package:flutter/material.dart';

class AnimatedCirclePainter extends CustomPainter {
  final double progress;
  final Color color;

  AnimatedCirclePainter({
    required this.progress,
    required this.color,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 * 0.8;

    final paint = Paint()
      ..color = color
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    const startAngle = -pi / 2;
    final sweepAngle = 2 * pi * progress;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(AnimatedCirclePainter oldDelegate) {
    return oldDelegate.progress != progress || oldDelegate.color != color;
  }
}

// Использование
class CircularProgressWidget extends StatefulWidget {
  const CircularProgressWidget({super.key});

  @override
  State<CircularProgressWidget> createState() =>
      _CircularProgressWidgetState();
}

class _CircularProgressWidgetState extends State<CircularProgressWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return CustomPaint(
          painter: AnimatedCirclePainter(
            progress: _controller.value,
            color: Colors.blue,
          ),
          size: Size(200, 200),
        );
      },
    );
  }
}

Curves

Curves определяют, как быстро протекает анимация во времени.

Популярные кривые

// Линейная — постоянная скорость
Curves.linear

// Ускорение
Curves.easeIn
Curves.easeInCubic
Curves.easeInQuart
Curves.easeInQuad

// Замедление
Curves.easeOut
Curves.easeOutCubic
Curves.easeOutQuad

// Ускорение и замедление
Curves.easeInOut
Curves.easeInOutCubic
Curves.easeInOutQuad

// Bounce эффекты
Curves.bounceIn
Curves.bounceOut
Curves.bounceInOut

// Elastic эффекты
Curves.elasticIn
Curves.elasticOut
Curves.elasticInOut

// Специализированные
Curves.fastOutSlowIn // Material Design default
Curves.slowMiddle

Custom curve

class CustomCurve extends Curve {
  @override
  double transformInternal(double t) {
    // Ваша custom кривая
    return sin(t * pi);
  }
}

Lottie анимации

Lottie — это библиотека для анимаций, созданных в Adobe After Effects.

Установка

dependencies:
  lottie: ^3.0.0

Использование

import 'package:lottie/lottie.dart';

class LottieExample extends StatelessWidget {
  const LottieExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Lottie.asset('assets/animations/loader.json');
  }
}

Контролируемая Lottie анимация

class ControlledLottieExample extends StatefulWidget {
  const ControlledLottieExample({super.key});

  @override
  State<ControlledLottieExample> createState() =>
      _ControlledLottieExampleState();
}

class _ControlledLottieExampleState extends State<ControlledLottieExample> {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(vsync: this);

    // Загружаем анимацию и получаем длительность
    _loadAnimation();
  }

  void _loadAnimation() async {
    final composition = await Lottie.asset('assets/animations/loader.json');

    _controller.duration = composition.duration;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Lottie.asset(
          'assets/animations/loader.json',
          controller: _controller,
          onLoaded: (composition) {
            _controller.duration = composition.duration;
          },
          frameRate: FrameRate(60),
          repeat: true,
          animate: true,
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(
              icon: Icon(Icons.play_arrow),
              onPressed: () => _controller.forward(),
            ),
            IconButton(
              icon: Icon(Icons.pause),
              onPressed: () => _controller.stop(),
            ),
            IconButton(
              icon: Icon(Icons.stop),
              onPressed: () {
                _controller.stop();
                _controller.reset();
              },
            ),
          ],
        ),
      ],
    );
  }
}

Rive анимации

Rive (ранее Flare) — это мощный инструмент для создания real-time анимаций.

Установка

dependencies:
  flutter_rive: ^0.13.0

Использование

import 'package:flutter_rive/flutter_rive.dart';

class RiveExample extends StatelessWidget {
  const RiveExample({super.key});

  @override
  Widget build(BuildContext context) {
    return RiveAnimation.asset('assets/vehicles.riv');
  }
}

Контролируемая Rive анимация

class ControlledRiveExample extends StatefulWidget {
  const ControlledRiveExample({super.key});

  @override
  State<ControlledRiveExample> createState() => _ControlledRiveExampleState();
}

class _ControlledRiveExampleState extends State<ControlledRiveExample> {
  SMITrigger? _bump;

  void _onRiveInit(Artboard artboard) {
    final controller = StateMachineController.fromArtboard(
      artboard,
      'bumpy', // Имя state machine
    );

    artboard.addController(controller!);

    _bump = controller.findInput<bool>('bump') as SMITrigger;
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => _bump?.fire(),
      child: RiveAnimation.asset(
        'assets/vehicles.riv',
        onInit: _onRiveInit,
      ),
    );
  }
}

Best Practices

1. Используйте implicit анимации когда возможно

Implicit анимации проще в использовании и требуют меньше кода.

// Хорошо
AnimatedOpacity(opacity: _visible ? 1.0 : 0.0, child: widget)

// Плохо (для простых случаев)
OpacityAnimation(opacity: _visible ? 1.0 : 0.0, child: widget)

2. Оптимизируйте производительность

// Используйте const конструкторы где возможно
const SizedBox(height: 16)

// Изолируйте дорогие операции
RepaintBoundary(
  child: ExpensiveAnimationWidget(),
)

3. Удаляйте контроллеры

@override
void dispose() {
  _controller.dispose(); // Обязательно!
  super.dispose();
}

4. Используйте правильные curves

// Входящие элементы — easeOut
Curves.easeOut

// Исходящие элементы — easeIn
Curves.easeIn

// Естественное движение — easeInOut
Curves.easeInOut

Заключение

Анимации во Flutter — это мощный инструмент для создания потрясающего пользовательского интерфейса. Используйте implicit анимации для простых случаев, explicit для сложного контроля, Hero для навигации, и Lottie/Rive для профессиональных анимаций.

Рубрики
Flutter

Firebase во Flutter: Полное руководство 2025

Firebase — это облачная платформа от Google, которая предоставляет полный бэкенд для мобильных и веб-приложений. В этом руководстве мы разберём полную интеграцию Firebase во Flutter приложение, от настройки проекта до продвинутых функций.

Что такое Firebase?

Firebase — это Backend-as-a-Service (BaaS) платформа, которая предоставляет готовые решения для common задач мобильной разработки:

  • Authentication — готовая система аутентификации с множеством провайдеров
  • Cloud Firestore — NoSQL база данных real-time
  • Cloud Functions — serverless функции на Node.js
  • Cloud Storage — хранилище файлов (изображения, видео, документы)
  • Cloud Messaging — push-уведомления
  • Analytics — аналитика пользователей
  • Crashlytics — отчёты о крашах

Преимущества Firebase

  • Быстрый старт — не нужно настраивать сервер
  • Real-time — мгновенное обновление данных
  • Масштабируемость — автоматическое масштабирование
  • Бесплатный tier — generous free plan для небольших проектов
  • Интеграция с Google —无缝 интеграция с Google сервисами

Настройка проекта

1. Создание проекта в Firebase Console

  1. Перейдите на Firebase Console
  2. Нажмите «Add project»
  3. Введите название проекта и включите Google Analytics (опционально)

2. Добавление Flutter приложений

Для Android:

  1. В Firebase Console выберите «Add app» → Android
  2. Введите package name (например, com.example.myapp)
  3. Скачайте google-services.json
  4. Положите файл в android/app/

Для iOS:

  1. В Firebase Console выберите «Add app» → iOS
  2. Введите bundle ID (например, com.example.myapp)
  3. Скачайте GoogleService-Info.plist
  4. Положите файл в ios/Runner/

3. Зависимости

dependencies:
  firebase_core: ^3.0.0
  firebase_auth: ^5.0.0
  cloud_firestore: ^5.0.0
  firebase_storage: ^12.0.0
  firebase_messaging: ^15.0.0
  firebase_analytics: ^11.0.0
  google_sign_in: ^6.2.0

Выполните flutter pub get.

Инициализация Firebase

Перед использованием任何 Firebase сервиса нужно инициализировать Firebase в приложении:

import 'package:firebase_core/firebase_core.dart';

Future<void> main() async {
  // Обязательно для Firebase
  WidgetsFlutterBinding.ensureInitialized();

  // Инициализация Firebase
  await Firebase.initializeApp();

  runApp(MyApp());
}

Если вы используете несколько Firebase проектов или специфическую конфигурацию:

await Firebase.initializeApp(
  options: DefaultFirebaseOptions.currentPlatform,
);

Класс DefaultFirebaseOptions генерируется автоматически при добавлении Flutter приложений в Firebase Console через FlutterFire CLI.

Authentication

Firebase Authentication поддерживает множество провайдеров: email/password, Google, Facebook, Apple, GitHub, телефон и другие.

Email/Password аутентификация

Сначала включите этот провайдер в Firebase Console → Authentication → Sign-in method.

import 'package:firebase_auth/firebase_auth.dart';

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  // Текущий пользователь
  User? get currentUser => _auth.currentUser;

  // Stream изменений аутентификации
  Stream<User?> get authStateChanges => _auth.authStateChanges();

  // Регистрация
  Future<Result<UserCredential>> register(
    String email,
    String password,
  ) async {
    try {
      final credential = await _auth.createUserWithEmailAndPassword(
        email: email,
        password: password,
      );

      return Result.success(credential);
    } on FirebaseAuthException catch (e) {
      return Result.failure(e.message ?? 'Registration failed');
    } catch (e) {
      return Result.failure('An error occurred');
    }
  }

  // Вход
  Future<Result<UserCredential>> login(
    String email,
    String password,
  ) async {
    try {
      final credential = await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );

      return Result.success(credential);
    } on FirebaseAuthException catch (e) {
      switch (e.code) {
        case 'user-not-found':
          return Result.failure('User not found');
        case 'wrong-password':
          return Result.failure('Wrong password');
        default:
          return Result.failure(e.message ?? 'Login failed');
      }
    } catch (e) {
      return Result.failure('An error occurred');
    }
  }

  // Выход
  Future<void> logout() async {
    await _auth.signOut();
  }

  // Сброс пароля
  Future<Result<void>> resetPassword(String email) async {
    try {
      await _auth.sendPasswordResetEmail(email);
      return Result.success(null);
    } catch (e) {
      return Result.failure('Failed to send reset email');
    }
  }
}

// Helper класс для результата
class Result<T> {
  final bool isSuccess;
  final T? data;
  final String? error;

  Result.success(this.data)
      : isSuccess = true,
        error = null;

  Result.failure(this.error)
      : isSuccess = false,
        data = null;
}

Google Sign-In

Google Sign-In — один из самых удобных способов аутентификации.

import 'package:google_sign_in/google_sign_in.dart';

class GoogleAuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  final GoogleSignIn _googleSignIn = GoogleSignIn();

  Future<Result<UserCredential>> signInWithGoogle() async {
    try {
      // 1. Запускаем Google Sign-In
      final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();

      if (googleUser == null) {
        // Пользователь отменил вход
        return Result.failure('Sign-in cancelled');
      }

      // 2. Получаем authentication данные
      final GoogleSignInAuthentication googleAuth =
          await googleUser.authentication;

      // 3. Создаём credential для Firebase
      final credential = GoogleAuthProvider.credential(
        accessToken: googleAuth.accessToken,
        idToken: googleAuth.idToken,
      );

      // 4. Входим через Firebase
      final userCredential = await _auth.signInWithCredential(credential);

      return Result.success(userCredential);
    } catch (e) {
      return Result.failure('Google sign-in failed: $e');
    }
  }

  Future<void> signOut() async {
    await _googleSignIn.signOut();
    await _auth.signOut();
  }
}

Отслеживание состояния аутентификации

Для автоматического перенаправления пользователя на нужный экран используйте StreamBuilder:

class AuthWrapper extends StatelessWidget {
  const AuthWrapper({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.authStateChanges(),
      builder: (context, snapshot) {
        // Загрузка
        if (snapshot.connectionState == ConnectionState.waiting) {
          return Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }

        // Пользователь авторизован
        if (snapshot.hasData) {
          return HomeScreen();
        }

        // Пользователь не авторизован
        return LoginScreen();
      },
    );
  }
}

Cloud Firestore

Firestore — это NoSQL база данных real-time. Данные хранятся в виде коллекций документов.

Базовые операции CRUD

import 'package:cloud_firestore/cloud_firestore.dart';

class DatabaseService {
  final FirebaseFirestore _db = FirebaseFirestore.instance;

  // CREATE: Создать документ
  Future<Result<void>> createUser(User user) async {
    try {
      await _db.collection('users').doc(user.id).set(user.toMap());

      return Result.success(null);
    } catch (e) {
      return Result.failure('Failed to create user');
    }
  }

  // READ: Прочитать документ
  Future<Result<User>> getUser(String userId) async {
    try {
      final doc = await _db.collection('users').doc(userId).get();

      if (!doc.exists) {
        return Result.failure('User not found');
      }

      final user = User.fromMap(doc.data() as Map<String, dynamic>);

      return Result.success(user);
    } catch (e) {
      return Result.failure('Failed to get user');
    }
  }

  // UPDATE: Обновить документ
  Future<Result<void>> updateUser(User user) async {
    try {
      await _db.collection('users').doc(user.id).update(user.toMap());

      return Result.success(null);
    } catch (e) {
      return Result.failure('Failed to update user');
    }
  }

  // DELETE: Удалить документ
  Future<Result<void>> deleteUser(String userId) async {
    try {
      await _db.collection('users').doc(userId).delete();

      return Result.success(null);
    } catch (e) {
      return Result.failure('Failed to delete user');
    }
  }
}

Real-time обновления

Один из главных преимуществ Firestore — real-time обновления. Используйте snapshots() вместо get():

class UserList extends StatelessWidget {
  const UserList({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: FirebaseFirestore.instance
          .collection('users')
          .orderBy('createdAt', descending: true)
          .snapshots(),
      builder: (context, snapshot) {
        // Обработка ошибок
        if (snapshot.hasError) {
          return Center(child: Text('Error: ${snapshot.error}'));
        }

        // Загрузка
        if (snapshot.connectionState == ConnectionState.waiting) {
          return Center(child: CircularProgressIndicator());
        }

        // Нет данных
        if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
          return Center(child: Text('No users found'));
        }

        // Отображение списка
        final users = snapshot.data!.docs;

        return ListView.builder(
          itemCount: users.length,
          itemBuilder: (context, index) {
            final user = User.fromMap(
              users[index].data() as Map<String, dynamic>,
            );

            return ListTile(
              title: Text(user.name),
              subtitle: Text(user.email),
              trailing: Text(user.createdAt.toString()),
            );
          },
        );
      },
    );
  }
}

Запросы

Firestore поддерживает сложные запросы:

class QueryService {
  final FirebaseFirestore _db = FirebaseFirestore.instance;

  // Где (where)
  Future<List<User>> getUsersOlderThan(int age) async {
    final snapshot = await _db
        .collection('users')
        .where('age', isGreaterThan: age)
        .get();

    return snapshot.docs
        .map((doc) => User.fromMap(doc.data() as Map<String, dynamic>))
        .toList();
  }

  // Сортировка
  Future<List<User>> getUsersSortedByAge() async {
    final snapshot = await _db
        .collection('users')
        .orderBy('age', descending: true)
        .get();

    return snapshot.docs
        .map((doc) => User.fromMap(doc.data() as Map<String, dynamic>))
        .toList();
  }

  // Лимит
  Future<List<User>> getFirst10Users() async {
    final snapshot = await _db
        .collection('users')
        .limit(10)
        .get();

    return snapshot.docs
        .map((doc) => User.fromMap(doc.data() as Map<String, dynamic>))
        .toList();
  }

  // Сложные запросы
  Future<List<Product>> searchProducts({
    required String category,
    required double maxPrice,
  }) async {
    final snapshot = await _db
        .collection('products')
        .where('category', isEqualTo: category)
        .where('price', isLessThan: maxPrice)
        .orderBy('rating', descending: true)
        .limit(20)
        .get();

    return snapshot.docs
        .map((doc) => Product.fromMap(doc.data() as Map<String, dynamic>))
        .toList();
  }

  // Пагинация
  Future<List<User>> getUsersPaginated({
    required User? lastUser,
    int limit = 10,
  }) async {
    Query query = _db
        .collection('users')
        .orderBy('name')
        .limit(limit);

    if (lastUser != null) {
      query = query.startAfterDocument(lastUser);
    }

    final snapshot = await query.get();

    return snapshot.docs
        .map((doc) => User.fromMap(doc.data() as Map<String, dynamic>))
        .toList();
  }
}

Транзакции

Для атомарных операций используйте транзакции:

Future<Result<void>> transferMoney({
  required String fromUserId,
  required String toUserId,
  required double amount,
}) async {
  try {
    await FirebaseFirestore.instance.runTransaction((transaction) async {
      // 1. Читаем документы
      final fromUserRef =
          FirebaseFirestore.instance.collection('users').doc(fromUserId);
      final toUserRef =
          FirebaseFirestore.instance.collection('users').doc(toUserId);

      final fromUserDoc = await transaction.get(fromUserRef);
      final toUserDoc = await transaction.get(toUserRef);

      if (!fromUserDoc.exists || !toUserDoc.exists) {
        throw Exception('One or both users not found');
      }

      final fromBalance = fromUserDoc.data()!['balance'] as double;
      final toBalance = toUserDoc.data()!['balance'] as double;

      // 2. Проверяем баланс
      if (fromBalance < amount) {
        throw Exception('Insufficient balance');
      }

      // 3. Обновляем документы
      transaction.update(fromUserRef, {'balance': fromBalance - amount});
      transaction.update(toUserRef, {'balance': toBalance + amount});
    });

    return Result.success(null);
  } catch (e) {
    return Result.failure('Transaction failed: $e');
  }
}

Batch операции

Для нескольких записей используйте batch операции:

Future<void> deleteAllMessages(String chatId) async {
  final batch = FirebaseFirestore.instance.batch();

  final messages = await FirebaseFirestore.instance
      .collection('chats')
      .doc(chatId)
      .collection('messages')
      .limit(500)
      .get();

  for (var doc in messages.docs) {
    batch.delete(doc.reference);
  }

  await batch.commit();
}

Firebase Storage

Cloud Storage используется для хранения файлов: изображений, видео, документов и других бинарных данных.

Загрузка файлов

import 'package:firebase_storage/firebase_storage.dart';

class StorageService {
  final FirebaseStorage _storage = FirebaseStorage.instance;

  // Загрузить файл
  Future<Result<String>> uploadFile({
    required File file,
    required String path,
  }) async {
    try {
      final ref = _storage.ref().child(path);

      final uploadTask = ref.putFile(file);

      // Отслеживание прогресса
      uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) {
        final progress = snapshot.bytesTransferred / snapshot.totalBytes;
        print('Upload progress: ${(progress * 100).toStringAsFixed(0)}%');
      });

      // Ожидание завершения
      await uploadTask;

      // Получение URL
      final url = await ref.getDownloadURL();

      return Result.success(url);
    } catch (e) {
      return Result.failure('Upload failed');
    }
  }

  // Загрузить изображение с metadata
  Future<Result<String>> uploadImage(File imageFile) async {
    try {
      final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
      final ref = _storage.ref().child('images/$fileName');

      final metadata = SettableMetadata(
        contentType: 'image/jpeg',
        customMetadata: {
          'uploaded_by': 'user_id',
          'description': 'Profile picture',
        },
      );

      await ref.putFile(imageFile, metadata);

      final url = await ref.getDownloadURL();

      return Result.success(url);
    } catch (e) {
      return Result.failure('Image upload failed');
    }
  }

  // Удалить файл
  Future<Result<void>> deleteFile(String path) async {
    try {
      await _storage.ref().child(path).delete();

      return Result.success(null);
    } catch (e) {
      return Result.failure('Delete failed');
    }
  }

  // Получить URL файла
  Future<Result<String>> getFileUrl(String path) async {
    try {
      final url = await _storage.ref().child(path).getDownloadURL();

      return Result.success(url);
    } catch (e) {
      return Result.failure('Failed to get URL');
    }
  }
}

Cloud Functions

Cloud Functions позволяют выполнять serverless код на backend. Это полезно для: — Отправки email уведомлений — Обработки платежей — Агрегации данных — Интеграции с внешними API

Вызов функций из Flutter

dependencies:
  cloud_functions: ^5.0.0
import 'package:cloud_functions/cloud_functions.dart';

class FunctionsService {
  final FirebaseFunctions _functions = FirebaseFunctions.instance;

  Future<Result<void>> sendWelcomeEmail(String userId) async {
    try {
      final result = await _functions.httpsCallable('sendWelcomeEmail').call({
        'userId': userId,
      });

      return Result.success(result.data);
    } on FirebaseFunctionsException catch (e) {
      return Result.failure(e.message);
    } catch (e) {
      return Result.failure('Function call failed');
    }
  }

  Future<Result<String>> processPayment(PaymentRequest request) async {
    try {
      final result =
          await _functions.httpsCallable('processPayment').call(request.toMap());

      final transactionId = result.data['transactionId'] as String;

      return Result.success(transactionId);
    } catch (e) {
      return Result.failure('Payment failed');
    }
  }
}

Push Notifications

Firebase Cloud Messaging (FCM) позволяет отправлять push-уведомления на устройства пользователей.

Настройка FCM

import 'package:firebase_messaging/firebase_messaging.dart';

class NotificationService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  Future<void> initialize() async {
    // 1. Запрос permissions на iOS
    NotificationSettings settings = await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      print('User granted permission');
    }

    // 2. Получить FCM token
    final token = await _messaging.getToken();

    if (token != null) {
      print('FCM Token: $token');

      // Сохранить token в Firestore для отправки уведомлений
      await _saveTokenToServer(token);
    }

    // 3. Слушать сообщения в foreground
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('Got message in foreground: ${message.notification?.title}');

      // Показать local notification
      _showLocalNotification(message);
    });

    // 4. Сообщения при открытии приложения из notification
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      print('App opened from notification');
      _handleNotificationTap(message);
    });
  }

  Future<void> _saveTokenToServer(String token) async {
    // Сохранить token в Firestore
    final userId = FirebaseAuth.instance.currentUser?.uid;

    if (userId != null) {
      await FirebaseFirestore.instance
          .collection('users')
          .doc(userId)
          .update({'fcmToken': token});
    }
  }

  void _showLocalNotification(RemoteMessage message) {
    // Используйте flutter_local_notifications для показа
  }

  void _handleNotificationTap(RemoteMessage message) {
    // Навигация к нужному экрану
  }
}

Обработка фоновых сообщений

Для обработки сообщений в свернутом приложении:

// Обязательно top-level функция
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();

  print('Background message: ${message.messageId}');

  // Обработка сообщения
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Регистрируем background handler
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(MyApp());
}

Analytics

Firebase Analytics автоматически собирает данные о пользователях. Для custom events:

import 'package:firebase_analytics/firebase_analytics.dart';

class AnalyticsService {
  final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;

  // Логировать custom событие
  Future<void> logEvent({
    required String name,
    Map<String, Object?>? parameters,
  }) async {
    await _analytics.logEvent(
      name: name,
      parameters: parameters,
    );
  }

  // Просмотр экрана
  Future<void> logScreenView(String screenName) async {
    await _analytics.logScreenView(
      screenName: screenName,
      screenClass: screenName,
    );
  }

  // Покупка
  Future<void> logPurchase({
    required String itemId,
    required double price,
    required String currency,
  }) async {
    await _analytics.logPurchase(
      transactionId: DateTime.now().millisecondsSinceEpoch.toString(),
      itemId: itemId,
      value: price,
      currency: currency,
    );
  }

  // Поиск
  Future<void> logSearch(String searchTerm) async {
    await _analytics.logSearch(searchTerm: searchTerm);
  }

  // Выбор контента
  Future<void> logSelectContent({
    required String contentType,
    required String itemId,
  }) async {
    await _analytics.logSelectContent(
      contentType: contentType,
      itemId: itemId,
    );
  }
}

Заключение

Firebase предоставляет мощный и простой бэкенд для Flutter приложений. В этом руководстве мыcovered:

  • Authentication — email/password и Google sign-in
  • Cloud Firestore — CRUD операции, real-time обновления, транзакции
  • Cloud Storage — загрузка файлов
  • Cloud Functions — serverless функции
  • FCM — push-уведомления
  • Analytics — tracking событий

Используйте Firebase для быстрого прототипирования и MVP. Для больших проектов с complex requirements может потребоваться custom backend.

Рубрики
Flutter

Тестирование во Flutter: Полное руководство 2025

Тестирование — неотъемлемая часть разработки качественных Flutter приложений. В этом руководстве мы разберём все типы тестов, научимся писать надёжные тесты и покроем лучшие практики тестирования в Flutter.

Виды тестов во Flutter

Flutter поддерживает три основных типа тестов, каждый из которых решает свои задачи:

  • Unit тесты — тестируют изолированные функции и классы без зависимостей от Flutter фреймворка
  • Widget тесты — тестируют отдельные UI компоненты в изолированном окружении
  • Integration тесты — тестируют всё приложение целиком, имитируя действия пользователя

Пирамида тестирования

        Integration
       /            \
      /              \
     /    Widget      \
    /  (много тестов)  \
   /____________________\
  Unit    (много тестов, быстрые)

Идеальный баланс: больше всего unit тестов, меньше widget, ещё меньше integration. Unit тесты быстрые и дешёвые в поддержке, integration — медленные и дорогие.

Unit тесты

Unit тесты проверяют работу отдельных функций и классов в изоляции. Они не зависят от Flutter фреймворка и выполняются очень быстро.

Базовый пример

Начнём с простого примера. Допустим, у нас есть класс для валидации email:

// lib/models/user.dart
class User {
  final String name;
  final String email;

  User({required this.name, required this.email});

  bool get isValidEmail {
    return email.contains('@') && email.contains('.');
  }
}

Напишем unit тест для этого класса:

// test/models/user_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/models/user.dart';

void main() {
  group('User', () {
    test('isValidEmail returns true for valid email', () {
      final user = User(name: 'John', email: 'john@example.com');

      expect(user.isValidEmail, isTrue);
    });

    test('isValidEmail returns false for invalid email', () {
      final user = User(name: 'John', email: 'invalid-email');

      expect(user.isValidEmail, isFalse);
    });

    test('isValidEmail returns false when email missing @', () {
      final user = User(name: 'John', email: 'example.com');

      expect(user.isValidEmail, isFalse);
    });
  });
}

Запуск тестов

# Запустить все тесты
flutter test

# Запустить конкретный файл
flutter test test/models/user_test.dart

# Запустить с покрытием кода
flutter test --coverage

Использование Matchers

Flutter Test предоставляет мощные matchers для проверок:

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('Matchers examples', () {
    final number = 42;
    final text = 'Hello World';
    final items = ['apple', 'banana', 'orange'];

    // Equality matchers
    expect(number, equals(42));
    expect(number, isNotNull);
    expect(number, isPositive);
    expect(number, greaterThan(40));
    expect(number, inInclusiveRange(40, 50));

    // String matchers
    expect(text, contains('World'));
    expect(text, startsWith('Hello'));
    expect(text, endsWith('World'));
    expect(text, matches(r'^Hello.*'));

    // Collection matchers
    expect(items, contains('banana'));
    expect(items, hasLength(3));
    expect(items, isNotEmpty);
    expect(items, allOf(hasLength(greaterThan(2)), contains('apple')));
  });
}

Тестирование асинхронного кода

Для асинхронного кода используйте expectLater и await:

test('Async function test', () async {
  final result = await fetchData();

  expect(result, isNotNull);
  expect(result.status, equals('success'));
});

// Тестирование Stream
test('Stream emits values', () async {
  final stream = countStream(3);

  await expectLater(
    stream,
    emitsInOrder([1, 2, 3, emitsDone]),
  );
});

Группировка тестов

Используйте group для логической организации тестов:

void main() {
  group('AuthenticationService', () {
    late AuthService authService;

    setUp(() {
      // Выполняется перед каждым тестом
      authService = AuthService();
    });

    tearDown(() {
      // Выполняется после каждого теста
      authService.dispose();
    });

    group('login', () {
      test('succeeds with valid credentials', () async {
        final result = await authService.login('user@example.com', 'password');

        expect(result.isSuccess, isTrue);
        expect(result.user, isNotNull);
      });

      test('fails with invalid credentials', () async {
        final result = await authService.login('user@example.com', 'wrong');

        expect(result.isSuccess, isFalse);
        expect(result.error, equals('Invalid credentials'));
      });
    });

    group('register', () {
      test('creates new user', () async {
        final result = await authService.register('new@example.com', 'password');

        expect(result.isSuccess, isTrue);
      });

      test('fails with duplicate email', () async {
        await authService.register('user@example.com', 'password');

        final result = await authService.register('user@example.com', 'password');

        expect(result.isSuccess, isFalse);
      });
    });
  });
}

Mocking с Mockito

При тестировании кода с зависимостями используем mock объекты. Mockito — популярный пакет для создания mock объектов во Flutter.

Установка

dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0

Создание Mock классов

import 'package:mockito/mockito.dart';

// Mock класс
class MockAuthService extends Mock implements AuthService {}

class MockHttpClient extends Mock implements http.Client {}

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

Использование Mock в тестах

import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

@GenerateMocks([http.Client])
import 'auth_test.mocks.dart';

void main() {
  group('LoginViewModel', () {
    late LoginViewModel viewModel;
    late MockClient mockClient;

    setUp(() {
      mockClient = MockClient();
      viewModel = LoginViewModel(mockClient);
    });

    test('login succeeds with valid credentials', () async {
      // Arrange: настраиваем mock
      when(mockClient.post(any, body: anyNamed('body')))
          .thenAnswer((_) async => http.Response('{"token": "abc123"}', 200));

      // Act: выполняем действие
      final result = await viewModel.login('user@example.com', 'password');

      // Assert: проверяем результат
      expect(result.isSuccess, isTrue);

      // Проверяем, что mock был вызван правильно
      verify(mockClient.post(
        argThat(equals('/api/login')),
        body: argThat(contains('user@example.com')),
      )).called(1);
    });

    test('login fails with 401', () async {
      when(mockClient.post(any, body: anyNamed('body')))
          .thenAnswer((_) async => http.Response('Unauthorized', 401));

      final result = await viewModel.login('user@example.com', 'password');

      expect(result.isSuccess, isFalse);
      expect(result.error, equals('Unauthorized'));
    });
  });
}

Verify взаимодействий

verify проверяет, что методы были вызваны правильно:

test('calls logout when error occurs', () async {
  final mockAuth = MockAuthService();
  final viewModel = ProfileViewModel(mockAuth);

  when(mockAuth.getUser()).thenThrow(Exception('Network error'));

  await viewModel.loadProfile();

  verify(mockAuth.logout()).called(1);
});

// Проверка, что метод не был вызван
verifyNever(mockAuth.deleteAccount());

// Проверка количества вызовов
verify(mockAuth.refreshToken()).called(3);

Widget тесты

Widget тесты проверяют правильность рендеринга UI компонентов. Они запускаются в тестовом окружении Flutter, но быстрее integration тестов.

Базовый widget тест

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('MyWidget displays text', (tester) async {
    // Загружаем виджет
    await tester.pumpWidget(MyWidget());

    // Проверяем наличие текста
    expect(find.text('Hello World'), findsOneWidget);
  });
}

Тестирование взаимодействий

testWidgets('Button increments counter', (tester) async {
  await tester.pumpWidget(MyApp());

  // Находим текст с начальным значением
  expect(find.text('0'), findsOneWidget);

  // Находим кнопку и нажимаем
  await tester.tap(find.byType(FloatingActionButton));

  // Обновляем виджет после изменения состояния
  await tester.pump();

  // Проверяем новое значение
  expect(find.text('1'), findsOneWidget);
});

Работа с TextField

testWidgets('TextField input and validation', (tester) async {
  await tester.pumpWidget(MyForm());

  final textField = find.byType(TextField);

  // Вводим текст
  await tester.enterText(textField, 'invalid-email');

  // Тап на кнопку валидации
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();

  // Проверяем сообщение об ошибке
  expect(find.text('Invalid email'), findsOneWidget);

  // Вводим корректный email
  await tester.enterText(textField, 'valid@example.com');
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();

  // Проверяем успешную валидацию
  expect(find.text('Success'), findsOneWidget);
});

Поиск виджетов

Flutter Test предоставляет множество способов поиска виджетов:

testWidgets('Finding widgets examples', (tester) async {
  await tester.pumpWidget(MyWidget());

  // По тексту
  expect(find.text('Submit'), findsOneWidget);

  // По типу
  expect(find.byType(ElevatedButton), findsOneWidget);

  // По ключу
  expect(find.byKey(Key('submit-button')), findsOneWidget);

  // По иконке
  expect(find.byIcon(Icons.send), findsOneWidget);

  // По виджету с определённым свойством
  expect(find.byWidgetPredicate((widget) =>
      widget is Text && widget.data?.startsWith('Hello') == true),
      findsOneWidget);
});

Matchers для поиска

  • findsOneWidget — найден ровно один виджет
  • findsNothing — виджет не найден
  • findsWidgets — найдено несколько виджетов
  • findsNWidgets(n) — найдено ровно n виджетов

Тестирование ScrollView

testWidgets('ListView scrolling', (tester) async {
  final items = List.generate(100, (i) => 'Item $i');

  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) => ListTile(title: Text(items[index])),
        ),
      ),
    ),
  );

  // Проверяем видимый элемент
  expect(find.text('Item 0'), findsOneWidget);

  // Скроллим вниз
  await tester.drag(find.byType(ListView), Offset(0, -500));
  await tester.pump();

  // Теперь видим другие элементы
  expect(find.text('Item 10'), findsOneWidget);
  expect(find.text('Item 0'), findsNothing);
});

Тестирование с Provider

testWidgets('Counter with Provider', (tester) async {
  await tester.pumpWidget(
    Provider<Counter>(
      create: (_) => Counter(),
      child: const MaterialApp(home: CounterPage()),
    ),
  );

  // Начальное состояние
  expect(find.text('0'), findsOneWidget);

  // Увеличиваем счётчик
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);

  // Проверяем через Provider
  final context = tester.element(find.byType(CounterPage));
  final counter = Provider.of<Counter>(context, listen: false);
  expect(counter.value, equals(1));
});

Integration тесты

Integration тесты проверяют работу всего приложения от начала до конца. Они запускаются на реальном устройстве или эмуляторе.

Настройка

dev_dependencies:
  integration_test:
    sdk: flutter
  flutter_test:
    sdk: flutter

Базовый integration тест

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('Full login flow', (tester) async {
    await tester.pumpWidget(MyApp());

    // Вводим email
    await tester.enterText(
      find.byKey(Key('email-field')),
      'user@example.com',
    );

    // Вводим пароль
    await tester.enterText(
      find.byKey(Key('password-field')),
      'password123',
    );

    // Нажимаем кнопку входа
    await tester.tap(find.byKey(Key('login-button')));
    await tester.pumpAndSettle();

    // Проверяем, что перешли на экран профиля
    expect(find.text('Welcome'), findsOneWidget);
    expect(find.byType(ProfileScreen), findsOneWidget);
  });
}

Тестирование сложных сценариев

testWidgets('Complete purchase flow', (tester) async {
  await tester.pumpWidget(MyApp());

  // 1. Логин
  await _login(tester, 'user@example.com', 'password');

  // 2. Выбор товара
  await tester.tap(find.text('Products'));
  await tester.pumpAndSettle();
  await tester.tap(find.text('Product 1'));
  await tester.pumpAndSettle();

  // 3. Добавление в корзину
  await tester.tap(find.text('Add to Cart'));
  await tester.pumpAndSettle();

  // 4. Переход в корзину
  await tester.tap(find.text('Cart'));
  await tester.pumpAndSettle();

  // 5. Checkout
  await tester.tap(find.text('Checkout'));
  await tester.pumpAndSettle();

  // 6. Заполнение формы оплаты
  await tester.enterText(
    find.byKey(Key('card-number')),
    '4111111111111111',
  );
  await tester.tap(find.text('Pay'));
  await tester.pumpAndSettle();

  // Проверяем успешную покупку
  expect(find.text('Order Confirmed'), findsOneWidget);
});

Future<void> _login(WidgetTester tester, String email, String password) async {
  await tester.enterText(find.byKey(Key('email')), email);
  await tester.enterText(find.byKey(Key('password')), password);
  await tester.tap(find.text('Login'));
  await tester.pumpAndSettle();
}

Golden тесты

Golden тесты сравнивают рендеринг виджета с эталонным изображением.

Создание golden теста

testWidgets('MyWidget golden test', (tester) async {
  await tester.pumpWidget(MyWidget());

  await expectLater(
    find.byType(MyWidget),
    matchesGoldenFile('goldens/my_widget.png'),
  );
});

Обновление golden файлов

# Создать/обновить golden файлы
flutter test --update-goldens

# Запустить тест с golden
flutter test

Golden тесты для разных тем

testWidgets('MyWidget in dark mode', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: ThemeData.dark(),
      home: MyWidget(),
    ),
  );

  await expectLater(
    find.byType(MyWidget),
    matchesGoldenFile('goldens/my_widget_dark.png'),
  );
});

Best Practices

1. Organize тесты правильно

test/
├── unit/              # Unit тесты
│   ├── models/
│   ├── services/
│   └── utils/
├── widgets/           # Widget тесты
│   ├── common/
│   ├── screens/
│   └── dialogs/
└── integration/       # Integration тесты
    └── flows/

2. Используйте AAA паттерн

Arrange-Act-Assert делает тесты читаемыми:

test('User can login', () async {
  // Arrange: настраиваем тестовые данные
  final viewModel = LoginViewModel(mockApi);
  when(mockApi.login(any, any)).thenAnswer((_) async => User(id: '1'));

  // Act: выполняем тестируемое действие
  await viewModel.login('user@example.com', 'password');

  // Assert: проверяем результат
  expect(viewModel.user, isNotNull);
  expect(viewModel.user.id, equals('1'));
});

3. Тестируйте edge cases

Не забывайте тестировать граничные случаи:

test('handles empty list', () {
  final result = processItems([]);
  expect(result, isEmpty);
});

test('handles null input', () {
  final result = processInput(null);
  expect(result, isNull);
});

test('handles maximum value', () {
  final counter = Counter(max: 100);
  counter.increment();
  expect(counter.value, equals(100));
});

4. Держите тесты независимыми

setUp(() {
  // Каждый тест начинается с чистого состояния
  service = AuthService();
});

tearDown(() {
  // Очищаем ресурсы после каждого теста
  service.dispose();
});

5. Используйте описательные имена тестов

// Плохо
test('test1', () {});

// Хорошо
test('login succeeds with valid credentials', () {});
test('login fails when user does not exist', () {});
test('login fails with wrong password', () {});

Покрытие кода

Измеряйте покрытие кода тестами:

# Запустить тесты с покрытием
flutter test --coverage

# Генерировать HTML отчёт
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

Заключение

Тестирование Flutter приложений в 2025 — это обязательная практика для создания качественного ПО. Используйте все три типа тестов: unit для быстрой проверки логики, widget для UI компонентов, и integration для проверки полных сценариев использования. Помните о пирамиде тестирования и старайтесь поддерживать высокий уровень покрытия кода тестами.