Поделиться
Поделиться

Flutter позволяет писать одну кодовую базу для iOS, Android, Web и Desktop. Звучит как компромисс, но на практике Flutter-приложения выглядят нативно, работают плавно и выпускаются быстрее, чем два отдельных проекта. Разберём полный путь — от первого виджета до публикации в App Store и Google Play.

Дерево виджетов: как думать по-флаттеровски

В Flutter всё — виджет. Кнопка, отступ, анимация, даже тема приложения. Интерфейс — это дерево неизменяемых (immutable) объектов, которое Flutter перестраивает при каждом изменении состояния.

Два базовых типа виджетов:

| Тип | Когда использовать | |---|---| | StatelessWidget | Нет внутреннего состояния, зависит только от входных параметров | | StatefulWidget | Есть мутируемое состояние, нужен setState |

// Простой StatelessWidget
class ProductCard extends StatelessWidget {
  final Product product;
  const ProductCard({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        leading: CachedNetworkImage(imageUrl: product.imageUrl),
        title: Text(product.name),
        subtitle: Text('${product.price} ₽'),
        trailing: AddToCartButton(productId: product.id),
      ),
    );
  }
}

Производительность дерева

Главное правило: чем меньше виджет — тем лучше. Разбивайте build на маленькие методы или отдельные классы. Используйте const конструкторы везде, где возможно — Flutter пропустит rebuild таких виджетов:

// ❌ Пересоздаётся при каждом rebuild родителя
Text('Заголовок')

// ✅ Flutter кеширует, rebuild не происходит
const Text('Заголовок')

Управление состоянием: Bloc vs Riverpod

Bloc

Bloc (Business Logic Component) реализует однонаправленный поток данных: Events → Bloc → States. Отлично подходит для сложной логики с чёткими переходами между состояниями.

// Событие
abstract class AuthEvent {}
class LoginRequested extends AuthEvent {
  final String email, password;
  LoginRequested(this.email, this.password);
}

// Состояние
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState { final User user; AuthAuthenticated(this.user); }
class AuthError extends AuthState { final String message; AuthError(this.message); }

// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc(this._authRepository) : super(AuthInitial()) {
    on<LoginRequested>(_onLoginRequested);
  }

  Future<void> _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
    emit(AuthLoading());
    try {
      final user = await _authRepository.login(event.email, event.password);
      emit(AuthAuthenticated(user));
    } catch (e) {
      emit(AuthError(e.toString()));
    }
  }
}

// UI
BlocBuilder<AuthBloc, AuthState>(
  builder: (context, state) {
    if (state is AuthLoading) return CircularProgressIndicator();
    if (state is AuthError) return ErrorMessage(state.message);
    return LoginForm();
  },
)

Riverpod

Riverpod проще в начале и хорошо масштабируется. Провайдеры — это глобальные объявления, которые компилятор проверяет статически.

// providers.dart
final authRepositoryProvider = Provider<AuthRepository>((ref) => AuthRepository());

final userProvider = FutureProvider<User?>((ref) async {
  final repo = ref.watch(authRepositoryProvider);
  return repo.getCurrentUser();
});

// UI
class ProfileScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider);
    return userAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (e, _) => Text('Ошибка: $e'),
      data: (user) => user != null ? UserInfo(user) : LoginPrompt(),
    );
  }
}

Когда выбирать что:

  • Bloc — для крупных команд, строгих архитектурных требований, сложных state machines
  • Riverpod — для быстрого старта, небольших команд, менее церемониального кода

Platform Channels: обращение к нативному коду

Иногда Flutter SDK не покрывает нужную функцию — например, Bluetooth Low Energy или специфичный биометрический API. Platform Channels позволяют вызывать нативный код Kotlin/Swift из Dart:

// Dart
class BiometricService {
  static const _channel = MethodChannel('com.example.app/biometric');

  Future<bool> authenticate() async {
    try {
      return await _channel.invokeMethod<bool>('authenticate') ?? false;
    } on PlatformException catch (e) {
      debugPrint('Biometric error: ${e.message}');
      return false;
    }
  }
}
// Android (MainActivity.kt)
private fun setupBiometricChannel(flutterEngine: FlutterEngine) {
  MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.app/biometric")
    .setMethodCallHandler { call, result ->
      if (call.method == "authenticate") {
        authenticateWithBiometrics { success ->
          result.success(success)
        }
      } else {
        result.notImplemented()
      }
    }
}

Offline-first архитектура

Мобильные пользователи часто работают в условиях плохого интернета. Offline-first значит: приложение функционирует без сети, а синхронизация происходит при восстановлении соединения.

Схема с drift (SQLite для Flutter) и очередью синхронизации:

// Локальная база — источник истины для UI
@DriftDatabase(tables: [Tasks, SyncQueue])
class AppDatabase extends _$AppDatabase { ... }

// Repository с offline-логикой
class TaskRepository {
  final AppDatabase _db;
  final TaskApiClient _api;
  final ConnectivityService _connectivity;

  Future<void> createTask(CreateTaskDto dto) async {
    // 1. Сохраняем локально немедленно
    final localTask = await _db.tasks.insertTask(dto);

    // 2. Если есть сеть — синхронизируем
    if (await _connectivity.isConnected()) {
      await _api.createTask(localTask);
    } else {
      // 3. Иначе добавляем в очередь
      await _db.syncQueue.enqueue(SyncOperation.create(localTask));
    }
  }

  // Вызывается при восстановлении соединения
  Future<void> processSyncQueue() async {
    final pending = await _db.syncQueue.getPending();
    for (final op in pending) {
      try {
        await _executeOperation(op);
        await _db.syncQueue.markComplete(op.id);
      } catch (e) {
        await _db.syncQueue.incrementRetryCount(op.id);
      }
    }
  }
}

CI/CD: Fastlane + GitLab CI

Fastlane

Fastlane автоматизирует подпись, сборку и отправку в стор. Настройка Fastfile:

# fastlane/Fastfile
default_platform(:ios)

platform :ios do
  lane :beta do
    increment_build_number
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end
end

platform :android do
  lane :deploy do
    gradle(task: "bundle", build_type: "Release")
    upload_to_play_store(track: "internal", aab: "build/app/outputs/bundle/release/app-release.aab")
  end
end

GitLab CI

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

flutter_test:
  stage: test
  image: cirrusci/flutter:stable
  script:
    - flutter pub get
    - flutter analyze
    - flutter test --coverage
  coverage: '/lines\s*:\s*(\d+\.\d+)%/'

build_android:
  stage: build
  image: cirrusci/flutter:stable
  script:
    - flutter pub get
    - flutter build apk --release
    - flutter build appbundle --release
  artifacts:
    paths:
      - build/app/outputs/
  only:
    - main

deploy_play_store:
  stage: deploy
  image: ruby:3.2
  script:
    - bundle install
    - bundle exec fastlane android deploy
  only:
    - tags

Чеклист публикации

App Store (iOS)

  • [ ] Сертификат дистрибуции и provisioning profile обновлены
  • [ ] CFBundleShortVersionString и CFBundleVersion увеличены
  • [ ] Скриншоты для всех размеров экрана (iPhone 6.7", iPad 12.9")
  • [ ] Privacy manifest (PrivacyInfo.xcprivacy) заполнен
  • [ ] Описание на русском и английском
  • [ ] Возрастной рейтинг заполнен
  • [ ] Тестирование на TestFlight завершено
  • [ ] App Review Information: тестовый аккаунт предоставлен

Google Play

  • [ ] versionCode увеличен в build.gradle
  • [ ] Подписан release keystore (не debug)
  • [ ] Сборка в формате AAB (не APK)
  • [ ] Скриншоты и feature graphic загружены
  • [ ] Data Safety section заполнена
  • [ ] Тестирование во внутреннем треке пройдено
  • [ ] Target API level соответствует требованиям (API 35+)

Итог

Flutter снижает стоимость мобильной разработки без жертв в пользовательском опыте. Ключ к успеху — правильное управление состоянием (Bloc или Riverpod), offline-first архитектура и автоматизированный CI/CD. Потратьте время на настройку пайплайна в начале проекта — это вернётся в виде уверенных и частых релизов.

О том, как выстроить CI/CD пайплайн для серверной части, читайте в статье GitLab CI/CD: настройка pipeline.