Рубрики
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 для проверки полных сценариев использования. Помните о пирамиде тестирования и старайтесь поддерживать высокий уровень покрытия кода тестами.