Рубрики
Flutter

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

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

Performance Overlay

Включение overlay

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

Dart DevTools

flutter pub global activate devtools
flutter pub global run devtools

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

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

// Плохо
class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text('Hello'),
    );
  }
}

// Хорошо
class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const SizedBox(
      child: Text('Hello'),
    );
  }
}

RepaintBoundary

// Изолировать дорогостоящую отрисовку
RepaintBoundary(
  child: ExpensiveWidget(),
)

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

// Плохо
ListView(
  children: [
    for (int i = 0; i < 10000; i++) ListTile(title: Text('Item $i')),
  ],
)

// Хорошо
ListView.builder(
  itemCount: 10000,
  itemBuilder: (context, index) {
    return ListTile(title: Text('Item $index'));
  },
)

addAutomaticKeepAlives

// Отключение сохранения состояния для больших списков
ListView.builder(
  addAutomaticKeepAlives: false,
  addRepaintBoundaries: false,
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemWidget(items[index]);
  },
)

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

StatefulWidget правильно

class GoodStatefulWidget extends StatefulWidget {
  final String data;

  const GoodStatefulWidget({required this.data, super.key});

  @override
  State<GoodStatefulWidget> createState() => _GoodStatefulWidgetState();
}

class _GoodStatefulWidgetState extends State<GoodStatefulWidget> {
  @override
  Widget build(BuildContext context) {
    return Text(widget.data);
  }
}

rebuild на виджет

// Плохо - весь UI пересобирается
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(
          child: ListView.builder(
            itemCount: 1000,
            itemBuilder: (context, index) {
              return ListTile(title: Text('Item $index'));
            },
          ),
        ),
        ElevatedButton(
          onPressed: () => setState(() => counter++),
          child: Text('Increment'),
        ),
      ],
    );
  }
}

// Хорошо - пересобирается только нужная часть
class GoodExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CounterWidget(),
        Expanded(
          child: ListView.builder(
            itemCount: 1000,
            itemBuilder: (context, index) {
              return ListTile(title: Text('Item $index'));
            },
          ),
        ),
      ],
    );
  }
}

class CounterWidget extends StatefulWidget {
  @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: Text('Increment'),
        ),
      ],
    );
  }
}

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

Кэширование

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),
)

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

// Укажите размер
Image.network(
  'https://example.com/image.jpg',
  width: 100,
  height: 100,
  fit: BoxFit.cover,
)

Lazy loading

class ImageLazyLoader extends StatefulWidget {
  @override
  State<ImageLazyLoader> createState() => _ImageLazyLoaderState();
}

class _ImageLazyLoaderState extends State<ImageLazyLoader> {
  @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,
        );
      },
    );
  }
}

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

Dispose ресурсов

class ProperDispose extends StatefulWidget {
  @override
  State<ProperDispose> createState() => _ProperDisposeState();
}

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

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

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

    _textController = TextEditingController();

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

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

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

Оптимизация списков

// Плохо - все данные в памяти
final allItems = List.generate(100000, (i) => Item(id: i));

// Хорошо - загрузка по требованию
class LazyList extends StatefulWidget {
  @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 Center(child: CircularProgressIndicator());
        }
        return ItemWidget(_items[index]);
      },
    );
  }
}

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

Batch запросы

// Плохо
for (final id in ids) {
  final item = await api.getItem(id);
}

// Хорошо
final items = await api.getItems(ids);

Кэширование

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

  CachedApiService(this._api);

  Future<T> get<T>(String key, Future<T> Function() fetch) async {
    if (_cache.containsKey(key)) {
      return _cache[key] as T;
    }

    final result = await fetch();
    _cache[key] = result;
    return result;
  }

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

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

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

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

Compute функция

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

// Использование
class ComputeWidget extends StatefulWidget {
  @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: Text('Compute'),
        ),
      ],
    );
  }
}

Isolate для JSON parsing

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

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

Profiling

Performance timeline

class ProfiledWidget extends StatefulWidget {
  @override
  State<ProfiledWidget> createState() => _ProfiledWidgetState();
}

class _ProfiledWidgetState extends State<ProfiledWidget> {
  @override
  Widget build(BuildContext context) {
    return Timeline.startSync('build ProfiledWidget', () {
      return Container();
    });
  }
}

Best Practices

1. Используйте const где возможно

const SizedBox(height: 16)
const Text('Hello')

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

// Используйте отдельные виджеты для динамических частей
class StaticContainer extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        StaticPart(),
        DynamicPart(),
      ],
    );
  }
}

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

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

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

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

Заключение

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

Рубрики
Flutter

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

Хорошая архитектура — залог успеха проекта. Разберём Clean Architecture для Flutter приложений.

Что такое Clean Architecture?

Clean Architecture — это подход, разделяющий приложение на слои с зависимостями направленными внутрь.

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

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

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

lib/
├── core/
│   ├── error/
│   │   └── failures.dart
│   ├── network/
│   │   └── network_info.dart
│   ├── usecases/
│   │   └── usecase.dart
│   └── constants.dart
├── features/
│   └── auth/
│       ├── data/
│       │   ├── models/
│       │   ├── repositories/
│       │   └── datasources/
│       ├── domain/
│       │   ├── entities/
│       │   ├── repositories/
│       │   └── usecases/
│       └── presentation/
│           ├── bloc/
│           ├── pages/
│           └── widgets/
├── config/
│   └── routes.dart
└── main.dart

Domain слой

Entities (Сущности)

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

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

Repository интерфейсы

// features/auth/domain/repositories/auth_repository.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, void>> logout();
  Stream<User?> get authStateChanges;
}

Use Cases

// core/usecases/usecase.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());
    }

    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

// core/error/failures.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 InvalidEmailFailure extends Failure {
  const InvalidEmailFailure([String message = 'Invalid email'])
      : super(message);
}

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

Data слой

DTOs (Data Transfer Objects)

// features/auth/data/models/user_model.dart
class UserModel extends User {
  UserModel({
    required String id,
    required String name,
    required String email,
  }) : super(id: id, name: name, email: email);

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

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

  User toEntity() {
    return User(id: id, name: name, email: email);
  }
}

Data Sources

// features/auth/data/datasources/auth_remote_datasource.dart
abstract class AuthRemoteDataSource {
  Future<UserModel> login(String email, String password);
  Future<UserModel> register(String email, String password);
  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/login'),
      body: {'email': email, 'password': password},
    );

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

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

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

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

Repository Implementation

// features/auth/data/repositories/auth_repository_impl.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 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('Server error'));
      }
    } else {
      return Left(NetworkFailure('No internet connection'));
    }
  }

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

  @override
  Stream<User?> get authStateChanges {
    // Реализация stream изменений
    return const Stream.empty();
  }
}

Presentation слой

BLoC

// features/auth/presentation/bloc/auth_bloc.dart
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 {}

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];
}

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

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

  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(NoParams());

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

Pages

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: BlocBuilder<AuthBloc, AuthState>(
        builder: (context, state) {
          if (state is AuthLoading) {
            return Center(child: CircularProgressIndicator());
          }

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

Widgets

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

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state is AuthError) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(state.message)),
          );
        }
      },
      child: Form(
        key: _formKey,
        child: Padding(
          padding: EdgeInsets.all(16),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextFormField(
                controller: _emailController,
                decoration: InputDecoration(labelText: 'Email'),
                validator: (value) {
                  if (value == null || !value.contains('@')) {
                    return 'Invalid email';
                  }
                  return null;
                },
              ),
              TextFormField(
                controller: _passwordController,
                decoration: InputDecoration(labelText: 'Password'),
                obscureText: true,
                validator: (value) {
                  if (value == null || value.length < 6) {
                    return 'Password too short';
                  }
                  return null;
                },
              ),
              SizedBox(height: 20),
              ElevatedButton(
                onPressed: () {
                  if (_formKey.currentState!.validate()) {
                    context.read<AuthBloc>().add(
                          LoginEvent(
                            email: _emailController.text,
                            password: _passwordController.text,
                          ),
                        );
                  }
                },
                child: Text('Login'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Dependency Injection

GetIt

// core/locator/locator.dart
final sl = GetIt.instance;

Future<void> init() async {
  // External
  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(),
      ));
}

Заключение

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

Рубрики
Flutter

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

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

Виды анимаций

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

  • Implicit animations — простые анимации с минимальным кодом
  • Explicit animations — сложные анимации с полным контролем

Implicit анимации

AnimatedContainer

class AnimatedContainerWidget extends StatefulWidget {
  @override
  State<AnimatedContainerWidget> createState() => _AnimatedContainerWidgetState();
}

class _AnimatedContainerWidgetState extends State<AnimatedContainerWidget> {
  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,
        color: _expanded ? Colors.blue : Colors.red,
        child: Center(child: Text('Tap me')),
      ),
    );
  }
}

AnimatedOpacity

class FadeWidget extends StatefulWidget {
  @override
  State<FadeWidget> createState() => _FadeWidgetState();
}

class _FadeWidgetState extends State<FadeWidget> {
  double _opacity = 1.0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AnimatedOpacity(
          opacity: _opacity,
          duration: Duration(milliseconds: 500),
          child: FlutterLogo(size: 100),
        ),
        Slider(
          value: _opacity,
          onChanged: (value) => setState(() => _opacity = value),
        ),
      ],
    );
  }
}

AnimatedPositioned

class PositionedWidget extends StatefulWidget {
  @override
  State<PositionedWidget> createState() => _PositionedWidgetState();
}

class _PositionedWidgetState extends State<PositionedWidget> {
  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: Container(
                width: 100,
                height: 100,
                color: Colors.blue,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

AnimatedCrossFade

class CrossFadeWidget extends StatefulWidget {
  @override
  State<CrossFadeWidget> createState() => _CrossFadeWidgetState();
}

class _CrossFadeWidgetState extends State<CrossFadeWidget> {
  bool _showFirst = true;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AnimatedCrossFade(
          firstChild: FlutterLogo(size: 100),
          secondChild: Icon(Icons.star, size: 100),
          crossFadeState: _showFirst
              ? CrossFadeState.showFirst
              : CrossFadeState.showSecond,
          duration: Duration(milliseconds: 500),
        ),
        ElevatedButton(
          onPressed: () => setState(() => _showFirst = !_showFirst),
          child: Text('Toggle'),
        ),
      ],
    );
  }
}

Explicit анимации

AnimationController

class ExplicitAnimationWidget extends StatefulWidget {
  @override
  State<ExplicitAnimationWidget> createState() => _ExplicitAnimationWidgetState();
}

class _ExplicitAnimationWidgetState extends State<ExplicitAnimationWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

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

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

    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);

    _controller.forward();
  }

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

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

Tween анимация

class TweenAnimationWidget extends StatefulWidget {
  @override
  State<TweenAnimationWidget> createState() => _TweenAnimationWidgetState();
}

class _TweenAnimationWidgetState extends State<TweenAnimationWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Color?> _colorAnimation;
  late Animation<double> _sizeAnimation;

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

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

    _colorAnimation = ColorTween(
      begin: Colors.blue,
      end: Colors.red,
    ).animate(_controller);

    _sizeAnimation = Tween<double>(begin: 50, end: 150).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
  }

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

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

СтackedTween

class StackedAnimationWidget extends StatefulWidget {
  @override
  State<StackedAnimationWidget> createState() => _StackedAnimationWidgetState();
}

class _StackedAnimationWidgetState extends State<StackedAnimationWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _opacity;
  late Animation<double> _size;
  late Animation<double> _rotation;

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

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

    _opacity = Tween<double>(begin: 0.5, end: 1).animate(_controller);
    _size = Tween<double>(begin: 50, end: 150).animate(_controller);
    _rotation = Tween<double>(begin: 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: _rotation.value,
          child: Opacity(
            opacity: _opacity.value,
            child: Container(
              width: _size.value,
              height: _size.value,
              color: Colors.blue,
            ),
          ),
        );
      },
    );
  }
}

Hero анимации

Базовая Hero

class FirstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('First')),
      body: GestureDetector(
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => SecondPage()),
        ),
        child: Hero(
          tag: 'image-hero',
          child: Image.network('https://example.com/image.jpg'),
        ),
      ),
    );
  }
}

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Second')),
      body: Center(
        child: Hero(
          tag: 'image-hero',
          child: Image.network('https://example.com/image.jpg'),
        ),
      ),
    );
  }
}

Hero с placeholder

Hero(
  tag: 'avatar',
  placeholderBuilder: (context, heroSize, child) {
    return Container(
      width: heroSize.width,
      height: heroSize.height,
      child: CircularProgressIndicator(),
    );
  },
  child: CircleAvatar(
    backgroundImage: NetworkImage(url),
  ),
)

CustomPainter анимации

Анимированный painter

class AnimatedCirclePainter extends CustomPainter {
  final double progress;

  AnimatedCirclePainter(this.progress);

  @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 = Colors.blue
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;

    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;
  }
}

// Использование
class AnimatedCircleWidget extends StatefulWidget {
  @override
  State<AnimatedCircleWidget> createState() => _AnimatedCircleWidgetState();
}

class _AnimatedCircleWidgetState extends State<AnimatedCircleWidget>
    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(_controller.value),
          size: Size(200, 200),
        );
      },
    );
  }
}

Lottie анимации

Установка

dependencies:
  lottie: ^3.0.0

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

class LottieWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Lottie.asset('assets/animation.json');
  }
}

// С контроллером
class ControlledLottieWidget extends StatefulWidget {
  @override
  State<ControlledLottieWidget> createState() => _ControlledLottieWidgetState();
}

class _ControlledLottieWidgetState extends State<ControlledLottieWidget> {
  late AnimationController _controller;

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

    _controller = AnimationController(vsync: this);

    // Загрузка анимации
    _loadAnimation();
  }

  void _loadAnimation() {
    final composition = Lottie.asset('assets/animation.json');
    composition.then((value) {
      _controller.duration = value.duration;
    });
  }

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

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

Rive анимации

Установка

dependencies:
  flutter_rive: ^0.13.0

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

class RiveWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RiveAnimation.asset('assets/animation.riv');
  }
}

// С контроллером
class ControlledRiveWidget extends StatefulWidget {
  @override
  State<ControlledRiveWidget> createState() => _ControlledRiveWidgetState();
}

class _ControlledRiveWidgetState extends State<ControlledRiveWidget> {
  late SMITrigger _bump;

  void _onRiveInit(Artboard artboard) {
    final controller = StateMachineController.fromArtboard(artboard, 'bumpy');
    artboard.addController(controller!);
    _bump = controller.findInput<bool>('bump') as SMITrigger;
  }

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

Curves

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

// Линейная
Curves.linear

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

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

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

// Bounce эффект
Curves.bounceIn
Curves.bounceOut
Curves.elasticOut

Заключение

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

Рубрики
Flutter

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

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

Установка

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

  1. Создайте проект в Firebase Console
  2. Добавьте приложения Android и iOS
  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

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

import 'package:firebase_core/firebase_core.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

Authentication

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

import 'package:firebase_auth/firebase_auth.dart';

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  // Регистрация
  Future<UserCredential?> register(
    String email,
    String password,
  ) async {
    try {
      return await _auth.createUserWithEmailAndPassword(
        email: email,
        password: password,
      );
    } catch (e) {
      print('Registration error: $e');
      return null;
    }
  }

  // Вход
  Future<UserCredential?> login(
    String email,
    String password,
  ) async {
    try {
      return await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
    } catch (e) {
      print('Login error: $e');
      return null;
    }
  }

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

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

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

Google Sign-In

dependencies:
  google_sign_in: ^6.2.0
class GoogleAuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  final GoogleSignIn _googleSignIn = GoogleSignIn();

  Future<UserCredential?> signInWithGoogle() async {
    try {
      final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();

      if (googleUser == null) return null;

      final GoogleSignInAuthentication googleAuth =
          await googleUser.authentication;

      final credential = GoogleAuthProvider.credential(
        accessToken: googleAuth.accessToken,
        idToken: googleAuth.idToken,
      );

      return await _auth.signInWithCredential(credential);
    } catch (e) {
      print('Google sign-in error: $e');
      return null;
    }
  }
}

Отслеживание состояния

class AuthWrapper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.authStateChanges(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return Center(child: CircularProgressIndicator());
        }

        if (snapshot.hasData) {
          return HomeScreen();
        }

        return LoginScreen();
      },
    );
  }
}

Cloud Firestore

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

import 'package:cloud_firestore/cloud_firestore.dart';

class DatabaseService {
  final FirebaseFirestore _db = FirebaseFirestore.instance;

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

  // Читать документ
  Future<User?> getUser(String userId) async {
    final doc = await _db.collection('users').doc(userId).get();

    if (doc.exists) {
      return User.fromMap(doc.data()!);
    }
    return null;
  }

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

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

StreamBuilder для real-time

class UserList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: FirebaseFirestore.instance
          .collection('users')
          .orderBy('createdAt', descending: true)
          .snapshots(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return Center(child: CircularProgressIndicator());
        }

        if (!snapshot.hasData) {
          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));
          },
        );
      },
    );
  }
}

Запросы

// Где
final snapshot = await _db
    .collection('users')
    .where('age', isGreaterThan: 18)
    .get();

// Сортировка
final snapshot = await _db
    .collection('users')
    .orderBy('createdAt', descending: true)
    .limit(10)
    .get();

// Сложные запросы
final snapshot = await _db
    .collection('products')
    .where('category', isEqualTo: 'electronics')
    .where('price', isLessThan: 1000)
    .orderBy('rating', descending: true)
    .get();

Транзакции

Future<void> transferMoney(
  String fromUserId,
  String toUserId,
  double amount,
) async {
  await _db.runTransaction((transaction) async {
    final fromDoc = await transaction.get(_db.collection('users').doc(fromUserId));
    final toDoc = await transaction.get(_db.collection('users').doc(toUserId));

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

    transaction.update(_db.collection('users').doc(fromUserId), {
      'balance': fromBalance - amount
    });

    transaction.update(_db.collection('users').doc(toUserId), {
      'balance': toBalance + amount
    });
  });
}

Firebase Storage

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

import 'package:firebase_storage/firebase_storage.dart';

class StorageService {
  final FirebaseStorage _storage = FirebaseStorage.instance;

  // Загрузить файл
  Future<String?> uploadFile(
    String filePath,
    String fileName,
  ) async {
    try {
      final ref = _storage.ref().child('uploads/$fileName');
      await ref.putFile(File(filePath));

      final url = await ref.getDownloadURL();
      return url;
    } catch (e) {
      print('Upload error: $e');
      return null;
    }
  }

  // Удалить файл
  Future<void> deleteFile(String fileName) async {
    try {
      await _storage.ref().child('uploads/$fileName').delete();
    } catch (e) {
      print('Delete error: $e');
    }
  }
}

Загрузка изображений

Future<String?> uploadImage(File imageFile) async {
  final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
  final ref = _storage.ref().child('images/$fileName');

  final metadata = SettableMetadata(
    contentType: 'image/jpeg',
  );

  await ref.putFile(imageFile, metadata);
  return await ref.getDownloadURL();
}

Cloud Functions

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

dependencies:
  cloud_functions: ^5.0.0
class FunctionsService {
  final FirebaseFunctions _functions = FirebaseFunctions.instance;

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

      print(result.data);
    } catch (e) {
      print('Function error: $e');
    }
  }
}

Push Notifications

Настройка

class NotificationService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  Future<void> initialize() async {
    // Запрос разрешения
    NotificationSettings settings = await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

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

    // Получить токен
    final token = await _messaging.getToken();
    print('FCM Token: $token');

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

    // Сообщения в фоне
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      print('Opened app from message');
    });
  }
}

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

@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();

  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(MyApp());
}

Analytics

class AnalyticsService {
  final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;

  // Лог события
  Future<void> logEvent(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(String itemId, double price) async {
    await _analytics.logPurchase(
      transactionId: DateTime.now().millisecondsSinceEpoch.toString(),
      itemId: itemId,
      value: price,
    );
  }
}

Заключение

Firebase предоставляет полный бэкенд для Flutter приложений. Используйте его для быстрого прототипирования и production приложений.

Рубрики
Flutter

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

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

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

Flutter поддерживает три типа тестов:

  • Unit тесты — тестирование отдельных функций и классов
  • Widget тесты — тестирование UI компонентов
  • Integration тесты — тестирование всего приложения

Unit тесты

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

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('Counter increments', () {
    final counter = Counter();
    counter.increment();
    expect(counter.value, 1);
  });
}

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

test('User validation', () {
  final user = User(name: 'John', email: 'john@example.com');

  expect(user.name, equals('John'));
  expect(user.email, contains('@'));
  expect(user.isValid, isTrue);
});

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

group('User', () {
  test('isValid returns true for valid user', () {
    final user = User(name: 'John', email: 'john@example.com');
    expect(user.isValid, isTrue);
  });

  test('isValid returns false for invalid email', () {
    final user = User(name: 'John', email: 'invalid');
    expect(user.isValid, isFalse);
  });
});

Mocking с Mockito

Установка

dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0

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

import 'package:mockito/mockito.dart';

class MockAuthService extends Mock implements AuthService {}

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

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

test('Login succeeds with valid credentials', () async {
  final mockAuthService = MockAuthService();

  when(mockAuthService.login('user@example.com', 'password'))
      .thenAnswer((_) async => User(id: '1', name: 'John'));

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

  expect(result.name, equals('John'));
  verify(mockAuthService.login('user@example.com', 'password')).called(1);
});

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

test('Logout calls auth service', () {
  final mockAuth = MockAuthService();
  final viewModel = LoginViewModel(mockAuth);

  viewModel.logout();

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

Widget тесты

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

testWidgets('MyWidget displays text', (tester) async {
  await tester.pumpWidget(MyWidget());

  expect(find.text('Hello'), 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);
});

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

// По тексту
find.text('Submit')

// По типу
find.byType(ElevatedButton)

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

// по иконке
find.byIcon(Icons.send)

Работа с TextField

testWidgets('TextField input works', (tester) async {
  await tester.pumpWidget(MyWidget());

  final textField = find.byType(TextField);

  await tester.enterText(textField, 'Hello World');
  await tester.pump();

  expect(find.text('Hello World'), findsOneWidget);
});

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

testWidgets('ListView displays items', (tester) async {
  final items = ['Item 1', 'Item 2', 'Item 3'];

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

  for (final item in items) {
    expect(find.text(item), findsOneWidget);
  }
});

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

testWidgets('Counter increments 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);
});

Integration тесты

Настройка

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

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

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

    // Тест полного пользовательского пути
  });
}

Пример сценария

testWidgets('Complete 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);
});

Golden тесты

Создание golden файла

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

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

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

flutter test --update-goldens

Best Practices

1. Organize тесты

// unit/auth_service_test.dart
// widgets/login_page_test.dart
// integration/login_flow_test.dart

2. Используйте describe/it/group

group('AuthService', () {
  group('login', () {
    test('succeeds with valid credentials', () {});
    test('fails with invalid credentials', () {});
  });
});

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

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

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

4. Изолируйте тесты

setUp(() {
  // Код перед каждым тестом
});

tearDown(() {
  // Код после каждого теста
});

setUpAll(() {
  // Код перед всеми тестами
});

tearDownAll(() {
  // Код после всех тестов
});

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

Запуск с покрытием

flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

Заключение

Тестирование во Flutter в 2025 — это обязательная практика. Используйте все три типа тестов для надёжных приложений.