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.

