Flutter-приложение — это дерево виджетов на Dart: runApp получает корневой виджет, MaterialApp даёт тему и навигацию, а экран собирается из Scaffold, Column, Row, Expanded и других виджетов раскладки. Виджеты без изменяемых данных наследуют от StatelessWidget, а данные, которые меняются от действий пользователя, хранят в State у StatefulWidget и меняют через setState. Из Dart для старта нужны null safety (String?), классы с именованными параметрами и async/await.
После материала вы сможете
- написать на Dart класс с
final-полями, именованными параметрами и nullable-полемString?и асинхронную функцию сFutureиawait - создать проект командой
flutter create, проверить его черезflutter analyzeиflutter test - собрать экран из
Scaffold,Card,Column,Row,ExpandedиListViewбез переполнения на узком экране - выбрать между
StatelessWidgetиStatefulWidgetи обновить экран черезsetState
Что знать заранее
Проверено на
Flutter 3.47.7 (stable)Dart 3.13.5flutter_lints 6.0.0
Сверено с документацией: 11 октября 2026 г.
Содержание
Что соберём и что понадобится
Flutter — фреймворк Google для интерфейсов, которые из одного кода собираются под Android, iOS, веб и десктоп. Пишут на нём на языке Dart. В этом уроке мы соберём экран карточки услуги — тот же, что в уроке по Expo, чтобы подходы было удобно сравнить: название, описание, длительность, отметка «Премиум» и кнопка «Записаться», которая меняет вид после нажатия.
Урок рассчитан на тех, кто уже понимает функции и переменные хотя бы в одном языке и знает, чем мобильное приложение отличается от сайта. Весь код проверен на Flutter 3.47.7 и Dart 3.13.5 (актуальный stable на дату проверки): flutter analyze без замечаний, два widget-теста проходят, flutter build web собирается. На реальном телефоне код не запускался — это отмечено в журнале проверки.
Установите Flutter
Официальный быстрый старт предлагает VS Code (или другой редактор на базе Code OSS): поставьте Git и расширение Flutter, затем командой
Flutter: New ProjectвыберитеDownload SDK— редактор сам скачает SDK и предложит добавить его в PATH.Проверьте окружение
Выполните
flutter doctor. Предупреждение об отсутствии Android Studio можно игнорировать, если для начала вы запускаете приложение в браузере.Создайте проект
flutter create service_cardсоздаёт проект со стартовым счётчиком, папками платформ,pubspec.yaml(зависимости) и настроенным линтеромflutter_lints.Запустите
Документация советует для первого раза запустить приложение в Chrome: в VS Code —
Flutter: Select Device, затем F5; из терминала —flutter run -d chrome.
flutter doctor
flutter create service_card
cd service_card
flutter run -d chromeDart за 15 минут: типы, null safety, классы, async
Dart — язык со статической типизацией и выводом типов: var year = 1977 получит тип int из значения. Переменную, которую не будут переприсваивать, объявляют через final, а значение, известное на этапе компиляции, — через const. Для Flutter особенно важны четыре темы: null safety, классы с именованными параметрами, перечисления и асинхронность.
Null safety (безопасность от null) означает, что по умолчанию переменная не может быть null. Если значение может отсутствовать, к типу добавляют ?: String?. Документация Dart подчёркивает: начиная с Dart 3 (май 2023) null safety обязательна и «звуковая» — анализатор не даст обратиться к полю значения, которое может оказаться null, пока вы его не проверили. После проверки if (city == null) return … Dart сам «сужает» тип до String.
/// Уровень услуги: enum — фиксированный набор значений.
enum ServiceLevel { standard, premium }
class Service {
const Service({
required this.title,
required this.durationMinutes,
this.description,
this.level = ServiceLevel.standard,
});
final String title;
final int durationMinutes;
final String? description; // может отсутствовать — тип String?
final ServiceLevel level;
// Геттер — вычисляемое свойство, вызывается без скобок.
String get durationLabel => '$durationMinutes мин';
// ?? подставляет запасное значение, если слева null.
String get descriptionOrPlaceholder =>
description ?? 'Описание скоро появится';
bool get isPremium => level == ServiceLevel.premium;
}
/// Имитация запроса к серверу: через 300 мс возвращает список услуг.
Future<List<Service>> fetchServices() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
return const [
Service(
title: 'Чистка лица',
durationMinutes: 60,
description: 'Ультразвуковая чистка и маска по типу кожи.',
level: ServiceLevel.premium,
),
Service(title: 'Массаж спины', durationMinutes: 45),
];
}final / const
- Что делает
- Неизменяемое поле / значение времени компиляции
- В примере
- все поля
Service,const Service(…)
{required this.title}
- Что делает
- Именованный обязательный параметр конструктора
- В примере
Service(title: …, durationMinutes: …)
String?
- Что делает
- Тип, допускающий
null - В примере
description
??
- Что делает
- Значение по умолчанию вместо
null - В примере
descriptionOrPlaceholder
get
- Что делает
- Вычисляемое свойство
- В примере
durationLabel,isPremium
enum
- Что делает
- Фиксированный набор значений
- В примере
ServiceLevel
Future<T> + async/await
- Что делает
- Результат, который появится позже
- В примере
fetchServices()
Future — это обещание результата асинхронной операции: запроса к серверу, чтения файла, таймера. Внутри функции с пометкой async можно написать await и получить значение, не блокируя программу; ошибки ловятся обычным try/catch. Ниже — консольный скрипт, который использует модель. Его запускают командой dart run, Flutter для этого не нужен.
// Консольный скрипт: здесь print уместен, поэтому отключаем правило линтера.
// ignore_for_file: avoid_print
import 'package:service_card/models/service.dart';
// Именованный параметр в фигурных скобках; String? — может быть null.
String greet(String name, {String? city}) {
if (city == null) return 'Привет, $name!';
// После проверки выше Dart знает, что city — уже String, а не String?.
return 'Привет, $name из города ${city.toUpperCase()}!';
}
Future<void> main() async {
print(greet('Аружан'));
print(greet('Аружан', city: 'Алматы'));
final services = await fetchServices(); // ждём Future, не блокируя программу
for (final s in services) {
print('${s.title} — ${s.durationLabel}. ${s.descriptionOrPlaceholder}');
}
final premiumCount = services.where((s) => s.isPremium).length;
print('Премиум-услуг: $premiumCount');
}$ dart run bin/basics.dart
Привет, Аружан!
Привет, Аружан из города АЛМАТЫ!
Чистка лица — 60 мин. Ультразвуковая чистка и маска по типу кожи.
Массаж спины — 45 мин. Описание скоро появится
Премиум-услуг: 1Всё — виджет: дерево виджетов и метод build
Документация Flutter формулирует главный принцип так: почти всё во Flutter — виджет, даже способы раскладки. Текст, отступ, строка, карточка, тема и само приложение — виджеты, вложенные друг в друга. Получается дерево: у каждого виджета есть родитель, а дети передаются через child (один) или children (список).
- 01ServiceApp
- 02MaterialApp
- 03Scaffold
- 04ListView
- 05ServiceCard
Виджет — это описание интерфейса, а не сам интерфейс. Метод build возвращает новое описание каждый раз, когда фреймворку нужно перерисовать часть экрана, поэтому build должен быть быстрым и без побочных эффектов. Виджет без изменяемых данных наследуют от StatelessWidget: все его поля final, а конструктор можно сделать const, чтобы Flutter переиспользовал экземпляр.
import 'package:flutter/material.dart';
import 'service_screen.dart';
void main() {
runApp(const ServiceApp());
}
class ServiceApp extends StatelessWidget {
const ServiceApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Карточка услуги',
// Вся палитра Material 3 строится из одного «зерна».
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const ServiceScreen(),
);
}
}MaterialApp подключает Material Design: навигацию, тему, типографику. Material 3 во Flutter включён по умолчанию — свойство ThemeData.useMaterial3 равно true, а документация API предупреждает, что со временем этот флаг уберут и останется только Material 3. ColorScheme.fromSeed строит всю палитру — основной, вторичный, фоновые цвета и цвета «поверх» них — из одного исходного цвета.
Раскладка: Column, Row, Padding, Expanded, ListView
Column выстраивает детей сверху вниз, Row — слева направо. У каждой из них есть главная ось (вдоль которой идут дети) и поперечная: для Row главная ось горизонтальная, для Column — вертикальная. Выравнивание задают mainAxisAlignment и crossAxisAlignment. Padding добавляет отступ одному ребёнку, SizedBox — пустое место фиксированного размера, Card — поверхность Material со скруглением и тенью.
import 'package:flutter/material.dart';
import 'models/service.dart';
const demoService = Service(
title: 'Чистка лица',
durationMinutes: 60,
description: 'Ультразвуковая чистка и маска по типу кожи.',
level: ServiceLevel.premium,
);
class ServiceScreen extends StatelessWidget {
const ServiceScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Услуга')),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [ServiceCard(service: demoService)],
),
);
}
}
class ServiceCard extends StatelessWidget {
const ServiceCard({super.key, required this.service});
final Service service;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Expanded отдаёт названию всё свободное место в строке.
Expanded(
child: Text(service.title, style: textTheme.titleLarge),
),
if (service.isPremium) const Chip(label: Text('Премиум')),
],
),
const SizedBox(height: 8),
Text(service.descriptionOrPlaceholder, style: textTheme.bodyMedium),
const SizedBox(height: 16),
Row(
children: [
const Icon(Icons.schedule, size: 20),
const SizedBox(width: 4),
// Expanded, а не Spacer: на узком экране текст сожмётся,
// а кнопка останется у правого края.
Expanded(child: Text(service.durationLabel)),
const BookButton(),
],
),
],
),
),
);
}
}
class BookButton extends StatefulWidget {
const BookButton({super.key});
@override
State<BookButton> createState() => _BookButtonState();
}
class _BookButtonState extends State<BookButton> {
bool _booked = false;
@override
Widget build(BuildContext context) {
if (_booked) {
return OutlinedButton.icon(
onPressed: () => setState(() => _booked = false),
icon: const Icon(Icons.check),
label: const Text('Вы записаны'),
);
}
return FilledButton(
onPressed: () => setState(() => _booked = true),
child: const Text('Записаться'),
);
}
}Column / Row
- Когда брать
- Дети друг под другом / в строку
- Важное
- Не прокручиваются; лишнее вылезает за край
Expanded
- Когда брать
- Ребёнку
Row/Columnнужно оставшееся место - Важное
flexзадаёт долю, по умолчанию 1
Spacer
- Когда брать
- Пустое растягивающееся место
- Важное
- Сам не сжимает соседей
Padding / SizedBox
- Когда брать
- Отступ вокруг / пустой промежуток
- Важное
- Отступы —
EdgeInsets
ListView
- Когда брать
- Содержимое может не поместиться по высоте
- Важное
- Прокручивается сам
Scaffold
- Когда брать
- Каркас экрана Material
- Важное
appBar,body,floatingActionButton
Если содержимое не помещается, Flutter рисует жёлто-чёрную полосу у края и пишет ошибку A RenderFlex overflowed by … pixels. Мы столкнулись с этим при проверке: в первой версии нижней строки стояли текст, Spacer и кнопка, и тест с шириной экрана 320 логических пикселей упал с ошибкой A RenderFlex overflowed by 19 pixels on the right. Spacer занимает только свободное место, а когда его нет, никого не сжимает. Решение — обернуть в Expanded то, что может ужаться: текст длительности. С названием услуги та же история: без Expanded длинный заголовок в Row вылезает за экран.
StatefulWidget и setState: кнопка записи
Карточке не нужно ничего помнить: она получает Service и рисует его. А кнопка записи должна помнить, нажата ли она. Такой виджет наследуют от StatefulWidget. Он состоит из двух классов: сам виджет (неизменяемая конфигурация) и объект State, который живёт дольше отдельных вызовов build и хранит изменяемые данные — у нас поле _booked.
Чтобы изменить данные и обновить экран, их меняют внутри setState(() { … }). Документация называет этот вызов критически важным: он сообщает фреймворку, что состояние изменилось и виджет нужно перерисовать. Если просто присвоить _booked = true без setState, значение поменяется, но экран останется прежним.
StatelessWidget
- Все поля
final, данные приходят через конструктор - Перерисовывается, когда родитель передал новые данные
- Карточка, текст, иконка, заголовок экрана
StatefulWidget
- Изменяемые данные живут в объекте
State - Перерисовывается после
setState - Кнопка с переключением, поле ввода, счётчик
Документация даёт правило выбора, кто хранит состояние: если это данные пользователя (отмечен ли чекбокс, положение ползунка), ими лучше управлять в родителе; если это чисто визуальная деталь (анимация), — в самом виджете; если сомневаетесь — начинайте с родителя. Отметку «записан» в реальном приложении нужно отправить на сервер и показать на других экранах, поэтому со временем её поднимают выше. Этому посвящён следующий урок о состоянии и навигации.
Hot reload, тесты и проверка результата
Hot reload — горячая перезагрузка: Flutter подгружает изменённый код в уже запущенное приложение и перестраивает дерево виджетов, сохраняя состояние. Поменяйте seedColor или текст кнопки, сохраните файл — экран обновится, а нажатая кнопка останется нажатой. Работает только в режиме отладки.
Hot reload
- Как вызвать
- Сохранить файл в IDE или
rв терминалеflutter run - Что происходит
- Новый код, состояние сохраняется;
main()иinitState()не выполняются заново
Hot restart
- Как вызвать
Rв терминале или кнопка в IDE- Что происходит
- Приложение перезапускается, состояние сбрасывается
Полный перезапуск
- Как вызвать
- Остановить и снова
flutter run - Что происходит
- Нужен после изменений нативного кода (Kotlin, Swift)
Изменения в инициализаторах глобальных переменных и статических полей, а также превращение enum в обычный класс hot reload не подхватит — нужен hot restart. Проверять экран глазами удобно, но надёжнее закрепить поведение widget-тестом: он запускает виджет без устройства, находит элементы по тексту и нажимает кнопки.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:service_card/main.dart';
import 'package:service_card/models/service.dart';
import 'package:service_card/service_screen.dart';
void main() {
testWidgets('карточка показывает услугу, кнопка меняет состояние', (
tester,
) async {
await tester.pumpWidget(const ServiceApp());
expect(find.text('Чистка лица'), findsOneWidget);
expect(find.text('60 мин'), findsOneWidget);
expect(find.text('Премиум'), findsOneWidget);
await tester.tap(find.text('Записаться'));
await tester.pump(); // перерисовка после setState
expect(find.text('Вы записаны'), findsOneWidget);
expect(find.text('Записаться'), findsNothing);
});
testWidgets('длинное название на узком экране не переполняет строку', (
tester,
) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
const service = Service(
title: 'Очень длинное название услуги, которое не помещается в строку',
durationMinutes: 90,
level: ServiceLevel.premium,
);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: ServiceCard(service: service)),
),
);
expect(tester.takeException(), isNull); // нет ошибки переполнения
expect(find.text('Описание скоро появится'), findsOneWidget);
});
}$ flutter analyze
Analyzing service_card...
No issues found! (ran in 11.1s)
$ flutter test
00:00 +0: карточка показывает услугу, кнопка меняет состояние
00:00 +1: длинное название на узком экране не переполняет строку
00:00 +2: All tests passed!flutter analyzeпишетNo issues found!flutter test—All tests passed!- в приложении после нажатия «Записаться» кнопка меняется на «Вы записаны»
- на узком экране нет жёлто-чёрной полосы переполнения
Частые ошибки и упражнения
Жёлто-чёрная полоса, RenderFlex overflowed
- Причина
- Дети
Row/Columnне помещаются - Исправление
Expandedдля гибкого ребёнка илиListViewвместоColumn
Нажатие ничего не меняет на экране
- Причина
- Поле изменено без
setState - Исправление
- Менять данные внутри
setState(() { … })
The property 'length' can't be unconditionally accessed
- Причина
- Обращение к значению типа
String? - Исправление
- Проверить на
null, использовать??или?.
Замечание линтера avoid_print
- Причина
printв коде приложения- Исправление
- Для отладки —
debugPrint; в консольном скрипте правило отключают комментариемignore_for_file
Изменение не видно после hot reload
- Причина
- Код в
main(),initState()или в инициализаторе глобальной переменной - Исправление
- Сделать hot restart (
R)
Следующий шаг — урок о состоянии и навигации во Flutter: как поднять состояние выше, разделить его между экранами через ChangeNotifier и provider и перейти от списка к деталям с передачей параметров.
Как проверено5 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
lib/models/service.dart, bin/basics.dart
flutter analyze (No issues found) + dart run bin/basics.dart — вывод в статье скопирован из прогона; Docker ghcr.io/cirruslabs/flutter:stable, SDK переключён на тег 3.47.7 (Dart 3.13.5); RU и EN-версии проверены отдельно
- запущено, работает
lib/main.dart, lib/service_screen.dart (ServiceApp, ServiceCard, BookButton)
flutter analyze без замечаний, flutter build web — ✓ Built build/web; flutter run -d web-server — сервер поднялся, выведены клавиши r/R; первая версия с Spacer упала в тесте 320×640 (RenderFlex overflowed by 19 pixels) — исправлено на Expanded
- запущено, работает
test/widget_test.dart
flutter test: 2 теста, All tests passed (RU и EN)
- запущено, работает
Решения упражнений (TwoCardsScreen, FavoriteButton, ServiceListScreen)
lib/exercises.dart + test/exercises_test.dart (3 теста) в том же проекте, flutter analyze без замечаний
- запущено, работает
Утверждение «Text без Expanded в Row переполняет строку» и сообщение анализатора о String?
отдельный тест (A RenderFlex overflowed by 573 pixels) и dart analyze на файле с s.length для String? — текст ошибки сверен
- не запускалось
Запуск на Android/iOS-устройстве и в Chrome (flutter run -d chrome), hot reload вживую
в контейнере нет устройств и Chrome; сверено с документацией docs.flutter.dev/install/quick и /tools/hot-reload
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.